前端测试策略:从单元测试到 E2E
测试写了不少,从”不写测试”到”过度测试”再到”合理测试”,总结一套策略。
测试金字塔
/\
/E2E\
/------\
/ 集成 \
/----------\
/ 单元测试 \
/--------------\
| 层级 | 数量 | 成本 | 价值 |
|---|---|---|---|
| 单元测试 | 最多 | 低 | 高 |
| 集成测试 | 中等 | 中 | 中 |
| E2E 测试 | 最少 | 高 | 高 |
单元测试
测试最小单元:函数、组件。
工具选择
| 工具 | 特点 | 推荐场景 |
|---|---|---|
| Jest | 全功能、生态好 | 通用 |
| Vitest | 快、Vite 原生 | Vite 项目 |
| Testing Library | 关注用户行为 | React/Vue |
函数测试
// utils.test.js
import { formatPrice, debounce } from './utils';
describe('formatPrice', () => {
it('should format number to currency', () => {
expect(formatPrice(1234.5)).toBe('¥1,234.50');
});
it('should handle zero', () => {
expect(formatPrice(0)).toBe('¥0.00');
});
it('should handle negative', () => {
expect(formatPrice(-100)).toBe('-¥100.00');
});
});
describe('debounce', () => {
beforeEach(() => {
jest.useFakeTimers();
});
it('should debounce function calls', () => {
const fn = jest.fn();
const debounced = debounce(fn, 100);
debounced();
debounced();
debounced();
expect(fn).not.toHaveBeenCalled();
jest.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
});
});
组件测试
// Button.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
describe('Button', () => {
it('should render with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('should call onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('should be disabled when loading', () => {
render(<Button loading>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
集成测试
测试模块间的交互。
API 测试
// api.test.js
import { getUser, createUser } from './api';
import { server } from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('User API', () => {
it('should fetch user by id', async () => {
const user = await getUser(1);
expect(user.name).toBe('John Doe');
});
it('should handle not found', async () => {
await expect(getUser(999)).rejects.toThrow('User not found');
});
it('should create user', async () => {
const newUser = await createUser({ name: 'Jane' });
expect(newUser.id).toBeDefined();
});
});
Mock Service Worker
// mocks/handlers.js
import { rest } from 'msw';
export const handlers = [
rest.get('/api/users/:id', (req, res, ctx) => {
const { id } = req.params;
if (id === '999') {
return res(ctx.status(404));
}
return res(
ctx.json({
id: Number(id),
name: 'John Doe',
email: 'john@example.com',
})
);
}),
];
E2E 测试
模拟真实用户操作。
Playwright
// login.spec.js
import { test, expect } from '@playwright/test';
test.describe('Login', () => {
test('should login successfully', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'user@example.com');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('.user-name')).toContainText('John');
});
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'wrong@example.com');
await page.fill('[name="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
await expect(page.locator('.error-message')).toBeVisible();
});
});
测试关键路径
| 路径 | 优先级 |
|---|---|
| 用户登录 | P0 |
| 核心业务流程 | P0 |
| 支付流程 | P0 |
| 用户注册 | P1 |
| 搜索功能 | P1 |
测试覆盖率
配置
// vitest.config.js
export default {
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: [
'node_modules/',
'**/*.test.js',
'**/*.config.js',
],
},
},
};
合理目标
| 类型 | 目标覆盖率 |
|---|---|
| 工具函数 | 90% |
| 业务逻辑 | 70% |
| UI 组件 | 50% |
| 整体 | 70% |
不要追求 100%,边际成本太高。
测试技巧
测试用户行为,不要测试实现细节
// 不好:测试内部状态
expect(component.state.count).toBe(1);
// 好:测试可见结果
expect(screen.getByText('Count: 1')).toBeInTheDocument();
用 beforeEach 隔离
describe('UserService', () => {
let service;
beforeEach(() => {
service = new UserService();
});
test('should create user', () => {
// service 是干净的实例
});
});
快照测试要谨慎
// 不要对整个组件做快照
expect(container).toMatchSnapshot();
// 只对关键输出做快照
expect(screen.getByRole('alert')).toMatchSnapshot();
快照测试容易变成”更新快照”的无意义操作。
CI 集成
# GitHub Actions
name: Test
on: [push, pull_request]
jobs:
test:
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:coverage
- run: npm run test:e2e
常见反模式
| 反模式 | 问题 | 改进 |
|---|---|---|
| 不写测试 | 没有保障 | 从关键路径开始 |
| 过度 mock | 测试无意义 | 减少不必要的 mock |
| 测试实现细节 | 重构就挂 | 测试行为和输出 |
| 追求 100% 覆盖 | 成本太高 | 关注关键路径 |
总结
测试的目的是提高信心,不是为了覆盖率数字。
建议:
- 关键功能必须有测试
- 新功能写测试,老功能逐步补
- 单元测试为主,E2E 为辅
- CI 跑测试,提交前本地也要跑
测试写多了,重构才敢放心做。