测试是 React 应用质量的最后防线,也是重构的信心来源。本文构建了从单元测试到 E2E 的完整测试体系,涵盖工具选型、代码规范、CI/CD集成全流程。
一、测试金字塔:投入比例与策略
┌──────────┐
│ E2E │ ← 5% 投入,验证关键用户流程 (Playwright/Cypress)
│ (~20个) │
├──────────┤
│ 集成 │ ← 15% 投入,组件间交互 (RTL + Mock API)
│ (~200个) │
├──────────┤
│ 单元 │ ← 80% 投入,纯函数、工具逻辑 (Vitest/Jest)
│ (~2000个)│
└──────────┘
关键原则:
- 单元测试成本低、速度快、定位精准,应占绝大多数
- 集成测试验证组件组合行为,重点放在数据流和交互上
- E2E 测试覆盖真实用户场景(注册 → 登录 → 下单),不要测试细节
- 不要在每个测试中测试实现细节(如是否调用了某个函数),测试输出行为
测试口诀:“如果消失了你担心吗?"——删除这个测试后,还能不能有同样的信心?如果不会,这个测试就是多余的。
二、Vitest:2025 年首选单元测试框架
2.1 为什么选 Vitest 而非 Jest?
| 特性 | Jest | Vitest |
|---|---|---|
| Vite 集成 | 需要额外配置 | 原生支持 |
| TypeScript | 需要 ts-jest | 原生支持 |
| 执行速度 | 快 | 更快(基于 esbuild) |
| Watch 模式 | 快 | 更快(HMR 级别) |
| ESM 支持 | 需配置 | 原生支持 |
| UI 模式 | 需插件 | 内置 vitest --ui |
对于 Vite 项目(现代 React 项目的主流),Vitest 是零配置、性能最优的选择。
2.2 安装与配置
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './tests/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
functions: 80,
lines: 80,
},
},
},
});
// tests/setup.ts
import '@testing-library/jest-dom';
2.3 单元测试:纯函数与工具
import { describe, it, expect } from 'vitest';
import { formatPrice, calculateDiscount, parseQueryString } from './utils';
describe('formatPrice', () => {
it('formats number with currency symbol', () => {
expect(formatPrice(1999.99)).toBe('¥1,999.99');
});
it('handles zero', () => {
expect(formatPrice(0)).toBe('¥0.00');
});
it('rounds to 2 decimals', () => {
expect(formatPrice(99.999)).toBe('¥100.00');
});
});
describe('calculateDiscount', () => {
it('applies percentage discount', () => {
expect(calculateDiscount(100, 20)).toBe(80);
});
it('returns full price for zero discount', () => {
expect(calculateDiscount(100, 0)).toBe(100);
});
it('caps discount at 100%', () => {
expect(calculateDiscount(100, 150)).toBe(0);
});
});
三、React Testing Library:组件测试核心
3.1 测试哲学
React Testing Library(RTL)的核心思想是测试组件像用户一样使用它。不测试内部实现,只测试可见的输出和行为。
RTL 的经典三原则:
- 如果某件事让用户担心,测试它
- 如果某代码对用户体验有影响,测试它
- 不测试实现细节(不要测试 state、props、内部方法)
3.2 查询优先级
// 1. 最优先:查询语义化元素(无障碍)
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: 'Email' });
// 2. 标签关联
screen.getByLabelText('Password');
// 3. placeholder
screen.getByPlaceholderText('Enter email');
// 4. 文本内容
screen.getByText('Welcome back');
// 5. 显示值(表单)
screen.getByDisplayValue('John Doe');
// 6. alt 文本(图片)
screen.getByAltText('Product thumbnail');
// 7. title
screen.getByTitle('Close menu');
// 8. 最后手段:test id
screen.getByTestId('user-card');
为什么 getByRole 最优先? 因为它反映的是无障碍访问(Accessibility),既是测试又是无障碍检查。如果元素没有正确的 role,屏幕阅读器也无法识别。
3.3 事件与交互测试
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PasswordInput } from './PasswordInput';
it('toggles password visibility', async () => {
const user = userEvent.setup();
render(<PasswordInput />);
// 初始为密码类型(不可见)
const input = screen.getByLabelText('Password');
expect(input).toHaveAttribute('type', 'password');
// 点击显示按钮
const toggleBtn = screen.getByRole('button', { name: /show password/i });
await user.click(toggleBtn);
// 验证类型变为 text
expect(input).toHaveAttribute('type', 'text');
// 再次点击恢复
await user.click(toggleBtn);
expect(input).toHaveAttribute('type', 'password');
});
userEvent vs fireEvent:
userEvent模拟真实用户交互序列(click 包含 hover → mousedown → focus → mouseup → click)fireEvent直接触发单个事件,只用于userEvent不支持的特殊场景- 默认使用
userEvent
3.4 异步组件测试
import { render, screen, waitFor, within } from '@testing-library/react';
it('loads and displays user list', async () => {
render(<UserList />);
// 初始 loading 状态
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// 等待数据加载
const users = await screen.findByRole('list');
expect(users).toBeInTheDocument();
// 确认列表项数量
const items = within(users).getAllByRole('listitem');
expect(items).toHaveLength(3);
// 确认特定用户存在
expect(screen.getByText('Alice')).toBeInTheDocument();
});
it('handles error state', async () => {
// Mock API 失败
vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'));
render(<UserList />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
// 恢复 mock
vi.restoreAllMocks();
});
async 方法对照表:
| 方法 | 用法 | 等待条件 |
|---|---|---|
findBy | await screen.findByText('Loaded') | 元素出现(自带 1s 超时) |
waitFor | await waitFor(() => expect(...)) | 自定义断言条件 |
waitForElementToBeRemoved | await waitForElementToBeRemoved(() => screen.getByText('Loading')) | 元素消失 |
3.5 Mock API:MSW(Mock Service Worker)
MSW 使用 Service Worker 拦截浏览器网络请求,统一 Mock API,既用于测试也用于开发。
// tests/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.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: `User ${params.id}`,
email: `user${params.id}@example.com`,
});
}),
http.post('/api/login', async ({ request }) => {
const body = await request.json();
if (body.email === 'test@test.com' && body.password === '123456') {
return HttpResponse.json({ token: 'fake-jwt-token', user: { id: '1', name: 'Test' } });
}
return new HttpResponse(null, { status: 401 });
}),
];
// tests/setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
MSW 相比直接 Mock fetch 的优势:
- 统一 API 层拦截,代码完全不感知是 mock 还是真实请求
- 开发环境也可使用(启动 dev server 时使用相同 handlers)
- 支持 REST 和 GraphQL
- 可覆盖网络错误、超时、慢速等边缘场景
四、E2E 测试:Playwright 完整实践
4.1 Playwright vs Cypress:选型对比
| 特性 | Cypress | Playwright |
|---|---|---|
| 浏览器支持 | Chromium/Firefox/WebKit(需插件) | Chromium/Firefox/WebKit 原生 |
| 并发执行 | 需商业版 | 原生支持(多 worker) |
| API 测试 | 弱 | 强(内置 request) |
| 执行速度 | 快 | 更快(隔离执行) |
| 移动端模拟 | 需插件 | 原生 device 枚举 |
| 调试 | 优秀(Time travel) | 优秀(Trace viewer) |
| 生态 | 插件丰富 | GitHub 官方维护,增长快 |
2025 年推荐 Playwright:原生多浏览器、并发快、API 现代化、微软/Google 支持。
4.2 Playwright 配置与测试
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
workers: process.env.CI ? 1 : undefined,
reporter: [['html'], ['junit', { outputFile: 'test-results/junit.xml' }]],
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: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
{ name: 'Mobile Safari', use: { ...devices['iPhone 12'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
4.3 关键用户流程 E2E
// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Checkout Flow', () => {
test.beforeEach(async ({ page }) => {
// 登录
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
});
test('complete purchase flow', async ({ page }) => {
// 浏览商品
await page.goto('/products');
await page.click('text=Premium Widget');
await expect(page.locator('h1')).toContainText('Premium Widget');
// 加入购物车
await page.click('button:has-text("Add to Cart")');
await expect(page.locator('[data-testid="cart-count"]')).toHaveText('1');
// 进入结账
await page.click('text=Checkout');
await page.fill('[name="address"]', '123 Test St');
await page.fill('[name="city"]', 'Test City');
await page.selectOption('[name="country"]', 'US');
// 提交订单
await page.click('button:has-text("Place Order")');
// 验证成功页
await expect(page).toHaveURL(/\/order\//);
await expect(page.locator('h1')).toContainText('Order Confirmed');
});
test('handles payment failure', async ({ page }) => {
// ... 模拟支付失败场景
});
});
五、视觉回归测试:Chromatic
视觉回归测试检测 UI 的意外变化,特别适合 Design System 和有严格视觉规范的团队。
5.1 Chromatic 快速集成
npm install -D chromatic
npx chromatic --project-token=<your-token>
// .storybook/main.ts
export default {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: ['@storybook/addon-essentials', '@chromatic-com/storybook'],
};
Chromatic 核心能力:
- 自动截图对比:每次 Story 在不同浏览器中截图对比
- 交互测试:在 Storybook 中测试交互状态
- UI Review:团队成员审核 UI 变更
- 分支比较:PR 中可视化对比修改前后的 UI
六、测试策略矩阵
| 场景 | 测试类型 | 工具 | 关键断言 |
|---|---|---|---|
| 工具函数(纯函数) | 单元测试 | Vitest | 输入输出的精确匹配 |
| React 组件渲染 | 组件测试 | RTL + Vitest | 元素存在性、文本内容 |
| 用户交互(点击、输入) | 组件测试 | RTL + userEvent | 状态变化后 UI 更新 |
| API 调用 | 集成测试 | MSW + RTL | Mock 响应后的 UI 渲染 |
| 组件间数据流 | 集成测试 | RTL + Provider Mock | 子组件接收到正确 props |
| 路由切换 | E2E | Playwright | URL 变化、页面内容 |
| 登录/注册/购买 | E2E | Playwright | 完整流程的端到端验证 |
| 响应式布局 | 视觉回归 | Chromatic + Storybook | 不同视口下的像素对比 |
| 主题切换 | 组件/视觉 | RTL + Chromatic | 暗黑/明亮模式截图对比 |
七、CI/CD 集成
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
unit:
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:unit -- --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
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
- run: npm run test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
visual:
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 chromatic --project-token=${{ secrets.CHROMATIC_TOKEN }}
八、测试覆盖率目标与策略
| 模块 | 目标覆盖率 | 说明 |
|---|---|---|
| 工具函数(utils/) | 90%+ | 纯函数,容易全覆盖 |
| Hooks | 80%+ | 测试状态变化逻辑 |
| 组件 | 70-80% | 重点测试条件渲染和用户交互 |
| 页面(Pages) | 50-60% | E2E 覆盖主要流程 |
| 集成 API | 60%+ | MSW 模拟关键接口 |
不要追求的指标:
- 100% 覆盖率不等于无 bug
- 不要为了覆盖率而测试 trivial 代码(如纯 styled-components)
- 重点关注分支覆盖(branch coverage)而非行覆盖(line coverage)
常见问题(FAQ)
什么时候开始写测试?
- 新功能:TDD 或功能完成后立即补测试
- Bug 修复:先写一个会失败的测试复现 bug,再修复,确保不再回归
- 重构:重构前必须有足够测试覆盖
- 遗留代码:核心路径优先覆盖,逐步补充
测试文件放在哪里?
| 策略 | 文件位置 | 适用 |
|---|---|---|
| 同目录 | Button.test.tsx 与 Button.tsx 同级 | 小型项目,文件数少 |
| tests 目录 | tests/components/Button.test.tsx | 中大型项目,统一管理 |
| Vite 约定 | __tests__ 目录 | 有特殊配置需求 |
推荐:同目录方式(Button.tsx + Button.test.tsx + Button.stories.tsx),方便切换。
React Testing Library 和 Enzyme 怎么选?
选 RTL。Enzyme 已停止维护(不兼容 React 18+),RTL 是 React 官方推荐的测试方式。
E2E 测试太慢,怎么优化?
- 并行执行:Playwright 的 workers 配置
- 只测试关键路径:20-30 个核心场景,而非每个细节
- 跳过登录:使用
storageState预存认证 cookie - API 级别的 E2E:用 Playwright 的
request做 API 测试,比 UI 快 10 倍 - Smart sharding:CI 中按 spec 文件分片到不同 runner
相关阅读
- React 详解 — React 核心概念与测试基础
- React Hooks 完全指南 — 自定义 Hooks 的测试模式
- React + TypeScript 实战指南 — 类型安全的测试写法
- React 性能优化深度指南 — 性能测试与 Profiler
- React Server Components 深度解析 — RSC 的测试策略
- React 状态管理指南 — 状态逻辑的测试方法
- Vitest 官方文档
- Testing Library 官方文档
- Playwright 官方文档
- MSW 官方文档
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。