测试设计 — 路径、用例选择与分层策略
测试金字塔(重新审视)
┌──────────┐
│ E2E │ 少(5%) : 关键用户路径
┌┴──────────┴┐
│ 集成测试 │ 中(15%) : 模块间契约
┌┴────────────┴┐
│ 单元测试 │ 多(80%) : 每个函数的逻辑分支
└───────────────┘
反模式 — 冰淇淋甜筒:E2E 多、单元少 → 跑得慢、定位难、维护贵。
单元测试:用例选择
等价类划分
// 被测函数
function calculateDiscount(age: number, isMember: boolean): number
// 等价类划分
// age: [0,12] 儿童 [13,59] 成人 [60,∞) 老人 [-∞,-1] 非法
// isMember: true / false
// 只需 4+1 个用例覆盖所有等价类:
test('儿童会员 50% 折扣', () => expect(calculateDiscount(10, true)).toBe(0.5))
test('成人非会员 0%', () => expect(calculateDiscount(30, false)).toBe(0))
test('老人会员 30% 折扣', () => expect(calculateDiscount(65, true)).toBe(0.3))
test('老人非会员 10% 折扣', () => expect(calculateDiscount(70, false)).toBe(0.1))
test('负数抛异常', () => expect(() => calculateDiscount(-1, true)).toThrow())
边界值分析
// 年龄边界: -1, 0, 12, 13, 59, 60
test('刚好 0 岁', () => calculateDiscount(0, false))
test('刚好 12 岁', () => calculateDiscount(12, false))
test('刚好 13 岁', () => calculateDiscount(13, false))
test('刚好 59 岁', () => calculateDiscount(59, false))
test('刚好 60 岁', () => calculateDiscount(60, false))
Mock vs Fake vs Stub
// 被测代码
class OrderService {
constructor(private repo: IOrderRepo, private notifier: INotifier) {}
async place(order: Order) {
await this.repo.save(order);
await this.notifier.send(order.userId, "订单已创建");
}
}
// Fake — 内存实现,用于测试
class FakeOrderRepo implements IOrderRepo {
orders: Order[] = [];
async save(o: Order) { this.orders.push(o); }
async findById(id: number) { return this.orders.find(o => o.id === id); }
}
// Mock — 验证调用行为
test('下单应保存并通知', async () => {
const mockNotifier = { send: jest.fn() };
const fakeRepo = new FakeOrderRepo();
const svc = new OrderService(fakeRepo, mockNotifier);
await svc.place(new Order(1, "item"));
expect(fakeRepo.orders).toHaveLength(1); // 状态验证
expect(mockNotifier.send).toHaveBeenCalledTimes(1); // 行为验证
});
选择指南
| 类型 | 何时用 |
|---|---|
| Fake | 有状态依赖(DB、缓存)→ 内存实现 |
| Stub | 返回固定值的查询 → 直接替代 |
| Mock | 需要验证”是否被调用/调用几次” |
| Spy | 真实对象 + 记录调用(部分 mock) |
集成测试:契约验证
// 测 UserRepo 和真实 MySQL 的交互
describe('UserRepository → MySQL', () => {
let repo: UserRepository;
beforeAll(async () => {
// 连接测试数据库(非生产)
const db = await mysql.createConnection(testDbConfig);
repo = new UserRepository(db);
});
beforeEach(async () => {
await repo.deleteAll(); // 每个用例前清空
});
test('保存后能查到', async () => {
await repo.save(new User(1, "Alice"));
const user = await repo.findById(1);
expect(user.name).toBe("Alice");
});
test('查不存在的返回 null', async () => {
const user = await repo.findById(999);
expect(user).toBeNull();
});
});
E2E:只测核心路径
// 不要测"每个按钮"!只测关键用户旅程
test('用户注册 → 登录 → 创建订单', async () => {
// 注册
await page.goto('/register');
await page.fill('#email', 'test@example.com');
await page.click('#submit');
await expect(page.locator('.success')).toBeVisible();
// 登录
await page.goto('/login');
await page.fill('#email', 'test@example.com');
await page.click('#login');
// 下单
await page.click('#new-order');
await page.fill('#item', 'Test Item');
await page.click('#place-order');
await expect(page.locator('.order-id')).toBeVisible();
});
覆盖率指南
| 指标 | 健康值 | 说明 |
|---|---|---|
| 行覆盖率 | 70-80% | 100% 成本太高且边际收益递减 |
| 分支覆盖率 | 60-70% | 每个 if/else 都要走到 |
| 函数覆盖率 | 80%+ | 核心逻辑 100% |
不追求 100%:getter/setter、简单转发代码不测;复杂算法 100% 测。
CI 集成
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
测试反模式
❌ 测试实现细节: "调了 save() 然后调了 send()" — 重构就断
✅ 测试行为: "下单后订单已保存 + 用户收到通知"
❌ 每个测试依赖前一个状态: test2 依赖 test1 的数据 → 顺序敏感
✅ 每个测试独立: beforeEach 重置状态
❌ 过度 mock: Mock 了 5 个依赖 → 测试的是 mock 不是代码
✅ 轻 mock: 只用 fake 替代外部 IO,核心逻辑走真的