name: 测试模式 description: 通过战略性测试构建信心——涵盖单元测试、集成测试、端到端测试和视觉回归测试。包括测试哲学、框架选择、模拟策略和CI集成。当涉及测试、测试覆盖率、TDD或质量保证请求时触发。 license: MIT complexity: 初学者 time_to_learn: 30分钟 tags:
- 测试
- 单元测试
- 集成测试
- 端到端测试
- vitest
- playwright
- 模拟
测试模式
通过战略性测试构建信心。
测试哲学
测试奖杯
╱╲
╱ ╲ 端到端测试(少量)
╱────╲
╱ ╲ 集成测试(更多)
╱────────╲
╱ ╲ 单元测试(大量、快速)
╱────────────╲
╱ 静态测试 ╲ TypeScript、ESLint
╱────────────────╲
测试什么
| 测试类型 | 内容 | 原因 |
|---|---|---|
| 静态测试 | 类型、lint规则 | 在编写时捕获错误 |
| 单元测试 | 纯函数、工具函数 | 快速、精确的反馈 |
| 集成测试 | 组件 + 依赖项 | 测试合约 |
| 端到端测试 | 用户流程 | 真实使用中的信心 |
不测试什么
- 实现细节(内部状态、私有方法)
- 第三方库内部
- 常量和配置
- 框架代码
单元测试
结构:AAA模式
describe('calculateTotal', () => {
it('should apply discount to subtotal', () => {
// Arrange
const items = [{ price: 100 }, { price: 50 }];
const discount = 0.1;
// Act
const result = calculateTotal(items, discount);
// Assert
expect(result).toBe(135);
});
});
测试命名
// 模式:should [预期行为] when [条件]
it('should return empty array when input is null')
it('should throw error when user is not authenticated')
it('should apply discount when coupon is valid')
测试纯函数
// utils/format.ts
export function formatCurrency(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
// utils/format.test.ts
describe('formatCurrency', () => {
it('should format cents to dollar string', () => {
expect(formatCurrency(1000)).toBe('$10.00');
expect(formatCurrency(1)).toBe('$0.01');
expect(formatCurrency(0)).toBe('$0.00');
});
it('should handle negative values', () => {
expect(formatCurrency(-500)).toBe('$-5.00');
});
});
需要考虑的边缘情况
- 空/null/undefined输入
- 边界值(0, -1, MAX_INT)
- 空数组/对象
- 无效类型(如果不使用TypeScript)
- 异步边缘情况(竞态条件、超时)
React组件测试
测试库哲学
“你的测试越像软件使用方式,它们能给你的信心就越多。”
组件测试结构
import { render, screen, fireEvent } from '@testing-library/react';
import { Counter } from './Counter';
describe('Counter', () => {
it('should increment count when button clicked', async () => {
render(<Counter initialCount={0} />);
const button = screen.getByRole('button', { name: /increment/i });
await fireEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
});
查询优先级
按此顺序使用查询(从最优先到最不优先):
getByRole- 对所有人可访问getByLabelText- 表单字段getByPlaceholderText- 如果没有标签getByText- 非交互内容getByTestId- 最后手段
异步测试
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
it('should load data after button click', async () => {
const user = userEvent.setup();
render(<DataLoader />);
await user.click(screen.getByRole('button', { name: /load/i }));
// 等待异步内容
await waitFor(() => {
expect(screen.getByText('Data loaded')).toBeInTheDocument();
});
});
模拟
// 模拟模块
vi.mock('./api', () => ({
fetchUser: vi.fn(() => Promise.resolve({ name: 'Test User' })),
}));
// 模拟钩子
vi.mock('./useAuth', () => ({
useAuth: () => ({ user: { id: '1' }, isLoading: false }),
}));
// 模拟fetch
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ data: 'test' }),
})
);
集成测试
API路由测试
import { createMocks } from 'node-mocks-http';
import handler from './api/posts';
describe('/api/posts', () => {
it('should return posts list', async () => {
const { req, res } = createMocks({
method: 'GET',
});
await handler(req, res);
expect(res._getStatusCode()).toBe(200);
expect(JSON.parse(res._getData())).toHaveLength(3);
});
});
数据库集成
import { db } from '@/lib/db';
describe('User service', () => {
beforeEach(async () => {
await db.user.deleteMany(); // 清理状态
});
afterAll(async () => {
await db.$disconnect();
});
it('should create user with valid data', async () => {
const user = await createUser({
email: 'test@example.com',
name: 'Test User'
});
expect(user.id).toBeDefined();
expect(user.email).toBe('test@example.com');
});
});
端到端测试
Playwright设置
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => {
test('should allow user to sign in', 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('h1')).toContainText('Welcome');
});
});
页面对象模式
// e2e/pages/login.page.ts
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) { <!-- allow-secret -->
await this.page.fill('[name="email"]', email);
await this.page.fill('[name="password"]', password);
await this.page.click('button[type="submit"]');
}
}
// e2e/auth.spec.ts
test('should login successfully', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password');
await expect(page).toHaveURL('/dashboard');
});
视觉回归测试
test('homepage should match snapshot', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png');
});
模拟策略
何时模拟
| 模拟对象 | 时机 |
|---|---|
| 外部API | 在单元/集成测试中总是模拟 |
| 数据库 | 有时模拟(测试容器 vs 模拟) |
| 时间/日期 | 测试依赖时间的逻辑时 |
| 随机性 | 测试确定性输出时 |
| 网络 | 在单元测试中总是模拟 |
MSW(模拟服务工作者)
// mocks/handlers.ts
import { rest } from 'msw';
export const handlers = [
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.json([
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
])
);
}),
rest.post('/api/users', async (req, res, ctx) => {
const body = await req.json();
return res(ctx.status(201), ctx.json({ id: 3, ...body }));
}),
];
测试配置
Vitest配置
// 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: ['./test/setup.ts'],
coverage: {
reporter: ['text', 'html'],
exclude: ['node_modules/', 'test/'],
},
},
});
测试设置
// test/setup.ts
import '@testing-library/jest-dom';
import { server } from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
覆盖率策略
有意义的覆盖率
| 类型 | 目标 | 注意事项 |
|---|---|---|
| 语句覆盖率 | 70-80% | 不要追求100% |
| 分支覆盖率 | 70-80% | 测试重要路径 |
| 函数覆盖率 | 80%+ | 所有公共API |
| 行覆盖率 | 70-80% | 平衡与开发速度 |
高覆盖率不代表什么
- 测试是好的
- 没有错误
- 代码可维护
- 边缘情况已覆盖
CI集成
# .github/workflows/test.yml
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
- uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
相关技能
互补技能(一起使用)
- tdd-workflow - 测试驱动开发工作流;与测试模式结合使用以实现完整TDD实践
- verification-loop - 使用测试作为质量门的迭代验证过程
- deployment-cicd - 在流水线中运行测试的CI/CD集成
替代技能(相似目的)
- webapp-testing - 专注于浏览器自动化的专项Web应用程序测试
先决技能(先学习)
- 无必需技能 - 这是一个基础技能
参考文献
references/vitest-patterns.md- Vitest特定模式references/playwright-patterns.md- 端到端测试模式references/mock-examples.md- 模拟示例