AI 教程系列-TUI 应用开发教程13-TUI 应用测试

35 阅读6分钟

TUI应用开发教程完全由ai生成,所使用的提示词存放在:github.com/hhk-png/cyc… code。

可以直接查看 github.com/hhk-png/cyc… 来阅读全部的章节

文章封面来自:bing.ioliu.cn/。

第十三章 TUI 应用测试

本章衔接: 上一章学习了 MCP 的高级特性和生产实践。本章聚焦 TUI 应用特有的测试策略,涵盖单元测试、集成测试、快照测试和 CI/CD 集成。测试是生产级 TUI 应用的关键环节。

13.1 TUI 测试的挑战

TUI 应用测试具有特殊性——它涉及终端交互、ANSI 序列和异步事件流。

挑战说明解决方案
终端依赖测试依赖于终端模拟器特性使用虚拟终端进行隔离测试
异步事件流LLM 流式输出需要时间维度验证使用 FakeTimers 控制时间
ANSI 序列渲染结果含大量控制字符剥离 ANSI 后校验内容
组件树blessed 组件树难以直接断言对逻辑层做单元测试
用户交互键盘、鼠标事件的模拟事件驱动测试

测试策略金字塔

        /\
       /  \         E2E 测试(少)—— 完整用户流程
      /    \
     /──────\       集成测试(中)—— 组件协作
    /        \
   /──────────\  单元测试(多)—— 逻辑层
  /            \
 /──────────────\ 静态分析 —— 类型检查

TUI 测试策略建议:

  • 底层逻辑(状态机、Markdown 渲染、Mock LLM):100% 单元测试覆盖
  • 组件渲染(消息卡片、工具卡片):快照测试
  • 用户流程(发送消息、取消生成):集成测试
  • 终端兼容:多终端模拟器的 E2E 测试

13.2 单元测试

将纯逻辑与 UI 渲染分离,对逻辑层进行标准的单元测试。

13.2.1 状态机测试

import { describe, it, expect } from 'vitest';

class StateMachine {
  private currentState: AppState = 'idle';

  private static readonly TRANSITIONS: Record<AppState, AppState[]> = {
    idle:        ['thinking'],
    thinking:    ['streaming', 'tool_call', 'error'],
    streaming:   ['tool_call', 'tool_result', 'error', 'done'],
    tool_call:   ['tool_result', 'error'],
    tool_result: ['streaming', 'error'],
    done:        ['idle'],
    error:       ['idle'],
  };

  transition(to: AppState): boolean {
    const allowed = StateMachine.TRANSITIONS[this.currentState];
    if (!allowed?.includes(to)) return false;
    this.currentState = to;
    return true;
  }

  canTransition(to: AppState): boolean {
    return StateMachine.TRANSITIONS[this.currentState]?.includes(to) ?? false;
  }

  get state(): AppState { return this.currentState; }
  reset(): void { this.currentState = 'idle'; }
}

describe('StateMachine', () => {
  it('should allow valid state transitions', () => {
    const sm = new StateMachine();
    expect(sm.transition('thinking')).toBe(true);
    expect(sm.state).toBe('thinking');
  });

  it('should reject invalid state transitions', () => {
    const sm = new StateMachine();
    expect(sm.transition('error')).toBe(false);
    expect(sm.state).toBe('idle');
  });

  it('should track a complete weather query flow', () => {
    const sm = new StateMachine();
    const flow = ['thinking', 'streaming', 'tool_call', 'tool_result', 'streaming', 'done', 'idle'];
    for (const state of flow) {
      expect(sm.transition(state as AppState)).toBe(true);
    }
    expect(sm.state).toBe('idle');
  });

  it('should check transitions without changing state', () => {
    const sm = new StateMachine();
    expect(sm.canTransition('thinking')).toBe(true);
    expect(sm.canTransition('error')).toBe(false);
    expect(sm.state).toBe('idle');
  });

  it('should track error flow correctly', () => {
    const sm = new StateMachine();
    sm.transition('thinking');
    sm.transition('streaming');
    sm.transition('error');
    expect(sm.state).toBe('error');
    sm.transition('idle');
    expect(sm.state).toBe('idle');
  });
});

13.2.2 Markdown 渲染器测试

describe('MarkdownRenderer', () => {
  it('should render heading with cyan-bg', () => {
    const result = renderMarkdown('# Hello');
    expect(result).toContain('cyan-bg');
    expect(result).toContain('Hello');
  });

  it('should render code block with black-bg', () => {
    const result = renderMarkdown('```ts\nconst x = 1;\n```');
    expect(result).toContain('black-bg');
    expect(result).toContain('const x = 1;');
  });

  it('should escape curly braces in JSON', () => {
    const result = renderMarkdown('{"key": "value"}');
    expect(result).not.toContain('{key}');
  });

  it('should render ordered list', () => {
    const result = renderMarkdown('1. First\n2. Second');
    expect(result).toContain('1.');
    expect(result).toContain('2.');
  });

  it('should render blockquote', () => {
    const result = renderMarkdown('> A quote');
    expect(result).toContain('yellow-fg');
    expect(result).toContain('A quote');
  });

  it('should handle empty input gracefully', () => {
    expect(renderMarkdown('').trim()).toBe('');
  });

  it('should handle nested bold+italic', () => {
    const result = renderMarkdown('***bold italic***');
    expect(result).toContain('bold');
    expect(result).toContain('italic');
  });
});

13.2.3 Mock LLM 流测试

describe('mockLLMStream', () => {
  it('should emit events in correct order', async () => {
    const events: string[] = [];
    for await (const event of mockLLMStream('hello')) {
      events.push(event.type);
    }
    expect(events[0]).toBe('thinking');
    expect(events[events.length - 1]).toBe('done');
  });

  it('weather query should include tool_call and tool_result', async () => {
    const events: string[] = [];
    for await (const event of mockLLMStream('北京天气')) {
      events.push(event.type);
    }
    expect(events).toContain('tool_call');
    expect(events).toContain('tool_result');
  });

  it('error scenario should emit error event', async () => {
    const events: string[] = [];
    for await (const event of mockLLMStream('制造错误')) {
      events.push(event.type);
    }
    expect(events).toContain('error');
  });

  it('should handle empty input without crashing', async () => {
    const events: LLMEvent[] = [];
    for await (const event of mockLLMStream('')) {
      events.push(event);
    }
    expect(events.length).toBeGreaterThanOrEqual(2);
  });

  it('code generation should produce text content', async () => {
    const events: LLMEvent[] = [];
    for await (const event of mockLLMStream('写代码')) {
      events.push(event);
    }
    const textEvents = events.filter(e => e.type === 'text') as Array<{ type: 'text'; content: string }>;
    const fullContent = textEvents.map(e => e.content).join('');
    expect(fullContent).toContain('TypeScript');
  });
});

13.2.4 输入验证测试

describe('InputValidator', () => {
  it('should reject empty input', () => {
    const result = InputValidator.validate('');
    expect(result.valid).toBe(false);
    expect(result.errors).toContain('输入不能为空');
  });

  it('should reject overly long input', () => {
    const longInput = 'a'.repeat(2001);
    const result = InputValidator.validate(longInput);
    expect(result.valid).toBe(false);
  });

  it('should sanitize ANSI sequences', () => {
    const result = InputValidator.validate('hello\x1b[31mworld');
    expect(result.valid).toBe(true);
    expect(result.warnings.length).toBeGreaterThan(0);
  });

  it('should pass normal input', () => {
    const result = InputValidator.validate('北京今天天气怎么样?');
    expect(result.valid).toBe(true);
    expect(result.errors.length).toBe(0);
  });
});

13.3 异步流测试

AsyncGenerator 是 TUI 中处理 LLM 流式输出的核心机制。

describe('AsyncGenerator 流测试', () => {
  it('should support cancellation via AbortController', async () => {
    const ac = new AbortController();
    const received: string[] = [];
    setTimeout(() => ac.abort(), 30);

    try {
      for await (const event of mockLLMStream('long message', ac.signal)) {
        received.push(event.type);
        await new Promise(r => setTimeout(r, 20));
      }
    } catch (err) {
      expect((err as Error).name).toBe('AbortError');
    }
    expect(received.length).toBeGreaterThan(0);
    expect(received.length).toBeLessThan(10);
  });

  it('tool call events should contain complete parameters', async () => {
    const events: LLMEvent[] = [];
    for await (const event of mockLLMStream('北京天气')) {
      events.push(event);
    }
    const toolCall = events.find(e => e.type === 'tool_call');
    expect(toolCall).toBeDefined();
    if (toolCall && toolCall.type === 'tool_call') {
      expect(toolCall.name).toBe('get_weather');
      expect(toolCall.args).toHaveProperty('city');
    }
  });
});

13.4 快照测试

快照测试通过捕获 TUI 渲染输出,与预先存储的基准快照对比,检测非预期变化。

13.4.1 虚拟终端

import { EventEmitter } from 'events';

/**
 * 虚拟终端 —— 用于集成测试
 * 模拟 blessed 屏幕,避免真正的终端依赖
 */
class VirtualTerminal {
  public screen: Widgets.Screen;
  private output = '';

  constructor(width: number = 80, height: number = 24) {
    this.screen = blessed.screen({
      smartCSR: true,
      fullUnicode: true,
      terminal: 'xterm-256color',
      input: new EventEmitter() as any,
      output: {
        write: (data: string) => { this.output += data; },
        columns: width, rows: height,
        on: () => {},
      } as any,
      log: '/dev/null',
    });
  }

  /** 模拟按键 */
  pressKey(key: string, ctrl: boolean = false): void {
    this.screen.emit('keypress', null, {
      name: key, ctrl, meta: false, shift: false,
      full: ctrl ? `C-${key}` : key,
    } as any);
  }

  /** 获取屏幕输出(剥离 ANSI 序列) */
  getOutput(): string {
    return this.output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
  }

  /** 获取原始输出(含 ANSI 序列) */
  getRawOutput(): string { return this.output; }
}

13.4.2 快照测试示例

describe('ChatUI 快照测试', () => {
  it('initial layout should match snapshot for 80x24', () => {
    const ui = new ChatTUI();
    const output = captureScreen(ui.screen);
    expect(output).toMatchSnapshot('initial-layout-80x24');
  });

  it('tool call card should display correctly', () => {
    const ui = new ChatTUI();
    ui.showToolCall('get_weather', { city: 'Beijing' });
    const output = captureScreen(ui.screen);
    expect(output).toContain('🔧');
    expect(output).toContain('get_weather');
    expect(output).toMatchSnapshot('tool-call-card');
  });

  it('error card should display correctly', () => {
    const ui = new ChatTUI();
    ui.showError('服务暂不可用');
    const output = captureScreen(ui.screen);
    expect(output).toContain('❌');
    expect(output).toContain('服务暂不可用');
    expect(output).toMatchSnapshot('error-card');
  });

  it('AI response should include correct role identifier', () => {
    const ui = new ChatTUI();
    ui.addMessage({ role: 'user', content: 'Hello' });
    ui.addMessage({ role: 'assistant', content: 'Hi there!' });
    const output = captureScreen(ui.screen);
    expect(output).toContain('🤖');
    expect(output).toMatchSnapshot('ai-response');
  });
});

13.4.3 快照管理建议

  • 每次运行 vitest --update 自动更新快照
  • 代码审查时务必检查快照变更
  • 为不同终端尺寸(80x24、120x40)分别维护快照
  • __snapshots__ 目录纳入版本控制

13.5 集成测试

describe('ChatTUI 集成测试', () => {
  it('sending message should transition state to thinking', () => {
    const ui = new ChatTUI();
    ui.sendMessage('tell me a joke');
    expect(ui.state).toBe('thinking');
  });

  it('status bar should show error on error', () => {
    const ui = new ChatTUI();
    ui.showError('Network error');
    const statusContent = ui.statusBar.getContent();
    expect(statusContent).toContain('错误');
  });

  it('input box should be cleared after send', () => {
    const ui = new ChatTUI();
    ui.inputBox.setValue('test message');
    ui.handleUserInput();
    expect(ui.inputBox.getValue()).toBe('');
  });

  it('error retry flow should work', async () => {
    const ui = new ChatTUI();
    ui.sendMessage('make error');
    await new Promise(r => setTimeout(r, 200));
    expect(ui.state).toBe('error');
    // Press R to retry
    ui.screen.emit('keypress', null, { name: 'r', ctrl: false } as any);
    expect(ui.lastMessage).toBe('make error');
  });
});

13.6 Spinner 时序测试

describe('Spinner 时序测试', () => {
  it('spinner should cycle frames at correct interval', () => {
    vi.useFakeTimers();
    const spinner = new Spinner();
    const frames: string[] = [];

    spinner.start();
    for (let i = 0; i < 4; i++) {
      frames.push(spinner.currentFrame);
      vi.advanceTimersByTime(80);
    }
    spinner.stop();

    expect(frames).toEqual(['⠋', '⠙', '⠹', '⠸']);
    vi.useRealTimers();
  });

  it('spinner should stop updating after stop() is called', () => {
    vi.useFakeTimers();
    const spinner = new Spinner();
    spinner.start();
    spinner.stop();
    const frameBefore = spinner.currentFrame;
    vi.advanceTimersByTime(1000);
    expect(spinner.currentFrame).toBe(frameBefore);
    vi.useRealTimers();
  });
});

13.7 测试覆盖率

覆盖率目标

层级目标覆盖率重点覆盖内容
状态机逻辑100%所有状态转换路径
Markdown 渲染100%所有标记类型
事件处理>90%所有事件类型的分发
错误处理100%所有异常分支
集成测试>80%主要用户操作流

vitest 配置

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov', 'html'],
      include: ['src/**/*.ts'],
      exclude: ['src/**/*.test.ts', 'src/examples/**'],
      thresholds: {
        branches: 80,
        functions: 85,
        lines: 85,
        statements: 85,
      },
    },
  },
});

13.8 CI/CD 集成

GitHub Actions

# .github/workflows/tui-ci.yml
name: TUI CI Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node-version: [18, 20, 22]

    runs-on: ${{ matrix.os }}

    steps:
      - uses: actions/checkout@v4
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - name: Install dependencies
        run: npm ci
      - name: Type check
        run: npx tsc --noEmit
      - name: Run unit & integration tests
        run: npx vitest --coverage
      - name: Run snapshot tests
        run: npx vitest --run src/__tests__/snapshots/
      - name: Upload coverage report
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage/lcov.info
          flags: ${{ matrix.os }}

本地运行测试

# 完整测试套件
npx vitest --run --coverage

# 仅快照测试
npx vitest --run src/__tests__/snapshots/

# 更新快照
npx vitest --update

# 监视模式(开发时使用)
npx vitest

13.9 测试策略总结

基础功能测试:
□ 状态机转换是否覆盖所有路径?
□ 所有 AsyncGenerator 分支是否都测试到?
□ 空输入是否被正确处理?
□ 输入过长是否被截断?

UI 渲染测试:
□ 每种角色消息是否正确渲染?
□ Spinner 是否在 thinking 状态启动?
□ 工具卡片的三种状态是否正确展示?
□ 错误卡片是否在所有错误场景展示?

边界情况测试:
□ 连续快速发送消息不会导致 race condition?
□ 窗口 resize 后布局是否正确?
□ 终端不支持 TrueColor 时的降级?
□ 超长 AI 回答是否截断?
□ 超过 100 条历史消息不会泄漏?

错误恢复测试:
□ 网络超时是否显示重试按钮?
□ 用户取消生成是否干净退出?
□ 进程崩溃后终端是否完全恢复?
□ ANSI 注入是否被正确过滤?

测试设计原则

原则说明
隔离逻辑层状态机、渲染器、验证器可独立于 blessed 进行单元测试
Mock 终端使用 VirtualTerminal 模拟终端环境
控制时间使用 FakeTimers 控制动画和流式输出的时序
快照+断言快照检测整体变化,具体断言验证特定内容
CI 集成测试在 CI 中自动运行,覆盖多平台

实践:llm-chat.ts 中的 InputHistory 类编写完整的单元测试,覆盖边界情况(空历史、历史上限、重复消息)。

上一章: 第十二章:高级 MCP 与生产实践

下一步: 第十四章:总结与进阶实践