名称: 测试模式 描述: 在全栈中编写有效的测试——单元测试、集成测试、端到端测试和视觉回归测试。涵盖测试哲学、框架选择、模拟策略和CI集成。触发于测试、测试覆盖率、TDD或质量保证请求。 许可证: MIT 复杂度: 初学者 学习时间: 30分钟 标签:
- 测试
- 单元测试
- 集成测试
- 端到端测试
- vitest
- playwright
- 模拟
测试模式
通过战略测试建立信心。
测试哲学
测试奖杯
╱╲
╱ ╲ 端到端测试(少)
╱────╲
╱ ╲ 集成测试(多)
╱────────╲
╱ ╲ 单元测试(多,快)
╱────────────╲
╱ 静态测试 ╲ TypeScript, ESLint
╱────────────────╲
测试什么
| 测试类型 | 测试内容 | 为什么测试 |
|---|---|---|
| 静态测试 | 类型、lint规则 | 在编写时捕获错误 |
| 单元测试 | 纯函数、工具 | 快速、精确的反馈 |
| 集成测试 | 组件 + 依赖项 | 测试合约 |
| 端到端测试 | 用户流程 | 对真实使用有信心 |
不测试什么
- 实现细节(内部状态、私有方法)
- 第三方库内部
- 常量和配置
- 框架代码
单元测试
结构:AAA模式
describe('calculateTotal', () => {
it('应将折扣应用于小计', () => {
// 安排
const items = [{ price: 100 }, { price: 50 }];
const discount = 0.1;
// 执行
const result = calculateTotal(items, discount);
// 断言
expect(result).toBe(135);
});
});
测试命名
// 模式:应该 [预期行为] 当 [条件]
it('当输入为null时应返回空数组')
it('当用户未认证时应抛出错误')
it('当优惠券有效时应应用折扣')
测试纯函数
// utils/format.ts
export function formatCurrency(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
// utils/format.test.ts
describe('formatCurrency', () => {
it('应将美分格式化为美元字符串', () => {
expect(formatCurrency(1000)).toBe('$10.00');
expect(formatCurrency(1)).toBe('$0.01');
expect(formatCurrency(0)).toBe('$0.00');
});
it('应处理负值', () => {
expect(formatCurrency(-500)).toBe('$-5.00');
});
});
考虑的边界情况
- 空/null/未定义输入
- 边界值(0, -1, MAX_INT)
- 空数组/对象
- 无效类型(如果不使用TypeScript)
- 异步边界情况(竞争条件、超时)
React组件测试
测试库哲学
“您的测试越像软件的使用方式,它们就能给您越多的信心。”
组件测试结构
import { render, screen, fireEvent } from '@testing-library/react';
import { Counter } from './Counter';
describe('Counter', () => {
it('点击按钮时应增加计数', 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('点击按钮后应加载数据', 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('应返回帖子列表', 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('用户服务', () => {
beforeEach(async () => {
await db.user.deleteMany(); // 清理状态
});
afterAll(async () => {
await db.$disconnect();
});
it('应使用有效数据创建用户', 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('认证', () => {
test('应允许用户登录', 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('应成功登录', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password');
await expect(page).toHaveURL('/dashboard');
});
视觉回归
test('主页应匹配快照', 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% | 与速度平衡 |
高覆盖率不意味着什么
- 测试是好的
- 没有bug
- 代码可维护
- 边界情况已覆盖
CI集成
# .github/workflows/test.yml
name: 测试
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- 模拟配方