1. 为什么写测试
回归保护修改代码后,测试能立即告诉你是否破坏了原有功能,无需手动点击每个页面验证。
重构信心有测试覆盖的代码可以大胆重构内部实现,只要测试通过,外部行为就没变化。
活文档好的测试用例描述了组件应该如何行为,是比注释更可靠的文档(测试会因代码变化而失败,注释不会)。
测试金字塔
测试分为三层,越靠底层数量越多、速度越快、成本越低:
| 层级 | 测试对象 | 工具 | 数量 | 速度 |
|---|---|---|---|---|
| 单元测试(Unit) | 纯函数、工具函数、Hook | Vitest | 最多 | 极快(毫秒) |
| 集成测试(Integration) | 组件 + 依赖交互 | Testing Library | 中等 | 较快(秒) |
| 端到端测试(E2E) | 完整用户流程(真实浏览器) | Playwright / Cypress | 最少 | 慢(分钟) |
2. 测试工具栈介绍
- Vitest:测试运行器(Test Runner)。负责发现测试文件、执行测试、报告结果。基于 Vite,速度极快,API 与 Jest 兼容。
- @testing-library/react:React 组件渲染工具。核心理念:像用户一样测试(通过可见文本、角色查询,而不是组件内部实现)。
- @testing-library/jest-dom:扩展断言(matcher)。提供
toBeInTheDocument()toHaveValue()等针对 DOM 的断言。 - @testing-library/user-event:模拟用户操作(点击、输入、键盘)。比
fireEvent更接近真实用户行为。 - jsdom:在 Node.js 中模拟浏览器 DOM 环境,让组件测试无需真实浏览器即可运行。
3. 安装与配置
# 安装测试依赖
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
// vite.config.ts — 添加 test 配置
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom', // 使用 jsdom 模拟浏览器环境
globals: true, // 无需 import describe/it/expect
setupFiles: ['./src/setupTests.ts'], // 每个测试文件前执行
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
},
},
});
// src/setupTests.ts — 全局配置
import '@testing-library/jest-dom';
// 导入后,所有测试文件都能使用 toBeInTheDocument() 等断言
// package.json — 添加测试脚本
{
"scripts": {
"test": "vitest", // 监听模式
"test:run": "vitest run", // 运行一次(CI 用)
"test:ui": "vitest --ui", // 浏览器 UI 界面
"coverage": "vitest run --coverage"
}
}
4. 测试基础概念
// 基本结构:describe 分组 + it/test 描述用例
describe('formatPrice 函数', () => {
// beforeEach 在每个测试前执行(初始化状态)
beforeEach(() => { /* 重置状态 */ });
afterEach(() => { /* 清理(如清除 mock)*/ });
// it / test 是同一函数的别名
it('应该格式化整数价格', () => {
// AAA 模式:Arrange(准备) / Act(行动) / Assert(断言)
const price = 1234; // Arrange
const result = formatPrice(price); // Act
expect(result).toBe('¥1,234.00'); // Assert
});
it('应该处理零值', () => {
expect(formatPrice(0)).toBe('¥0.00');
});
it.todo('应该处理负数'); // 标记待实现的测试
});
5. 单元测试:纯函数
// utils/string.ts — 被测试的函数
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + '...';
}
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '') // 移除特殊字符
.replace(/\s+/g, '-'); // 空格换连字符
}
// utils/string.test.ts — 对应测试文件
import { truncate, slugify } from './string';
describe('truncate', () => {
it('文本短于限制时原样返回', () => {
expect(truncate('hello', 10)).toBe('hello');
});
it('文本超出限制时截断并加省略号', () => {
expect(truncate('hello world', 5)).toBe('hello...');
});
it('等于限制长度时不截断', () => {
expect(truncate('hello', 5)).toBe('hello');
});
});
describe('slugify', () => {
it('转换为小写并用连字符连接', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
});
6. 组件测试:Testing Library
Testing Library 的核心原则:不测试实现细节(如组件的 state、内部方法),而是测试用户看到和交互的内容。
查询优先级
按推荐程度从高到低,优先使用用户能感知的查询方式:
getByRole— 通过 ARIA 角色查找(按钮、链接、标题等)首选getByLabelText— 通过表单 label 文字查找getByPlaceholderText— 通过 placeholder 查找getByText— 通过可见文字查找getByDisplayValue— 通过 input 当前值查找getByAltText— 通过图片 alt 查找getByTestId— 通过 data-testid 属性查找 最后选择
getBy vs queryBy vs findBy
| 前缀 | 找不到时 | 找到多个时 | 适合场景 |
|---|---|---|---|
getBy | 抛出错误 | 抛出错误 | 断言元素存在 |
queryBy | 返回 null | 抛出错误 | 断言元素不存在 |
findBy | 抛出错误(异步) | 抛出错误 | 等待异步元素出现 |
getAllBy | 抛出错误 | 返回数组 | 查找多个元素 |
// components/Counter.test.tsx — 完整组件测试示例
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter 组件', () => {
it('初始值为 0', () => {
render(<Counter />);
// getByRole('heading') 匹配 h1-h6 标签
expect(screen.getByRole('heading', { name: '计数: 0' })).toBeInTheDocument();
});
it('点击增加按钮后计数加一', async () => {
// userEvent 需要调用 setup() 才能正确处理事件序列
const user = userEvent.setup();
render(<Counter />);
// 通过 ARIA 角色和名称查找按钮
await user.click(screen.getByRole('button', { name: '增加' }));
expect(screen.getByRole('heading', { name: '计数: 1' })).toBeInTheDocument();
});
it('在输入框输入初始值', async () => {
const user = userEvent.setup();
render(<Counter />);
const input = screen.getByLabelText('初始值');
await user.clear(input);
await user.type(input, '5');
expect(input).toHaveValue(5);
});
});
测试异步操作
// 测试点击后异步加载数据
it('点击搜索后显示结果', async () => {
const user = userEvent.setup();
render(<SearchBox />);
await user.type(screen.getByRole('searchbox'), 'React');
await user.click(screen.getByRole('button', { name: '搜索' }));
// findBy 会等待元素出现(默认超时 1000ms)
const results = await screen.findByRole('list');
expect(results).toBeInTheDocument();
// 或者用 waitFor 等待断言通过
await waitFor(() => {
expect(screen.getAllByRole('listitem').length).toBeGreaterThan(0);
});
});
7. Mock(模拟)
vi.fn() — 函数 Mock
it('提交表单时调用 onSubmit 回调', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn(); // 创建一个监视函数(spy)
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('邮箱'), 'test@example.com');
await user.type(screen.getByLabelText('密码'), 'password123');
await user.click(screen.getByRole('button', { name: '登录' }));
// 断言函数被调用,且调用参数正确
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
vi.mock() — 模块 Mock
// 模拟整个模块(如 axios)
vi.mock('axios');
import axios from 'axios';
import { vi } from 'vitest';
it('获取用户列表', async () => {
// 设置 mock 返回值
vi.mocked(axios.get).mockResolvedValue({
data: [{ id: 1, name: 'Alice' }],
});
const user = userEvent.setup();
render(<UserList />);
await screen.findByText('Alice');
expect(screen.getByText('Alice')).toBeInTheDocument();
});
MSW — Mock Service Worker(推荐方案)
直接 mock axios/fetch 模块太底层,而且改变了请求库本身的行为。MSW 在 Service Worker 层面拦截 HTTP 请求,更接近真实网络,无需修改应用代码。
# npm install -D msw
// src/mocks/handlers.ts — 定义拦截规则
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
]);
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: 3, ...body }, { status: 201 });
}),
];
// src/mocks/server.ts — 测试环境服务器
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// setupTests.ts — 启动/重置/关闭 MSW 服务器
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers()); // 每次测试后重置覆盖
afterAll(() => server.close());
8. 自定义 Hook 测试
不能在测试文件中直接调用 Hook(Hook 只能在 React 函数组件内调用)。renderHook 提供了一个"容器组件"来运行 Hook。
// hooks/useCounter.ts
import { useState } from 'react';
export function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = () => setCount((c) => c + 1);
const decrement = () => setCount((c) => c - 1);
const reset = () => setCount(initial);
return { count, increment, decrement, reset };
}
// hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('初始值正确', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increment 增加计数', () => {
const { result } = renderHook(() => useCounter());
// act 包裹所有会导致状态更新的操作
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('reset 恢复初始值', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(5);
});
});
9. 测试覆盖率
# 生成覆盖率报告
npm run coverage
# 输出示例:
# ----------------------------------------
# File | % Stmts | % Branch | % Funcs | % Lines
# ----------------------------------------
# utils/ | 92.3 | 85.7 | 100 | 91.8
# string.ts | 100 | 100 | 100 | 100
# format.ts | 84.6 | 71.4 | 100 | 83.3
覆盖率指标说明:
- 语句覆盖(Statements):被执行的语句比例
- 分支覆盖(Branch):if/else 各分支被执行的比例(最重要)
- 函数覆盖(Functions):被调用的函数比例
- 行覆盖(Lines):被执行的代码行比例
覆盖率不是目标本身。 100% 覆盖率但断言无意义的测试没有价值。追求合理的覆盖率(80% 以上),重点覆盖业务逻辑和边界条件。
10. 测试最佳实践
测试用户行为,不测实现细节
// ❌ 不好:测试组件内部 state(实现细节)
it('state 被正确更新', () => {
const wrapper = shallow(<Counter />);
wrapper.find('button').simulate('click');
expect(wrapper.state('count')).toBe(1); // 不好!
});
// ✅ 好:测试用户能看到的结果
it('点击增加按钮后屏幕显示新计数', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: '增加' }));
expect(screen.getByText('1')).toBeInTheDocument(); // 好!
});
什么不需要测试
- 第三方库(React、Tailwind、axios)的内部逻辑
- 纯 UI 样式(颜色、字体大小——用视觉测试工具)
- TypeScript 类型(编译时已保证)
- 一次性的临时代码
- 实现过于简单的 getter/setter(如
getName() { return this.name })