前端测试入门
为什么需要测试
测试不是为了找到所有 Bug,而是为了建立对代码行为的信心。完善的测试让你在重构时敢于修改代码,新功能上线时更有把握。
测试金字塔
╱ ╲
╱E2E╲ ← 少量(端到端)
╱─────╲
╱集成测试╲ ← 适量(组件/API)
╱─────────╲
╱ 单元测试 ╲ ← 大量(函数/工具)
╱─────────────╲
单元测试
测试最小的代码单元(函数、组件)。速度快,覆盖率高。
集成测试
测试多个单元之间的交互。验证组件渲染、用户交互、API 调用等。
E2E 测试
模拟真实用户操作。覆盖关键用户流程,但速度慢且脆弱。
测试工具选型
| 类型 | 工具 | 说明 |
|---|---|---|
| 测试框架 | Vitest / Jest | 运行测试、断言、覆盖率 |
| 组件测试 | Testing Library | 以用户视角测试组件 |
| E2E 测试 | Playwright / Cypress | 浏览器自动化 |
| Mock | MSW (Mock Service Worker) | 模拟 API 请求 |
单元测试(Vitest)
// utils/math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function isEven(n: number): boolean {
return n % 2 === 0;
}
// utils/__tests__/math.test.ts
import { describe, it, expect } from 'vitest';
import { add, isEven } from '../math';
describe('add', () => {
it('应该正确计算两个正数之和', () => {
expect(add(1, 2)).toBe(3);
});
it('应该正确处理负数', () => {
expect(add(-1, 1)).toBe(0);
expect(add(-1, -2)).toBe(-3);
});
});
describe('isEven', () => {
it('应该正确判断偶数', () => {
expect(isEven(2)).toBe(true);
expect(isEven(0)).toBe(true);
});
it('应该正确判断奇数', () => {
expect(isEven(1)).toBe(false);
expect(isEven(3)).toBe(false);
});
});
组件测试(React Testing Library)
// components/Counter.tsx
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>计数: {count}</p>
<button onClick={() => setCount(c => c + 1)}>增加</button>
</div>
);
}
// components/__tests__/Counter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Counter } from '../Counter';
describe('Counter', () => {
it('应该渲染初始计数', () => {
render(<Counter />);
expect(screen.getByText('计数: 0')).toBeDefined();
});
it('点击按钮应该增加计数', () => {
render(<Counter />);
fireEvent.click(screen.getByText('增加'));
expect(screen.getByText('计数: 1')).toBeDefined();
});
});
测试原则(Testing Library 哲学)
// ✅ 推荐:通过用户可见的内容查找
screen.getByText('提交');
screen.getByRole('button', { name: '提交' });
screen.getByLabelText('用户名');
screen.getByPlaceholderText('请输入密码');
// ❌ 避免:通过实现细节查找
screen.getByTestId('submit-button'); // 仅作为最后手段
wrapper.find('.btn-submit'); // 依赖 CSS 类名
API Mock(MSW)
// mocks/handlers.ts
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 });
}),
];
// 测试中使用 MSW
import { setupServer } from 'msw/node';
import { handlers } from '../mocks/handlers';
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it('应该获取用户列表', async () => {
render(<UserList />);
expect(await screen.findByText('张三')).toBeDefined();
expect(await screen.findByText('李四')).toBeDefined();
});
E2E 测试(Playwright)
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test('用户应该能成功登录', async ({ page }) => {
await page.goto('/login');
// 填写表单
await page.fill('[name="username"]', 'admin');
await page.fill('[name="password"]', 'password123');
// 提交
await page.click('button[type="submit"]');
// 验证跳转到首页
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('.welcome')).toContainText('欢迎回来');
});
test('显示登录错误信息', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="username"]', 'wrong');
await page.fill('[name="password"]', 'wrong');
await page.click('button[type="submit"]');
// 验证错误提示
await expect(page.locator('.error-message')).toBeVisible();
await expect(page.locator('.error-message')).toContainText('用户名或密码错误');
});
覆盖率
npx vitest --coverage
覆盖率指标:
- 行覆盖率:被执行的代码行数比例
- 分支覆盖率:if/else、三元运算符等分支的执行比例
- 函数覆盖率:被调用的函数比例
注意:高覆盖率不等于高质量测试。测试的目的是验证行为,而非凑数字。
测试驱动开发(TDD)流程
- 红灯:先写一个会失败的测试
- 绿灯:写最少的代码让测试通过
- 重构:优化代码,确保测试依然通过
// 1. 先写测试
describe('validateEmail', () => {
it('应该验证有效的邮箱', () => {
expect(validateEmail('test@example.com')).toBe(true);
});
it('应该拒绝无效的邮箱', () => {
expect(validateEmail('not-an-email')).toBe(false);
});
});
// 2. 再写实现
export function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
总结
前端测试的最佳实践:
- 测试行为,而非实现:关注用户看到什么、操作什么
- 优先集成测试:验证组件和功能正常工作
- 合理的测试金字塔:多单元测试、适量集成测试、少量 E2E
- 将测试集成到 CI:每次提交自动运行
- 避免过度 Mock:减少对实现细节的依赖
从 Vitest + Testing Library 开始,逐步引入 MSW 和 Playwright,建立起适合团队和项目的测试体系。