React 测试策略与最佳实践
React 测试策略与最佳实践
测试是保证 React 应用质量的重要手段,完善的测试体系可以及早发现 bug、提高代码可维护性、增强重构信心。React 生态提供了丰富的测试工具,从单元测试到端到端测试,开发者可以根据需求构建多层次的测试策略。
测试类型详解
单元测试:
单元测试是最基础的测试类型,用于测试单个组件或函数的行为。单元测试应该隔离测试,不依赖外部系统,执行快速,反馈及时。在 React 中,单元测试通常测试组件的渲染输出、状态变化、事件处理等。单元测试的覆盖率是衡量代码质量的重要指标,但不应过度追求 100% 覆盖率,而应关注关键逻辑和边界条件。
集成测试:
集成测试测试多个组件或模块之间的交互,模拟真实使用场景。集成测试关注组件之间的数据流、状态共享、API 调用等。集成测试比单元测试更接近真实用户行为,但执行速度较慢。在 React 应用中,集成测试通常测试表单提交流程、数据获取和渲染、路由导航等场景。
端到端测试 (E2E):
端到端测试模拟用户在真实浏览器中的操作,测试整个应用的流程。E2E 测试关注用户视角,验证用户体验和业务流程。E2E 测试执行最慢,但最能反映真实使用情况。常用的 E2E 测试工具包括 Playwright 和 Cypress,它们可以在真实浏览器中模拟用户操作。
代码示例
// Jest 配置示例
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\.(css|less|scss|sass)$': 'identity-obj-proxy'
},
transform: {
'^.+\.(js|jsx|ts|tsx)$': ['babel-jest', { presets: ['next/babel'] }]
},
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.{js,jsx,ts,tsx}'
]
};
// 组件渲染测试
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';
describe('Counter Component', () => {
test('renders initial count', () => {
render(<Counter initialCount={0} />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
test('increments count when button clicked', async () => {
const user = userEvent.setup();
render(<Counter initialCount={0} />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
test('decrements count when button clicked', async () => {
const user = userEvent.setup();
render(<Counter initialCount={5} />);
await user.click(screen.getByRole('button', { name: /decrement/i }));
expect(screen.getByText('Count: 4')).toBeInTheDocument();
});
});
// 表单组件测试
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
const mockOnSubmit = jest.fn();
beforeEach(() => {
mockOnSubmit.mockClear();
});
test('submits form with correct values', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockOnSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
});
});
});
test('shows validation error for invalid email', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockOnSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'invalid-email');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
expect(mockOnSubmit).not.toHaveBeenCalled();
});
});
// 异步组件测试
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';
// Mock fetch
global.fetch = jest.fn();
describe('UserProfile', () => {
beforeEach(() => {
fetch.mockClear();
});
test('shows loading state initially', () => {
fetch.mockImplementation(() => new Promise(() => {}));
render(<UserProfile userId="1" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
test('renders user data after fetch', async () => {
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: '1', name: 'John Doe', email: 'john@example.com' })
});
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
});
test('shows error message on fetch failure', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));
render(<UserProfile userId="1" />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
});
// 自定义 Hook 测试
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter Hook', () => {
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
});
// MSW API Mock 示例
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { render, screen, waitFor } from '@testing-library/react';
import UserList from './UserList';
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.json([
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' }
])
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('UserList with MSW', () => {
test('renders users from API', async () => {
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
});
test('handles API error', async () => {
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<UserList />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
});
// 快照测试
import { render } from '@testing-library/react';
import Card from './Card';
describe('Card Snapshot', () => {
test('matches snapshot', () => {
const { asFragment } = render(
<Card title="Test Card" description="This is a test card" />
);
expect(asFragment()).toMatchSnapshot();
});
test('matches snapshot with custom className', () => {
const { asFragment } = render(
<Card
title="Test Card"
description="This is a test card"
className="custom-class"
/>
);
expect(asFragment()).toMatchSnapshot();
});
});测试工具详解
Jest - 测试框架核心:
Jest 是 Facebook 开发的 JavaScript 测试框架,提供了完整的测试解决方案。Jest 内置断言库、Mock 功能、快照测试、代码覆盖率报告等功能。Jest 的零配置理念使得开箱即用,同时支持灵活的自定义配置。Jest 的并行测试执行和智能观察模式可以显著提高测试效率。
React Testing Library - 组件测试最佳实践:
React Testing Library 是测试 React 组件的推荐工具,它鼓励测试组件行为而非实现细节。通过 queryBy、getBy、findBy* 等方法查询 DOM 元素,通过 userEvent 模拟用户交互。Testing Library 的核心理念是"测试应该像用户使用应用一样",这使得测试更加健壮,不会因为重构而频繁修改。
MSW - API Mock 利器:
Mock Service Worker (MSW) 是现代的 API Mock 工具,通过 Service Worker 拦截网络请求。MSW 可以在浏览器和 Node.js 环境中使用,支持 REST 和 GraphQL API。MSW 的优势在于不需要修改应用代码,Mock 的 API 行为与真实 API 一致,测试更加真实可靠。
最佳实践
测试原则:
测试用户可见的行为,而不是实现细节。使用语义化的查询方法(如 getByRole、getByText)而不是测试 ID。保持测试独立,每个测试应该能够单独运行。测试边界条件和错误情况,而不仅仅是正常流程。
测试组织:
使用 describe 嵌套组织相关测试用例。使用 beforeEach 和 afterEach 处理公共的设置和清理。将测试文件放在与被测试文件相同的目录下,命名为 .test.js 或 .spec.js。
持续集成:
在 CI 流程中运行所有测试,确保代码质量。配置测试覆盖率阈值,低于阈值时构建失败。使用并行测试加速 CI 执行时间。
测试金字塔与投资回报
为什么需要分层测试?
测试金字塔(Test Pyramid)由 Mike Cohn 提出,它描述了不同层级测试的合理配比。底层是数量最多、执行最快、成本最低的单元测试;中间是集成测试;顶层是数量最少、执行最慢、成本最高的端到端测试。金字塔的核心思想是:把大部分测试放在成本低、反馈快的底层,只用少量 E2E 测试覆盖关键用户旅程。
一个反面模式是"测试冰淇淋甜筒(Ice Cream Cone)":大量 E2E 测试、少量单元测试。这会导致 CI 缓慢、测试脆弱、定位问题困难。
| 层级 | 占比建议 | 单次执行速度 | 维护成本 | 定位问题精度 | 典型工具 |
| --- | --- | --- | --- | --- | --- |
| 单元测试 | 70% | 1-50ms | 低 | 高(精确到函数) | Jest / Vitest |
| 集成测试 | 20% | 50-500ms | 中 | 中(组件交互) | RTL + MSW |
| E2E 测试 | 10% | 2-30s | 高 | 低(整条流程) | Playwright / Cypress |
Kent C. Dodds 提出了"测试奖杯(Testing Trophy)"模型,强调集成测试的价值,因为它在"信心"和"成本"之间取得最佳平衡。对 React 应用,集成测试(渲染真实组件树、模拟真实交互、Mock 网络层)往往是性价比最高的投资。
一个数据对比: 假设一个中型 React 项目有 500 个测试用例。
| 配比方案 | 单元 | 集成 | E2E | CI 总耗时 | 重构后误报率 | 真实 bug 捕获率 |
| --- | --- | --- | --- | --- | --- | --- |
| 冰淇淋甜筒 | 50 | 100 | 350 | 约 25 分钟 | 高(40%) | 中 |
| 均衡金字塔 | 350 | 100 | 50 | 约 4 分钟 | 低(8%) | 高 |
| 测试奖杯 | 250 | 200 | 50 | 约 5 分钟 | 低(10%) | 很高 |
React Testing Library 查询优先级
为什么查询方式如此重要?
RTL 提供了多种查询方法,它们的优先级反映了"测试应该像用户一样使用应用"的哲学。优先使用无障碍属性(可访问性)查询,因为这既能测试功能,又能顺带验证无障碍性。
查询优先级(从高到低):
import { render, screen } from '@testing-library/react';
function LoginForm() {
return (
<form>
<label htmlFor="email">邮箱</label>
<input id="email" type="email" placeholder="请输入邮箱" />
<button type="submit">登录</button>
<img src="/logo.png" alt="公司 Logo" />
<p data-testid="status">就绪</p>
</form>
);
}
describe('查询优先级演示', () => {
beforeEach(() => render(<LoginForm />));
// 1. 首选:可被所有人访问的查询(无障碍角色)
test('getByRole - 最推荐', () => {
// role 查询最健壮,同时验证了无障碍性
expect(screen.getByRole('button', { name: '登录' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: '邮箱' })).toBeInTheDocument();
});
// 2. getByLabelText - 表单元素首选
test('getByLabelText - 表单元素', () => {
expect(screen.getByLabelText('邮箱')).toBeInTheDocument();
});
// 3. getByPlaceholderText - 没有 label 时的退路
test('getByPlaceholderText', () => {
expect(screen.getByPlaceholderText('请输入邮箱')).toBeInTheDocument();
});
// 4. getByText - 非交互元素(div、span、p)
test('getByText', () => {
expect(screen.getByText('就绪')).toBeInTheDocument();
});
// 5. getByAltText - 图片
test('getByAltText', () => {
expect(screen.getByAltText('公司 Logo')).toBeInTheDocument();
});
// 6. 最后的手段:getByTestId(前面都无法定位时)
test('getByTestId - 最后手段', () => {
expect(screen.getByTestId('status')).toHaveTextContent('就绪');
});
});三种查询变体的区别:
// getBy* - 找不到直接抛错,找到多个也抛错(断言元素必须存在)
// queryBy* - 找不到返回 null,用于断言"元素不存在"
// findBy* - 返回 Promise,自动重试,用于异步出现的元素
describe('查询变体', () => {
test('queryBy 断言元素不存在', () => {
render(<div>已登录</div>);
// 不能用 getByText 断言不存在,它会抛错;用 queryByText
expect(screen.queryByText('登录')).not.toBeInTheDocument();
});
test('findBy 等待异步元素', async () => {
render(<AsyncGreeting />);
// findBy 内部封装了 waitFor,默认重试 1000ms
expect(await screen.findByText('你好,World')).toBeInTheDocument();
});
test('getAllBy 处理多个元素', () => {
render(
<ul>
<li>项 1</li>
<li>项 2</li>
<li>项 3</li>
</ul>
);
expect(screen.getAllByRole('listitem')).toHaveLength(3);
});
});userEvent 深入:模拟真实用户交互
userEvent vs fireEvent:
`fireEvent` 直接派发单个 DOM 事件,而 `userEvent` 模拟真实用户的完整交互序列。例如点击一个按钮,真实用户会触发 pointerdown、mousedown、focus、pointerup、mouseup、click 一系列事件。`userEvent` 更贴近真实,能捕获更多 bug。
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('userEvent 完整交互', () => {
test('输入、清空、键盘操作', async () => {
const user = userEvent.setup();
render(<input aria-label="search" />);
const input = screen.getByLabelText('search');
// 输入文本
await user.type(input, 'hello');
expect(input).toHaveValue('hello');
// 清空
await user.clear(input);
expect(input).toHaveValue('');
// 特殊按键:{Enter}、{Backspace}、{ArrowLeft} 等
await user.type(input, 'abc{Backspace}');
expect(input).toHaveValue('ab');
// 组合键(全选后删除)
await user.type(input, '{Control>}a{/Control}{Delete}');
expect(input).toHaveValue('');
});
test('下拉选择与复选框', async () => {
const user = userEvent.setup();
render(
<>
<select aria-label="城市">
<option value="bj">北京</option>
<option value="sh">上海</option>
</select>
<input type="checkbox" aria-label="同意条款" />
</>
);
await user.selectOptions(screen.getByLabelText('城市'), 'sh');
expect(screen.getByLabelText('城市')).toHaveValue('sh');
await user.click(screen.getByLabelText('同意条款'));
expect(screen.getByLabelText('同意条款')).toBeChecked();
});
test('悬停、双击、右键', async () => {
const user = userEvent.setup();
const onDoubleClick = jest.fn();
const onContextMenu = jest.fn();
render(
<button onDoubleClick={onDoubleClick} onContextMenu={onContextMenu}>
操作
</button>
);
const btn = screen.getByRole('button');
await user.dblClick(btn);
expect(onDoubleClick).toHaveBeenCalledTimes(1);
await user.pointer({ keys: '[MouseRight]', target: btn });
expect(onContextMenu).toHaveBeenCalledTimes(1);
});
test('使用假定时器时需要配置 advanceTimers', async () => {
jest.useFakeTimers();
// 关键:userEvent 内部有延迟,需告知它推进假定时器
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(<DebouncedSearch />);
await user.type(screen.getByRole('textbox'), 'react');
jest.advanceTimersByTime(500);
expect(await screen.findByText(/搜索: react/)).toBeInTheDocument();
jest.useRealTimers();
});
});Mock 策略全解
为什么需要 Mock?
测试应该隔离被测单元,避免依赖真实网络、真实定时器、真实第三方模块。Mock 让测试更快、更稳定、可复现。但过度 Mock 会让测试脱离现实,因此要把握"Mock 边界"——Mock 外部依赖(网络、时间、浏览器 API),但不 Mock 被测组件本身。
1. Mock 模块:
// 完整 Mock 一个模块
jest.mock('axios');
import axios from 'axios';
test('mock axios', async () => {
axios.get.mockResolvedValue({ data: { name: 'Alice' } });
const res = await axios.get('/user');
expect(res.data.name).toBe('Alice');
});
// 部分 Mock:保留真实实现,只覆盖某个导出
jest.mock('./utils', () => ({
...jest.requireActual('./utils'),
formatDate: jest.fn(() => '2026-08-03'),
}));
// Mock 默认导出与具名导出混合
jest.mock('./api', () => ({
__esModule: true,
default: jest.fn(),
fetchUser: jest.fn(),
}));2. Mock 定时器:
describe('假定时器', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
test('防抖函数只在最后触发一次', () => {
const fn = jest.fn();
const debounced = debounce(fn, 300);
debounced();
debounced();
debounced();
expect(fn).not.toHaveBeenCalled();
jest.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledTimes(1);
});
test('setInterval 轮询', () => {
const poll = jest.fn();
setInterval(poll, 1000);
jest.advanceTimersByTime(3500);
expect(poll).toHaveBeenCalledTimes(3);
});
});3. Mock 浏览器 API:
// Mock matchMedia(jsdom 不实现)
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
})),
});
});
// Mock IntersectionObserver
beforeAll(() => {
global.IntersectionObserver = class {
observe = jest.fn();
unobserve = jest.fn();
disconnect = jest.fn();
};
});
// Mock localStorage
const localStorageMock = (() => {
let store = {};
return {
getItem: (key) => store[key] || null,
setItem: (key, value) => { store[key] = String(value); },
removeItem: (key) => { delete store[key]; },
clear: () => { store = {}; },
};
})();
Object.defineProperty(window, 'localStorage', { value: localStorageMock });测试 Context 与 Provider
问题: 依赖 Context 的组件在测试中直接渲染会报错,因为缺少 Provider。解决方案是提供一个自定义 render 函数,自动包裹所需的 Provider。
// test-utils.tsx —— 自定义 render,一次性包裹所有 Provider
import { render as rtlRender } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
import { AuthProvider } from './AuthContext';
function AllProviders({ children }) {
return (
<ThemeProvider>
<AuthProvider>{children}</AuthProvider>
</ThemeProvider>
);
}
// 重新导出所有内容,并覆盖 render
export * from '@testing-library/react';
export function render(ui, options) {
return rtlRender(ui, { wrapper: AllProviders, ...options });
}
// 使用:业务测试无需关心 Provider 细节
import { render, screen } from './test-utils';
test('组件能读取主题', () => {
render(<ThemedButton />);
expect(screen.getByRole('button')).toHaveClass('theme-light');
});支持 initialState 的可配置 Provider:
function renderWithAuth(ui, { user = null } = {}) {
return render(
<AuthContext.Provider value={{ user, login: jest.fn(), logout: jest.fn() }}>
{ui}
</AuthContext.Provider>
);
}
test('未登录显示登录按钮', () => {
renderWithAuth(<Navbar />, { user: null });
expect(screen.getByRole('button', { name: '登录' })).toBeInTheDocument();
});
test('已登录显示用户名', () => {
renderWithAuth(<Navbar />, { user: { name: 'Alice' } });
expect(screen.getByText('Alice')).toBeInTheDocument();
});测试路由(React Router)
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// 用 MemoryRouter 控制初始路由,无需真实浏览器
function renderWithRouter(ui, { route = '/' } = {}) {
return render(<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>);
}
test('访问 /about 渲染关于页', () => {
renderWithRouter(
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>,
{ route: '/about' }
);
expect(screen.getByRole('heading', { name: '关于我们' })).toBeInTheDocument();
});
test('点击链接跳转', async () => {
const user = userEvent.setup();
renderWithRouter(<App />);
await user.click(screen.getByRole('link', { name: '关于' }));
expect(await screen.findByRole('heading', { name: '关于我们' })).toBeInTheDocument();
});测试状态管理(Redux / Zustand)
// Redux:为每个测试创建独立 store,避免测试间污染
import { configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import cartReducer from './cartSlice';
function renderWithStore(ui, { preloadedState } = {}) {
const store = configureStore({
reducer: { cart: cartReducer },
preloadedState,
});
return {
store,
...render(<Provider store={store}>{ui}</Provider>),
};
}
test('加入购物车增加数量', async () => {
const user = userEvent.setup();
const { store } = renderWithStore(<ProductCard id="1" />, {
preloadedState: { cart: { items: [] } },
});
await user.click(screen.getByRole('button', { name: '加入购物车' }));
expect(store.getState().cart.items).toHaveLength(1);
});
// Zustand:在每个测试前重置 store 状态
import { useCartStore } from './store';
beforeEach(() => {
useCartStore.setState({ items: [] });
});
test('Zustand action 更新状态', () => {
useCartStore.getState().addItem({ id: '1', name: '商品' });
expect(useCartStore.getState().items).toHaveLength(1);
});测试错误边界与异常路径
import { render, screen } from '@testing-library/react';
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) return <div>出错了</div>;
return this.props.children;
}
}
function Bomb() {
throw new Error('炸了');
}
test('错误边界捕获子组件异常', () => {
// 抑制 React 打印的错误日志,让测试输出干净
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary>
<Bomb />
</ErrorBoundary>
);
expect(screen.getByText('出错了')).toBeInTheDocument();
spy.mockRestore();
});无障碍性测试(jest-axe)
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('表单无无障碍违规', async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
// 自动检测缺少 label、对比度不足、缺少 alt 等问题
expect(results).toHaveNoViolations();
});E2E 测试:Playwright 实战
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('登录流程', () => {
test('成功登录后跳转到仪表盘', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('邮箱').fill('user@example.com');
await page.getByLabel('密码').fill('password123');
await page.getByRole('button', { name: '登录' }).click();
// 等待导航并断言 URL 与页面内容
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { name: '欢迎回来' })).toBeVisible();
});
test('拦截网络请求 Mock 后端', async ({ page }) => {
// Playwright 可直接拦截并 Mock API,无需真实后端
await page.route('**/api/user', (route) =>
route.fulfill({ json: { name: 'Alice', role: 'admin' } })
);
await page.goto('/profile');
await expect(page.getByText('Alice')).toBeVisible();
});
test('视觉回归截图对比', async ({ page }) => {
await page.goto('/');
// 首次运行生成基准图,后续对比像素差异
await expect(page).toHaveScreenshot('homepage.png', { maxDiffPixels: 100 });
});
});Playwright 配置多浏览器与并行:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true, // 文件内并行
retries: process.env.CI ? 2 : 0, // CI 失败重试,缓解偶发波动
workers: process.env.CI ? 4 : undefined,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry', // 首次重试录制追踪,方便调试
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'mobile', use: { ...devices['iPhone 13'] } },
],
});覆盖率:指标含义与合理阈值
覆盖率有四个维度,理解它们才能设置合理阈值,避免"为覆盖率而覆盖率"。
| 指标 | 含义 | 说明 |
| --- | --- | --- |
| Statements(语句) | 每条语句是否被执行 | 最基础,最容易达标 |
| Branches(分支) | if/else、三元、逻辑运算符各分支 | 最能反映逻辑覆盖质量 |
| Functions(函数) | 每个函数是否被调用 | 发现未测试的函数 |
| Lines(行) | 每行代码是否执行 | 与语句接近 |
// jest.config.js —— 设置覆盖率阈值,低于则 CI 失败
module.exports = {
collectCoverage: true,
coverageThreshold: {
global: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
// 对核心模块设更高标准
'./src/utils/': {
branches: 90,
functions: 90,
},
},
coverageReporters: ['text', 'lcov', 'html'],
};经验数据: 覆盖率与 bug 密度并非线性关系。业界普遍观察到:
| 覆盖率区间 | 边际价值 | 建议 |
| --- | --- | --- |
| 0% → 60% | 极高,快速捕获大量低级错误 | 优先补齐 |
| 60% → 80% | 高,覆盖主要分支 | 团队合理目标 |
| 80% → 90% | 中,覆盖边界与异常 | 核心模块追求 |
| 90% → 100% | 低,常测试到不值得的角落 | 不强求,避免脆弱测试 |
Vitest:更快的现代替代方案
Vitest 与 Jest API 高度兼容,但基于 Vite,启动更快、原生支持 ESM 和 TypeScript,是 Vite 项目的首选。
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true, // 无需 import describe/test/expect
setupFiles: './vitest.setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
},
},
});
// 测试写法几乎与 Jest 一致,只需把 jest.fn 换成 vi.fn
import { describe, test, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
test('vitest 测试组件', () => {
const onClick = vi.fn();
render(<button onClick={onClick}>点我</button>);
screen.getByRole('button').click();
expect(onClick).toHaveBeenCalled();
});Jest vs Vitest 对比:
| 维度 | Jest | Vitest |
| --- | --- | --- |
| 启动速度 | 慢(需 babel/ts 转译) | 快(复用 Vite 转译) |
| ESM 支持 | 需配置,有坑 | 原生支持 |
| TypeScript | 需 ts-jest / babel | 开箱即用 |
| Watch 模式 | 一般 | 极快(HMR 级别) |
| 生态成熟度 | 非常成熟 | 快速成熟中 |
| 适用场景 | CRA、Next.js(默认) | Vite 项目 |
CI 集成完整示例
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
unit-and-integration:
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:ci -- --coverage --maxWorkers=2
- uses: codecov/codecov-action@v4
e2e:
runs-on: ubuntu-latest
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 && npm run test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/真实案例:一个搜索组件的完整测试
// SearchableList.jsx
function SearchableList({ fetchUrl }) {
const [query, setQuery] = React.useState('');
const [items, setItems] = React.useState([]);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState(null);
React.useEffect(() => {
if (!query) { setItems([]); return; }
let cancelled = false;
setLoading(true);
setError(null);
fetch(fetchUrl + '?q=' + encodeURIComponent(query))
.then((r) => { if (!r.ok) throw new Error('请求失败'); return r.json(); })
.then((data) => { if (!cancelled) setItems(data); })
.catch((e) => { if (!cancelled) setError(e.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [query, fetchUrl]);
return (
<div>
<input aria-label="搜索" value={query} onChange={(e) => setQuery(e.target.value)} />
{loading && <p>加载中...</p>}
{error && <p role="alert">{error}</p>}
<ul>{items.map((it) => <li key={it.id}>{it.name}</li>)}</ul>
</div>
);
}
// SearchableList.test.jsx —— 用 MSW 覆盖成功、空、错误三条路径
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
const server = setupServer(
rest.get('/api/search', (req, res, ctx) => {
const q = req.url.searchParams.get('q');
if (q === 'empty') return res(ctx.json([]));
return res(ctx.json([{ id: 1, name: q + ' 结果' }]));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('SearchableList', () => {
test('输入后展示搜索结果', async () => {
const user = userEvent.setup();
render(<SearchableList fetchUrl="/api/search" />);
await user.type(screen.getByLabelText('搜索'), 'react');
expect(await screen.findByText('react 结果')).toBeInTheDocument();
});
test('无结果时列表为空', async () => {
const user = userEvent.setup();
render(<SearchableList fetchUrl="/api/search" />);
await user.type(screen.getByLabelText('搜索'), 'empty');
// 等待 loading 消失后断言无列表项
await waitForElementToBeRemoved(() => screen.queryByText('加载中...'));
expect(screen.queryByRole('listitem')).not.toBeInTheDocument();
});
test('请求失败展示错误', async () => {
server.use(rest.get('/api/search', (req, res, ctx) => res(ctx.status(500))));
const user = userEvent.setup();
render(<SearchableList fetchUrl="/api/search" />);
await user.type(screen.getByLabelText('搜索'), 'boom');
expect(await screen.findByRole('alert')).toHaveTextContent('请求失败');
});
});常见坑与规避
| 坑 | 现象 | 正确做法 |
| --- | --- | --- |
| 测试实现细节 | 重构后大量测试失败但功能正常 | 测行为不测实现,用 role/text 查询 |
| 缺少 act 警告 | 控制台 "not wrapped in act(...)" | 用 findBy/waitFor 等待异步更新 |
| 测试间状态污染 | 单独跑通过,一起跑失败 | beforeEach 重置 mock 与 store |
| 滥用 waitFor 包裹断言 | 超时才报错,定位慢 | 用 findBy 替代 waitFor + getBy |
| 假定时器忘记切回 | 后续测试莫名超时 | afterEach 调 useRealTimers |
| 过度 Mock | 测试全绿但线上崩 | 只 Mock 边界,用 MSW 而非 mock fetch |
| 快照过大 | 快照失去意义,无脑更新 | 小范围快照或改用显式断言 |
| E2E 依赖真实后端 | 测试不稳定、慢、难复现 | 用 route 拦截 Mock 网络 |
| 用 getByTestId 兜底一切 | 脱离用户视角 | 优先 role/label,testId 仅最后手段 |
最佳实践清单
自定义 Hook 的进阶测试
自定义 Hook 无法脱离组件运行,`renderHook` 提供了一个宿主组件来承载 Hook,让我们直接测试其返回值与行为。
import { renderHook, act, waitFor } from '@testing-library/react';
// 1. 测试带异步的 Hook
function useUser(id) {
const [user, setUser] = React.useState(null);
React.useEffect(() => {
fetch('/api/user/' + id).then((r) => r.json()).then(setUser);
}, [id]);
return user;
}
test('useUser 加载数据', async () => {
const { result } = renderHook(() => useUser('1'));
expect(result.current).toBeNull();
await waitFor(() => expect(result.current).toEqual({ id: '1', name: 'Alice' }));
});
// 2. 测试 rerender 时 props 变化
test('id 变化重新加载', async () => {
const { result, rerender } = renderHook(({ id }) => useUser(id), {
initialProps: { id: '1' },
});
await waitFor(() => expect(result.current?.id).toBe('1'));
rerender({ id: '2' });
await waitFor(() => expect(result.current?.id).toBe('2'));
});
// 3. 测试 cleanup(unmount 时清理)
test('unmount 时取消订阅', () => {
const unsubscribe = jest.fn();
renderHook(() => {
React.useEffect(() => unsubscribe, []);
}).unmount();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
// 4. 需要 Provider 的 Hook:通过 wrapper 注入
test('useTheme 需要 Provider', () => {
const wrapper = ({ children }) => <ThemeProvider>{children}</ThemeProvider>;
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current.theme).toBe('light');
act(() => result.current.toggleTheme());
expect(result.current.theme).toBe('dark');
});自定义匹配器与 jest-dom
`@testing-library/jest-dom` 提供了大量面向 DOM 的语义化断言,让测试更易读、报错更清晰。
// 在 jest.setup.js 中一次性引入
import '@testing-library/jest-dom';
test('jest-dom 常用匹配器', () => {
render(
<form>
<input aria-label="邮箱" required disabled value="a@b.com" readOnly />
<button disabled>提交</button>
<span hidden>隐藏文本</span>
</form>
);
const input = screen.getByLabelText('邮箱');
expect(input).toBeDisabled();
expect(input).toBeRequired();
expect(input).toHaveValue('a@b.com');
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByText('隐藏文本')).not.toBeVisible();
});
// 编写自定义匹配器
expect.extend({
toBeWithinRange(received, floor, ceiling) {
const pass = received >= floor && received <= ceiling;
return {
pass,
message: () =>
'期望 ' + received + (pass ? ' 不' : ' ') + '在 ' + floor + '-' + ceiling + ' 范围内',
};
},
});
test('自定义范围匹配器', () => {
expect(100).toBeWithinRange(90, 110);
});调试失败的测试
import { render, screen } from '@testing-library/react';
test('调试技巧', () => {
render(<ComplexComponent />);
// 1. screen.debug() 打印当前 DOM(可传元素只打印局部)
screen.debug();
screen.debug(screen.getByRole('list'));
// 2. 找不到元素时,logTestingPlaygroundURL 生成可视化调试链接
screen.logTestingPlaygroundURL();
// 3. 查看某元素有哪些可用的 role
// 在断言前临时打印所有可访问角色
});
// 4. 设置更长的调试超时,避免调试时 findBy 提前失败
// configure({ asyncUtilTimeout: 5000 });快照测试的正确姿势
快照测试适合"结构稳定、变化需要人工确认"的场景(如设计系统组件、序列化输出),但滥用会导致快照臃肿、无脑更新、失去意义。
// 好的快照:小而聚焦,针对序列化数据
test('格式化函数输出稳定', () => {
expect(formatInvoice({ amount: 100, tax: 0.1 })).toMatchInlineSnapshot(`
{
"amount": 100,
"tax": 10,
"total": 110,
}
`);
});
// 内联快照直接写在测试里,diff 时一目了然
// 反例:对整个复杂页面 toMatchSnapshot,任何微小改动都触发大 diff快照测试适用性判断:
| 场景 | 是否推荐快照 | 替代方案 |
| --- | --- | --- |
| 纯函数序列化输出 | 推荐(inline snapshot) | - |
| 设计系统小组件 | 谨慎使用 | 显式断言关键属性 |
| 复杂业务页面 | 不推荐 | 行为断言 |
| API 响应结构 | 推荐 | - |
总结表格
| 主题 | 核心工具 | 关键要点 | 一句话建议 |
| --- | --- | --- | --- |
| 单元测试 | Jest / Vitest | 隔离、快速、精确定位 | 覆盖纯函数与自定义 Hook |
| 组件测试 | React Testing Library | 测行为、用无障碍查询 | getByRole 优先,testId 兜底 |
| 用户交互 | userEvent | 模拟完整事件序列 | 用 userEvent.setup() 而非 fireEvent |
| 网络 Mock | MSW | 浏览器/Node 一致、真实 | 覆盖成功/空/错误三路径 |
| 异步处理 | findBy / waitFor | 自动重试,避免 act 警告 | 优先 findBy |
| 状态管理 | 独立 store / setState 重置 | 避免测试间污染 | 每个测试独立 store |
| 覆盖率 | coverageThreshold | 分支覆盖最重要 | 目标 80%,核心 90% |
| E2E | Playwright / Cypress | 真实浏览器、关键旅程 | 拦截网络、CI 重试 |
| 无障碍 | jest-axe | 自动检测 a11y 违规 | 关键页面常态化检测 |
| CI 集成 | GitHub Actions | 并行、缓存、失败上传报告 | 单测与 E2E 分 job |
测试不是负担,而是重构的安全网和文档。构建以集成测试为主体、单元测试打底、E2E 守护关键路径的分层测试体系,配合真实的网络 Mock 和无障碍检测,才能在快速迭代中持续保持 React 应用的质量与可维护性。