输入关键词开始搜索

测试设计 — 路径、用例选择与分层策略

测试金字塔(重新审视)

        ┌──────────┐
        │  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,核心逻辑走真的