测试
React 测试的核心栈:Vitest(测试框架)+ React Testing Library(组件测试)+ MSW(Mock Service Worker,API mock)。
测试金字塔
╱ E2E ╲ Playwright / Cypress(少量,测试核心流程)
╱ 集成 ╲ RTL + MSW(中等量,测试用户交互流程)
╱ 单元 ╲ Vitest(大量,测试工具函数、Hooks)💡 最佳实践:重点在集成测试(RTL 模拟用户行为),而非去测实现细节(state 内部值)。
技术选型
| 工具 | 用途 |
|---|---|
| Vitest | 测试运行器 + 断言库(兼容 Jest API,但更快) |
| React Testing Library | 从用户角度渲染和查询组件 |
| @testing-library/user-event | 模拟真实的用户交互(点击、输入、键盘导航) |
| MSW (Mock Service Worker) | 在测试中 mock API 请求 |
| jsdom | Node 中模拟浏览器 DOM(Vitest 内建) |
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom msw快速开始
// Button.jsx
export default function Button({ onClick, children }) {
return <button onClick={onClick}>{children}</button>;
}
// Button.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';
describe('Button', () => {
it('renders children', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Click</Button>);
await userEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
});查询优先级
RTL 提供多种查询方法,按推荐的优先顺序:
// 1. getByRole — 最推荐(语义化 + 可访问性)
screen.getByRole('button', { name: /submit/i });
screen.getByRole('heading', { name: '用户列表' });
screen.getByRole('textbox', { name: '邮箱' }); // 匹配 label 关联的 input
// 2. getByLabelText — 表单字段首选
screen.getByLabelText('邮箱地址');
// 3. getByPlaceholderText
screen.getByPlaceholderText('请输入...');
// 4. getByText — 非交互文本
screen.getByText(/共 \d+ 条记录/);
// 5. getByTestId — 最后的手段(添加 data-testid 属性)
screen.getByTestId('submit-button');| 方法 | 返回 | 找不到时 |
|---|---|---|
getBy... |
第一个匹配元素 | 抛出错误 |
queryBy... |
第一个匹配元素 | null(用于断言元素不存在) |
findBy... |
Promise → 匹配元素 | reject(用于异步等待出现) |
getAllBy... |
所有匹配数组 | 抛出错误 |
queryAllBy... |
所有匹配数组 | [] |
findAllBy... |
Promise → 数组 | reject |
模拟用户交互
import userEvent from '@testing-library/user-event';
// 点击
await userEvent.click(screen.getByRole('button', { name: '保存' }));
// 输入
const input = screen.getByRole('textbox', { name: '姓名' });
await userEvent.type(input, '张三'); // 模拟逐键输入(比 fireEvent.change 更真实)
// 清空
await userEvent.clear(input);
// 键盘
await userEvent.keyboard('{Enter}');
await userEvent.keyboard('{Shift>}A{/Shift}'); // Shift + A → "A"
// 双击
await userEvent.dblClick(element);
// 下拉选择
await userEvent.selectOptions(screen.getByRole('combobox'), 'option-value');💡 最佳实践:优先使用
userEvent(@testing-library/user-eventv14+),而非原始的fireEvent。userEvent模拟的是完整用户行为序列(如点击 = 聚焦 + 按键 + 弹起 + 点击),更接近真实。
测试异步行为
// 等待元素出现
const message = await screen.findByText('加载成功');
// 等待元素消失
await waitForElementToBeRemoved(() => screen.queryByText('加载中...'));
// 通用等待
import { waitFor } from '@testing-library/react';
await waitFor(() => {
expect(screen.getByText('完成')).toBeInTheDocument();
});测试自定义 Hook
import { renderHook, act } from '@testing-library/react';
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
return { count, increment, decrement };
}
describe('useCounter', () => {
it('increments counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
});🚨 陷阱:直接调用
result.current.increment()(不用 act)在大多数情况下可以,但如果状态更新触发了 effect,React 会报 warning。用act包裹所有会触发状态变更的操作。
Mock 服务端请求(MSW)
// mocks/handlers.js
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
]);
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: 3, ...body }, { status: 201 });
}),
];
// setup.js (vitest setup file)
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());在单测中覆盖 handler
import { http, HttpResponse } from 'msw';
import { server } from './setup';
it('handles error', () => {
server.use(
http.get('/api/users', () => {
return new HttpResponse(null, { status: 500 });
})
);
// 接下来渲染组件,它会收到 500 错误
});测试上下文提供者
import { render } from '@testing-library/react';
// 自定义 render,包裹 Provider
function renderWithProviders(ui, { preloadedState = {}, ...options } = {}) {
function Wrapper({ children }) {
return (
<ThemeProvider value="light">
<UserProvider value={preloadedState.user}>
{children}
</UserProvider>
</ThemeProvider>
);
}
return render(ui, { wrapper: Wrapper, ...options });
}测试覆盖的"正面清单"
✅ 应该测试的:
- 核心业务交互流程(用户注册、下单、支付)
- 条件渲染(登录前/后显示不同的 UI)
- 错误状态处理(API 失败时的错误提示)
- 表单验证逻辑
- 关键的工具函数和自定义 Hooks
❌ 不需要测试的:
- 第三方库的内部行为(React Router、Zustand 由库作者保证)
- 实现细节(组件内部 state 的精确值)
- 纯样式(CSS 是否正确应用——这由视觉测试工具做)
Vitest 配置参考
// vite.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true, // 无需 import describe/it/expect
environment: 'jsdom',
setupFiles: './src/test/setup.js',
css: true, // 处理 CSS imports
},
});🚨 常见陷阱
- 测试实现细节:测
state.count而不是测 “点击 + 后显示 1” - 用 fireEvent 代替 userEvent:
fireEvent.click()不模拟 focus/blur,行为不真实 - 查询方法优先级错误:能用 getByRole 就别用 getByTestId
- 忘记等待异步行为:API 请求后需
await findByText/waitFor - act 警告:异步 state 更新没包裹在 act 中
- 过度 mock:mock 太多东西让测试变成测试"mock"而非"真实行为"