跳到主要内容

前端自动化测试:从单元测试到 Agent 交互场景全覆盖

Agent 产品的自动化测试和传统前端不同:LLM 响应不确定、流式输出难断言、SSE 断连需要模拟。传统测试方法论不够用,需要新的测试思路来覆盖异步状态转换、流式数据渲染、LLM 响应 mock 等场景。

测试金字塔:搞清楚该在哪里投入精力

很多人对测试的认知停留在"写几个单元测试就够了"。但实际项目中,不同层次的测试解决不同的问题,投入产出比也完全不同。

┌───────────┐
│ E2E 测试 │ ← 少量,覆盖核心流程
│ (5-10%) │ 慢,但最接近真实用户
┌┴───────────┴┐
│ 集成/组件测试 │ ← 适量,覆盖关键交互
│ (20-30%) │ 平衡速度和真实性
┌┴─────────────┴┐
│ 单元测试 │ ← 大量,覆盖工具函数和纯逻辑
│ (60-70%) │ 快,成本低,信心高
└─────────────────┘

对于 Agent 前端项目,我建议的比例是:

测试层级占比速度覆盖重点工具
单元测试60%毫秒级工具函数、状态逻辑、数据转换Vitest
组件测试25%秒级组件渲染、用户交互、事件处理Vitest + Testing Library
E2E 测试15%分钟级完整用户流程、跨页面交互Playwright

核心原则是:能用单元测试覆盖的逻辑,不要写成 E2E 测试。 单元测试跑得快、定位问题准、维护成本低。E2E 测试应该只用来验证"用户能完成核心操作"。

单元测试:Vitest 实战

为什么选 Vitest

如果你还在用 Jest,我建议认真考虑切到 Vitest。原因很简单:

  1. 与 Vite 原生集成,不需要额外的 transform 配置,ESM 支持开箱即用
  2. 兼容 Jest API,迁移成本极低
  3. 速度快,尤其是大项目,冷启动和热更新都比 Jest 快一个量级
  4. 对 TypeScript 的支持更好,不需要 ts-jest 这种中间层

项目配置

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
'**/index.ts',
],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
});
// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

// 每个测试用例后自动清理 DOM
afterEach(() => {
cleanup();
});

package.json 里加上脚本:

{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
}
}

工具函数测试

Agent 项目中有大量的工具函数需要测试,比如消息格式化、Token 计数、Markdown 渲染等:

// src/utils/message.ts
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
toolCalls?: ToolCall[];
isStreaming?: boolean;
}

export interface ToolCall {
id: string;
name: string;
arguments: string;
result?: string;
}

// 将消息列表转换为 LLM API 需要的格式
export function formatMessagesForLLM(messages: ChatMessage[]) {
return messages
.filter((msg) => msg.role !== 'system' || msg.id === messages[0]?.id)
.map((msg) => ({
role: msg.role,
content: msg.content,
...(msg.toolCalls && {
tool_calls: msg.toolCalls.map((tc) => ({
id: tc.id,
type: 'function' as const,
function: {
name: tc.name,
arguments: tc.arguments,
},
})),
}),
}));
}

// 从 SSE 流中解析消息片段
export function parseSSEChunk(chunk: string): { event: string; data: string }[] {
const lines = chunk.split('\n');
const events: { event: string; data: string }[] = [];
let currentEvent = 'message';
let currentData = '';

for (const line of lines) {
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith('data:')) {
currentData = line.slice(5).trim();
} else if (line === '' && currentData) {
events.push({ event: currentEvent, data: currentData });
currentEvent = 'message';
currentData = '';
}
}

if (currentData) {
events.push({ event: currentEvent, data: currentData });
}

return events;
}

// 估算 Token 数量(简化版,实际项目可用 tiktoken)
export function estimateTokenCount(text: string): number {
// 中文约 1.5 Token/字,英文约 0.25 Token/word
const chineseChars = (text.match(/[-鿿]/g) || []).length;
const englishWords = (text.match(/[a-zA-Z]+/g) || []).length;
const punctuation = (text.match(/[^\w-鿿\s]/g) || []).length;

return Math.ceil(chineseChars * 1.5 + englishWords * 0.25 + punctuation * 0.5);
}
// src/utils/__tests__/message.test.ts
import { describe, it, expect } from 'vitest';
import {
formatMessagesForLLM,
parseSSEChunk,
estimateTokenCount,
} from '../message';
import type { ChatMessage } from '../message';

describe('formatMessagesForLLM', () => {
it('should filter out system messages except the first one', () => {
const messages: ChatMessage[] = [
{
id: 'sys-1',
role: 'system',
content: 'You are a helpful assistant.',
timestamp: Date.now(),
},
{
id: 'sys-2',
role: 'system',
content: 'This should be removed.',
timestamp: Date.now(),
},
{
id: 'user-1',
role: 'user',
content: 'Hello',
timestamp: Date.now(),
},
];

const result = formatMessagesForLLM(messages);
expect(result).toHaveLength(2);
expect(result[0].role).toBe('system');
expect(result[1].role).toBe('user');
});

it('should include tool_calls when present', () => {
const messages: ChatMessage[] = [
{
id: 'asst-1',
role: 'assistant',
content: '',
timestamp: Date.now(),
toolCalls: [
{
id: 'call-1',
name: 'search_web',
arguments: JSON.stringify({ query: 'Vitest testing' }),
},
],
},
];

const result = formatMessagesForLLM(messages);
expect(result[0].tool_calls).toHaveLength(1);
expect(result[0].tool_calls![0].function.name).toBe('search_web');
});

it('should return empty array for empty input', () => {
expect(formatMessagesForLLM([])).toEqual([]);
});
});

describe('parseSSEChunk', () => {
it('should parse a standard SSE chunk', () => {
const chunk = 'event: message\ndata: {"text":"hello"}\n\n';
const events = parseSSEChunk(chunk);
expect(events).toEqual([
{ event: 'message', data: '{"text":"hello"}' },
]);
});

it('should handle multiple events in one chunk', () => {
const chunk =
'event: message\ndata: chunk1\n\nevent: tool_call\ndata: {"name":"search"}\n\n';
const events = parseSSEChunk(chunk);
expect(events).toHaveLength(2);
expect(events[0].event).toBe('message');
expect(events[1].event).toBe('tool_call');
});

it('should handle chunk without trailing double newline', () => {
const chunk = 'data: partial content';
const events = parseSSEChunk(chunk);
expect(events).toEqual([
{ event: 'message', data: 'partial content' },
]);
});
});

describe('estimateTokenCount', () => {
it('should estimate Chinese text correctly', () => {
const count = estimateTokenCount('你好世界');
// 4 Chinese chars * 1.5 = 6
expect(count).toBe(6);
});

it('should estimate English text correctly', () => {
const count = estimateTokenCount('hello world');
// 2 words * 0.25 = 0.5, ceil = 1
expect(count).toBe(1);
});

it('should handle mixed text', () => {
const count = estimateTokenCount('Hello 你好');
// 1 English word * 0.25 + 2 Chinese chars * 1.5 = 3.25, ceil = 4
expect(count).toBe(4);
});
});

组件测试:验证 UI 行为

测试 Chat 消息组件

Agent 产品的核心是对话界面,消息组件是最需要测试的:

// src/components/ChatMessage/ChatMessage.tsx
import React from 'react';
import type { ChatMessage as ChatMessageType } from '../../utils/message';

interface Props {
message: ChatMessageType;
onRetry?: (messageId: string) => void;
onCopy?: (content: string) => void;
}

export function ChatMessage({ message, onRetry, onCopy }: Props) {
const isUser = message.role === 'user';
const isAssistant = message.role === 'assistant';

return (
<div
className={`chat-message ${isUser ? 'user' : 'assistant'}`}
data-testid={`message-${message.id}`}
data-role={message.role}
>
<div className="message-avatar">
{isUser ? 'U' : 'A'}
</div>
<div className="message-content">
<div className="message-text">
{message.content}
{message.isStreaming && (
<span className="cursor-blink" data-testid="streaming-cursor">
|
</span>
)}
</div>
{message.toolCalls && message.toolCalls.length > 0 && (
<div className="tool-calls" data-testid="tool-calls">
{message.toolCalls.map((tc) => (
<div key={tc.id} className="tool-call-item">
<span className="tool-name">{tc.name}</span>
{tc.result && (
<span className="tool-result" data-testid="tool-result">
{tc.result}
</span>
)}
</div>
))}
</div>
)}
{isAssistant && !message.isStreaming && (
<div className="message-actions">
{onCopy && (
<button
onClick={() => onCopy(message.content)}
data-testid="copy-button"
>
复制
</button>
)}
{onRetry && (
<button
onClick={() => onRetry(message.id)}
data-testid="retry-button"
>
重试
</button>
)}
</div>
)}
</div>
</div>
);
}
// src/components/ChatMessage/__tests__/ChatMessage.test.tsx
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ChatMessage } from '../ChatMessage';
import type { ChatMessage as ChatMessageType } from '../../../utils/message';

describe('ChatMessage', () => {
const baseMessage: ChatMessageType = {
id: 'msg-1',
role: 'user',
content: '你好,帮我查一下天气',
timestamp: Date.now(),
};

it('should render user message correctly', () => {
render(<ChatMessage message={baseMessage} />);
const el = screen.getByTestId('message-msg-1');
expect(el).toHaveAttribute('data-role', 'user');
expect(screen.getByText('你好,帮我查一下天气')).toBeDefined();
});

it('should render assistant message with actions', () => {
const assistantMsg: ChatMessageType = {
...baseMessage,
id: 'msg-2',
role: 'assistant',
content: '今天北京天气晴朗,温度 25 度',
};

render(
<ChatMessage
message={assistantMsg}
onRetry={vi.fn()}
onCopy={vi.fn()}
/>,
);

expect(screen.getByTestId('copy-button')).toBeDefined();
expect(screen.getByTestId('retry-button')).toBeDefined();
});

it('should show streaming cursor when streaming', () => {
const streamingMsg: ChatMessageType = {
...baseMessage,
id: 'msg-3',
role: 'assistant',
content: '正在',
isStreaming: true,
};

render(<ChatMessage message={streamingMsg} />);
expect(screen.getByTestId('streaming-cursor')).toBeDefined();
});

it('should not show actions when streaming', () => {
const streamingMsg: ChatMessageType = {
...baseMessage,
id: 'msg-4',
role: 'assistant',
content: '正在',
isStreaming: true,
};

render(
<ChatMessage
message={streamingMsg}
onRetry={vi.fn()}
onCopy={vi.fn()}
/>,
);

expect(screen.queryByTestId('copy-button')).toBeNull();
expect(screen.queryByTestId('retry-button')).toBeNull();
});

it('should render tool calls', () => {
const toolMsg: ChatMessageType = {
...baseMessage,
id: 'msg-5',
role: 'assistant',
content: '',
toolCalls: [
{
id: 'call-1',
name: 'get_weather',
arguments: '{"city":"北京"}',
result: '晴朗, 25度',
},
],
};

render(<ChatMessage message={toolMsg} />);
expect(screen.getByTestId('tool-calls')).toBeDefined();
expect(screen.getByText('get_weather')).toBeDefined();
expect(screen.getByText('晴朗, 25度')).toBeDefined();
});

it('should call onRetry with message id', () => {
const onRetry = vi.fn();
const assistantMsg: ChatMessageType = {
...baseMessage,
id: 'msg-6',
role: 'assistant',
content: '回答内容',
};

render(
<ChatMessage message={assistantMsg} onRetry={onRetry} />,
);

fireEvent.click(screen.getByTestId('retry-button'));
expect(onRetry).toHaveBeenCalledWith('msg-6');
});

it('should call onCopy with content', () => {
const onCopy = vi.fn();
const assistantMsg: ChatMessageType = {
...baseMessage,
id: 'msg-7',
role: 'assistant',
content: '要复制的内容',
};

render(
<ChatMessage message={assistantMsg} onCopy={onCopy} />,
);

fireEvent.click(screen.getByTestId('copy-button'));
expect(onCopy).toHaveBeenCalledWith('要复制的内容');
});
});

测试流式打字效果组件

Agent 产品中流式渲染是核心体验,这个组件的测试非常重要:

// src/components/Typewriter/Typewriter.tsx
import React, { useState, useEffect, useRef } from 'react';

interface Props {
text: string;
speed?: number;
onComplete?: () => void;
}

export function Typewriter({ text, speed = 30, onComplete }: Props) {
const [displayedText, setDisplayedText] = useState('');
const indexRef = useRef(0);

useEffect(() => {
indexRef.current = 0;
setDisplayedText('');

if (!text) {
onComplete?.();
return;
}

const timer = setInterval(() => {
indexRef.current += 1;
if (indexRef.current >= text.length) {
setDisplayedText(text);
clearInterval(timer);
onComplete?.();
} else {
setDisplayedText(text.slice(0, indexRef.current));
}
}, speed);

return () => clearInterval(timer);
}, [text, speed]);

return <span data-testid="typewriter">{displayedText}</span>;
}
// src/components/Typewriter/__tests__/Typewriter.test.tsx
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import { Typewriter } from '../Typewriter';

describe('Typewriter', () => {
it('should render empty string immediately', () => {
render(<Typewriter text="" />);
expect(screen.getByTestId('typewriter').textContent).toBe('');
});

it('should call onComplete when text is empty', () => {
const onComplete = vi.fn();
render(<Typewriter text="" onComplete={onComplete} />);
expect(onComplete).toHaveBeenCalled();
});

it('should display text progressively', () => {
vi.useFakeTimers();
render(<Typewriter text="你好" speed={100} />);

act(() => {
vi.advanceTimersByTime(100);
});
expect(screen.getByTestId('typewriter').textContent).toBe('你');

act(() => {
vi.advanceTimersByTime(100);
});
expect(screen.getByTestId('typewriter').textContent).toBe('你好');

vi.useRealTimers();
});

it('should call onComplete after typing finishes', () => {
vi.useFakeTimers();
const onComplete = vi.fn();
render(<Typewriter text="Hi" speed={100} onComplete={onComplete} />);

act(() => {
vi.advanceTimersByTime(200);
});

expect(onComplete).toHaveBeenCalled();
vi.useRealTimers();
});

it('should reset when text changes', () => {
vi.useFakeTimers();
const { rerender } = render(
<Typewriter text="First" speed={100} />,
);

act(() => {
vi.advanceTimersByTime(50);
});

rerender(<Typewriter text="Second" speed={100} />);

act(() => {
vi.advanceTimersByTime(100);
});

expect(screen.getByTestId('typewriter').textContent).toBe('S');
vi.useRealTimers();
});
});

E2E 测试:Playwright 实战

Playwright 配置

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { open: 'never' }],
['json', { outputFile: 'test-results/results.json' }],
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});

核心对话流程测试

// e2e/chat-flow.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Chat Flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// 等待聊天界面加载完成
await page.waitForSelector('[data-testid="chat-input"]');
});

test('should send message and receive response', async ({ page }) => {
// 输入消息
const input = page.getByTestId('chat-input');
await input.fill('你好');
await page.getByTestId('send-button').click();

// 验证用户消息出现
await expect(page.getByTestId('message-list')).toContainText('你好');

// 等待助手回复(设置合理的超时)
await expect(
page.locator('[data-role="assistant"]').last(),
).toBeVisible({ timeout: 30000 });
});

test('should handle Enter key to send', async ({ page }) => {
const input = page.getByTestId('chat-input');
await input.fill('测试回车发送');
await input.press('Enter');

await expect(page.getByTestId('message-list')).toContainText(
'测试回车发送',
);
});

test('should disable send button while streaming', async ({ page }) => {
const input = page.getByTestId('chat-input');
await input.fill('流式响应测试');
await page.getByTestId('send-button').click();

// 流式响应期间按钮应该禁用
await expect(page.getByTestId('send-button')).toBeDisabled();

// 等待响应完成
await expect(page.getByTestId('streaming-cursor')).toHaveCount(0, {
timeout: 30000,
});

// 响应完成后按钮恢复
await expect(page.getByTestId('send-button')).toBeEnabled();
});

test('should display tool call results', async ({ page }) => {
const input = page.getByTestId('chat-input');
await input.fill('帮我搜索今天的新闻');
await page.getByTestId('send-button').click();

// 等待工具调用完成
await expect(page.getByTestId('tool-calls').first()).toBeVisible({
timeout: 30000,
});
});

test('should allow retry on last message', async ({ page }) => {
// 先发一条消息
const input = page.getByTestId('chat-input');
await input.fill('测试重试功能');
await page.getByTestId('send-button').click();

// 等待响应完成
await expect(
page.locator('[data-role="assistant"]').last(),
).toBeVisible({ timeout: 30000 });

// 获取助手消息数量
const assistantCount = await page
.locator('[data-role="assistant"]')
.count();

// 点击重试
await page.getByTestId('retry-button').last().click();

// 应该产生新的助手消息
await expect(
page.locator('[data-role="assistant"]'),
).toHaveCount(assistantCount + 1, { timeout: 30000 });
});
});

快照测试和视觉回归

// e2e/visual-regression.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Visual Regression', () => {
test('chat interface default state', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-testid="chat-input"]');

// 全页面截图对比
await expect(page).toHaveScreenshot('chat-default.png', {
maxDiffPixelRatio: 0.01,
});
});

test('chat with messages', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-testid="chat-input"]');

// 发送消息后截图
await page.getByTestId('chat-input').fill('你好');
await page.getByTestId('send-button').click();
await expect(
page.locator('[data-role="assistant"]').last(),
).toBeVisible({ timeout: 30000 });

await expect(page).toHaveScreenshot('chat-with-messages.png', {
maxDiffPixelRatio: 0.01,
});
});

test('dark mode chat interface', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto('/');
await page.waitForSelector('[data-testid="chat-input"]');

await expect(page).toHaveScreenshot('chat-dark-mode.png', {
maxDiffPixelRatio: 0.01,
});
});
});

Agent 场景测试:Mock LLM 响应

这是 Agent 前端测试中最有挑战性也最重要的部分。LLM 响应是不确定的、异步的、流式的,我们必须用 mock 来让测试变得可控。

Mock SSE 流式响应

// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

// 模拟 LLM 流式响应
export const handlers = [
http.post('/api/chat', async ({ request }) => {
const body = (await request.json()) as { messages: Array<{ content: string }> };
const lastMessage = body.messages[body.messages.length - 1]?.content || '';

// 根据输入内容返回不同的模拟响应
if (lastMessage.includes('搜索') || lastMessage.includes('search')) {
return HttpResponse.json(
{ error: null, tool_calls: [{ id: 'call-1', name: 'search', arguments: '{}' }] },
{ status: 200 },
);
}

// 正常流式响应
const stream = createMockSSEStream(lastMessage);
return new HttpResponse(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}),

http.post('/api/chat/stream', async ({ request }) => {
const body = (await request.json()) as { message: string };

const stream = createMockSSEStream(body.message);
return new HttpResponse(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
},
});
}),

// Mock WebSocket 连接(用于实时通信场景)
http.get('/api/ws', () => {
return new HttpResponse(null, { status: 101 });
}),
];

// 创建模拟 SSE 流的 ReadableStream
function createMockSSEStream(input: string): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
const responseText = generateMockResponse(input);
const chunks = responseText.split(/(?<=)/);

let index = 0;

return new ReadableStream({
async pull(controller) {
if (index >= chunks.length) {
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
controller.close();
return;
}

const chunk = chunks[index];
const sseData = `data: ${JSON.stringify({ text: chunk })}\n\n`;
controller.enqueue(encoder.encode(sseData));
index++;

// 模拟网络延迟
await new Promise((resolve) => setTimeout(resolve, 50));
},
});
}

function generateMockResponse(input: string): string {
if (input.includes('天气')) {
return '今天北京天气晴朗,气温 25 摄氏度,适合外出活动。';
}
if (input.includes('错误') || input.includes('error')) {
throw new Error('Mock LLM Error');
}
return `你说了"${input}"。这是一个模拟的 LLM 响应,用于测试目的。`;
}

测试 SSE 流式组件

// src/hooks/__tests__/useChatStream.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useChatStream } from '../useChatStream';

// Mock fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;

describe('useChatStream', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

function createMockReadableStream(chunks: string[]) {
const encoder = new TextEncoder();
let index = 0;

return new ReadableStream({
pull(controller) {
if (index >= chunks.length) {
controller.close();
return;
}
controller.enqueue(encoder.encode(chunks[index]));
index++;
},
});
}

it('should stream text chunks and accumulate content', async () => {
const sseChunks = [
'data: {"text":"你好"}\n\n',
'data: {"text":",世界"}\n\n',
'data: [DONE]\n\n',
];

mockFetch.mockResolvedValue({
ok: true,
body: createMockReadableStream(sseChunks),
});

const { result } = renderHook(() => useChatStream());

await act(async () => {
await result.current.sendMessage('你好');
});

expect(result.current.content).toBe('你好,世界');
expect(result.current.isStreaming).toBe(false);
});

it('should handle streaming state during response', async () => {
let resolveStream!: () => void;
const streamPromise = new Promise<ReadableStream>((resolve) => {
resolveStream = () => {
resolve(
createMockReadableStream([
'data: {"text":"处理中"}\n\n',
]),
);
};
});

mockFetch.mockReturnValue(
streamPromise.then((body) => ({
ok: true,
body,
})),
);

const { result } = renderHook(() => useChatStream());

act(() => {
result.current.sendMessage('测试');
});

// 在流式响应期间,isStreaming 应为 true
expect(result.current.isStreaming).toBe(true);

await act(async () => {
resolveStream();
// 等待微任务完成
await new Promise((r) => vi.advanceTimersByTime(100));
});
});

it('should handle fetch error gracefully', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));

const { result } = renderHook(() => useChatStream());

await act(async () => {
await result.current.sendMessage('测试错误');
});

expect(result.current.error).toBe('Network error');
expect(result.current.isStreaming).toBe(false);
});

it('should handle abort correctly', async () => {
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');

mockFetch.mockReturnValue(
new Promise(() => {}), // 永远不 resolve
);

const { result } = renderHook(() => useChatStream());

act(() => {
result.current.sendMessage('测试');
});

act(() => {
result.current.abort();
});

expect(abortSpy).toHaveBeenCalled();
expect(result.current.isStreaming).toBe(false);
});
});

测试异步状态转换

Agent 对话中有复杂的状态机,需要精确测试每个状态转换:

// src/hooks/__tests__/useChatState.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useChatState } from '../useChatState';

describe('useChatState - 状态机测试', () => {
beforeEach(() => {
vi.useFakeTimers();
});

it('should start in idle state', () => {
const { result } = renderHook(() => useChatState());
expect(result.current.state).toBe('idle');
expect(result.current.messages).toEqual([]);
});

it('should transition: idle -> streaming -> idle', async () => {
const { result } = renderHook(() => useChatState());

act(() => {
result.current.sendMessage('Hello');
});

expect(result.current.state).toBe('streaming');

await act(async () => {
result.current.simulateComplete('Response');
});

expect(result.current.state).toBe('idle');
expect(result.current.messages).toHaveLength(2);
});

it('should transition: idle -> error -> idle (after retry)', async () => {
const { result } = renderHook(() => useChatState());

act(() => {
result.current.sendMessage('trigger error');
});

await act(async () => {
result.current.simulateError('API Error');
});

expect(result.current.state).toBe('error');
expect(result.current.error).toBe('API Error');

// 重试
act(() => {
result.current.retry();
});

expect(result.current.state).toBe('streaming');
});

it('should not send message while streaming', () => {
const { result } = renderHook(() => useChatState());

act(() => {
result.current.sendMessage('First');
});
expect(result.current.state).toBe('streaming');

act(() => {
result.current.sendMessage('Second');
});

// 第二条消息不应该被发送
expect(result.current.messages).toHaveLength(2); // user + assistant placeholder
});

it('should clear error when sending new message', async () => {
const { result } = renderHook(() => useChatState());

// 先触发错误
act(() => {
result.current.sendMessage('trigger error');
});
await act(async () => {
result.current.simulateError('API Error');
});
expect(result.current.state).toBe('error');

// 发送新消息应该清除错误
act(() => {
result.current.sendMessage('New message');
});
expect(result.current.error).toBeNull();
expect(result.current.state).toBe('streaming');
});

it('should handle tool call state transitions', async () => {
const { result } = renderHook(() => useChatState());

act(() => {
result.current.sendMessage('search something');
});

// 进入工具调用状态
await act(async () => {
result.current.simulateToolCall({
id: 'call-1',
name: 'search',
status: 'calling',
});
});
expect(result.current.state).toBe('tool_calling');

// 工具调用完成
await act(async () => {
result.current.simulateToolCall({
id: 'call-1',
name: 'search',
status: 'completed',
result: '搜索结果',
});
});

// 继续流式响应
expect(result.current.state).toBe('streaming');
});
});

CI 集成:让测试自动化

CI 流水线设计

┌─────────────────────────────────────────────────────────────────┐
│ CI Pipeline 流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 代码推送 │───>│ Lint │───>│ 单元测试 │───>│ 组件测试 │ │
│ │ (push/ │ │ + Type │ │ Vitest │ │ Vitest │ │
│ │ PR) │ │ Check │ │ --run │ │ --run │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │
│ v │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 部署 │<───│ E2E 测试 │<───│ Build │<───│ Coverage │ │
│ │ Preview │ │ Playwright│ │ Check │ │ Report │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘

GitHub Actions 配置

# .github/workflows/test.yml
name: Test

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

jobs:
lint-and-type:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck

unit-test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run test:run -- --coverage
- name: Upload Coverage
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/

e2e-test:
name: E2E Tests
runs-on: ubuntu-latest
needs: [lint-and-type, unit-test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build
- run: npx playwright test --reporter=json
- name: Upload Test Results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: test-results/

visual-regression:
name: Visual Regression
runs-on: ubuntu-latest
needs: [lint-and-type]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --update-snapshots
- name: Compare Screenshots
run: |
if [ -n "$(git diff --name-only '*.png')" ]; then
echo "Visual changes detected!"
git diff --stat '*.png'
fi

关于 Mock WebServer 的实践

在 E2E 测试中,我们通常不 mock LLM 响应,而是启动一个 mock server。这样测试更接近真实场景,同时又不会产生真实 API 调用费用:

// e2e/fixtures/mock-server.ts
import { createServer, type Server } from 'http';

export function startMockLLMServer(port = 3099): Server {
const server = createServer((req, res) => {
if (req.url === '/api/chat/stream' && req.method === 'POST') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});

// 模拟延迟后逐字输出
const text = '这是来自 Mock Server 的响应内容。';
let index = 0;

const interval = setInterval(() => {
if (index >= text.length) {
res.write('data: [DONE]\n\n');
res.end();
clearInterval(interval);
return;
}

const chunk = text[index];
res.write(`data: ${JSON.stringify({ text: chunk })}\n\n`);
index++;
}, 30);

req.on('close', () => clearInterval(interval));
} else {
res.writeHead(404);
res.end('Not Found');
}
});

server.listen(port);
return server;
}

// Playwright test fixture
import { test as base } from '@playwright/test';
import { startMockLLMServer } from './mock-server';
import type { Server } from 'http';

export const test = base.extend<{ mockServer: Server }>({
mockServer: async ({}, use) => {
const server = startMockLLMServer();
await use(server);
server.close();
},
});

export { expect } from '@playwright/test';

覆盖率策略:不是越高越好

很多人追求 100% 的测试覆盖率,但这其实是个陷阱。覆盖率是一个指标,不是目标。我的经验是分层设定阈值:

// vitest.config.ts 中的覆盖率配置
coverage: {
thresholds: {
statements: 80, // 语句覆盖
branches: 75, // 分支覆盖
functions: 80, // 函数覆盖
lines: 80, // 行覆盖
},
// 排除不需要覆盖的文件
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
'**/types.ts', // 纯类型定义
'**/index.ts', // 入口文件通常只是 re-export
'**/*.stories.tsx', // Storybook 文件
],
}

实际操作中的原则:

代码类型覆盖率目标原因
工具函数 (utils)95%+纯逻辑,容易测试,价值高
React 组件80%+核心交互必须覆盖
自定义 Hooks85%+状态逻辑是 Agent 前端的核心
API 层70%+依赖外部服务,重点测试错误处理
样式/配置0%不需要测试

不要为了覆盖率指标去写没有意义的测试。一个没有断言的测试比没有测试更糟糕,因为它给你虚假的安全感。

常见的坑

坑一:异步操作没有正确等待

这是最常见也是最隐蔽的坑。尤其是测试 Agent 流式响应时,很多人直接 render 之后就断言,结果测试时灵时不灵。

// 错误写法:没有等待异步操作完成
it('bad test', async () => {
render(<ChatStream />);
// 此时流式内容可能还没渲染出来
expect(screen.getByText('流式内容')).toBeDefined();
});

// 正确写法:使用 waitFor 等待状态变化
it('good test', async () => {
render(<ChatStream />);
await waitFor(() => {
expect(screen.getByText('流式内容')).toBeDefined();
});
});

坑二:Mock 没有正确清理

如果在测试之间共享 mock 状态,会导致测试互相污染,出现极其难以排查的 flaky test。

// 错误:mock 状态没有在测试之间清理
let mockData = {};

beforeEach(() => {
mockData = {};
});

// 正确:每个测试都创建独立的 mock
beforeEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});

坑三:假 timers 和真实异步混用

使用 vi.useFakeTimers() 时,如果没有正确处理 PromisesetTimeout 的关系,测试会卡死或者出现意外行为。

// 错误:fake timers 下没有手动推进异步
vi.useFakeTimers();
render(<Typewriter text="Hi" speed={100} />);
// 这里不会自动触发 timer
expect(screen.getByTestId('typewriter').textContent).toBe('H');

// 正确:手动推进时间
vi.useFakeTimers();
render(<Typewriter text="Hi" speed={100} />);
act(() => {
vi.advanceTimersByTime(100);
});
expect(screen.getByTestId('typewriter').textContent).toBe('H');

坑四:E2E 测试中硬编码等待时间

// 错误:硬编码等待
await page.waitForTimeout(5000); // 浪费时间且不可靠

// 正确:等待具体条件
await page.waitForSelector('[data-testid="response-complete"]');
// 或者
await expect(page.getByText('响应完成')).toBeVisible({ timeout: 10000 });

坑五:快照测试过度使用

快照测试(snapshot testing)看起来很方便,但实际上是"懒惰的测试"。它只检查 UI 是否变了,不检查 UI 是否正确。一旦快照文件更新,review 的人通常也不会认真对比每一个差异。

// 不推荐:只做快照
it('renders correctly', () => {
const { container } = render(<ChatMessage message={msg} />);
expect(container).toMatchSnapshot();
});

// 推荐:断言具体行为
it('renders correctly', () => {
render(<ChatMessage message={msg} />);
expect(screen.getByTestId('message-content')).toHaveTextContent(msg.content);
expect(screen.getByRole('button', { name: '重试' })).toBeEnabled();
});

参考资料