TDD 与测试策略
概述
测试驱动开发(TDD)和合理的测试策略是保证代码质量的关键实践。
一、测试金字塔
/\
/ \
/ E2E \ 少量:关键用户流程
/------\
/ 集成 \ 中等:模块间交互
/----------\
/ 单元 \ 大量:独立函数/组件
/--------------\各层职责
| 层级 | 数量 | 速度 | 成本 | 关注点 |
|---|---|---|---|---|
| E2E | ~10% | 慢 | 高 | 用户流程 |
| 集成 | ~20% | 中 | 中 | 模块协作 |
| 单元 | ~70% | 快 | 低 | 独立逻辑 |
前端测试金字塔变体
/\
/ \
/ E2E \ Playwright/Cypress
/------\
/ 组件集成 \ RTL + MSW
/----------\
/ 组件单元 \ RTL
/--------------\
/ 函数单元 \ Jest/Vitest二、TDD 红绿重构
核心循环
┌──────────┐
│ RED │ 写一个失败的测试
└────┬─────┘
│
┌────▼─────┐
│ GREEN │ 用最简单的代码通过测试
└────┬─────┘
│
┌────▼─────┐
│ REFACTOR │ 重构代码,保持测试通过
└────┬─────┘
│
└──────── 循环TDD 示例
javascript
// 需求:实现一个购物车折扣计算器
// 规则:满 100 减 10,满 200 减 30
// Step 1: RED - 写失败的测试
test('no discount for total under 100', () => {
expect(calculateDiscount(50)).toBe(0);
});
// Step 2: GREEN - 最简实现
function calculateDiscount(total) {
return 0;
}
// Step 3: RED - 下一个测试
test('10 discount for total >= 100', () => {
expect(calculateDiscount(100)).toBe(10);
expect(calculateDiscount(150)).toBe(10);
});
// Step 4: GREEN - 扩展实现
function calculateDiscount(total) {
if (total >= 100) return 10;
return 0;
}
// Step 5: RED - 下一个测试
test('30 discount for total >= 200', () => {
expect(calculateDiscount(200)).toBe(30);
expect(calculateDiscount(300)).toBe(30);
});
// Step 6: GREEN - 完成实现
function calculateDiscount(total) {
if (total >= 200) return 30;
if (total >= 100) return 10;
return 0;
}
// Step 7: REFACTOR - 重构
const DISCOUNT_TIERS = [
{ threshold: 200, discount: 30 },
{ threshold: 100, discount: 10 },
];
function calculateDiscount(total) {
const tier = DISCOUNT_TIERS.find(t => total >= t.threshold);
return tier?.discount ?? 0;
}三、测试策略矩阵
按功能类型选择测试类型
| 功能类型 | 推荐测试 | 示例 |
|---|---|---|
| 纯函数/工具 | 单元测试 | formatDate, calculateTotal |
| UI 组件 | 组件测试 (RTL) | Button, Form |
| 状态逻辑 | Hook 测试 | useAuth, useCart |
| API 集成 | 集成测试 (MSW) | fetchUser |
| 用户流程 | E2E | 登录→购物→支付 |
| 样式/布局 | 视觉回归 | 响应式布局 |
测试覆盖策略
javascript
// 高覆盖优先级
// 1. 核心业务逻辑
// 2. 公共组件库
// 3. 关键用户路径
// 4. 边界条件
// 低覆盖优先级
// 1. 第三方库封装
// 2. 简单展示组件
// 3. 样式细节
// 4. 实验性功能四、测试最佳实践
1. 测试隔离
javascript
// ❌ 测试间共享状态
let counter = 0;
test('test 1', () => {
counter++;
expect(counter).toBe(1);
});
test('test 2', () => {
counter++;
expect(counter).toBe(1); // 失败!
});
// ✅ 每个测试独立
let counter;
beforeEach(() => {
counter = 0;
});
test('test 1', () => {
counter++;
expect(counter).toBe(1);
});
test('test 2', () => {
counter++;
expect(counter).toBe(1); // 通过
});2. 避免测试实现细节
javascript
// ❌ 测试实现细节
test('sets isLoading state', () => {
const { result } = renderHook(() => useData());
expect(result.current.isLoading).toBe(true);
});
// ✅ 测试用户可见行为
test('shows loading indicator', async () => {
render(<DataComponent />);
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});3. 使用测试工厂
javascript
// test-factories.js
export const createUser = (overrides = {}) => ({
id: 1,
name: 'Test User',
email: 'test@example.com',
role: 'user',
...overrides
});
export const createProduct = (overrides = {}) => ({
id: 1,
name: 'Test Product',
price: 100,
stock: 10,
...overrides
});
// 使用
test('admin can delete user', () => {
const admin = createUser({ role: 'admin' });
const target = createUser({ id: 2 });
// ...
});4. 避免过度 Mock
javascript
// ❌ Mock 一切
jest.mock('./api');
jest.mock('./utils');
jest.mock('./config');
// ✅ 只 Mock 边界
// - 外部 API
// - 时间/随机数
// - 文件系统
jest.mock('./api'); // API 是边界
// utils 和 config 使用真实实现五、覆盖率策略
合理的覆盖率目标
javascript
// jest.config.js
module.exports = {
coverageThreshold: {
// 全局阈值
global: {
branches: 70,
functions: 80,
lines: 80,
statements: 80
},
// 关键模块更高标准
'./src/core/**: {
branches: 90,
functions: 95,
lines: 95
},
// 工具类可以放宽
'./src/utils/**': {
lines: 60
}
}
};覆盖率陷阱
javascript
// ❌ 追求 100% 覆盖率的无意义测试
test('getter returns value', () => {
const obj = { get name() { return 'test'; } };
expect(obj.name).toBe('test');
});
// ✅ 关注有意义的覆盖
test('handles edge case', () => {
expect(divide(10, 0)).toThrow('Division by zero');
});六、持续集成测试
测试流水线阶段
yaml
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- run: npm run test:unit -- --coverage
- uses: codecov/codecov-action@v3
integration-test:
runs-on: ubuntu-latest
needs: unit-test
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- run: npm run test:integration
e2e-test:
runs-on: ubuntu-latest
needs: integration-test
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run test:e2ePR 检查策略
| 检查项 | 阻断 PR | 可选 |
|---|---|---|
| 单元测试通过 | ✅ | |
| 覆盖率不下降 | ✅ | |
| 集成测试通过 | ✅ | |
| E2E 测试通过 | ✅ (耗时) | |
| 视觉回归 | ✅ |
七、测试组织结构
目录结构选项
# 选项 1:与源码同级
src/
├── components/
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.test.tsx
│ │ └── Button.stories.tsx
│ └── Form/
│ ├── Form.tsx
│ └── Form.test.tsx
# 选项 2:独立测试目录
src/
├── components/
│ ├── Button.tsx
│ └── Form.tsx
tests/
├── unit/
│ ├── Button.test.tsx
│ └── Form.test.tsx
├── integration/
│ └── checkout.test.tsx
└── e2e/
└── purchase-flow.spec.ts面试高频题
Q1: TDD 的优缺点?
优点:
- 更好的代码设计(可测试性强)
- 减少过度设计
- 内置回归测试
- 提高开发信心
缺点:
- 初期开发速度变慢
- 需要团队纪律
- 某些场景不适用(探索性开发)
Q2: 如何制定测试策略?
答:
- 分析业务关键路径
- 确定各层测试比例(金字塔)
- 设置合理的覆盖率目标
- 建立 CI 自动化流程
- 定期回顾和调整
Q3: 什么时候不写测试?
答:
- 原型/POC 阶段
- 第三方库的简单封装
- 纯展示组件(只有 props 到 UI)
- 即将废弃的代码
- 时间极其紧迫(但要技术债记录)
Q4: 如何说服团队采用 TDD?
答:
- 从小范围试点开始
- 展示 bug 减少的数据
- 强调重构的安全性
- 计算长期维护成本节省
- 结合 Code Review 推广