一、为什么要把测试写在代码前面?

很多人写代码的习惯是:先写功能,再补测试。但这样容易陷入"为了测试而测试"的困境。测试驱动开发(TDD)反其道而行,要求我们先写测试,再写实现代码。这就像造房子先画图纸,而不是边砌墙边改设计。

举个生活中的例子:你要做一道番茄炒蛋。传统方式是直接开火,发现盐放多了再补救;TDD则是先明确"咸度适中"的标准,准备好量勺再下锅。虽然前期准备费点时间,但成品质量更有保障。

二、TDD实战三步曲

1. 红灯阶段:写一个必定失败的测试

(示例技术栈:JavaScript + Jest)

// 测试需求:实现字符串反转功能
test('reverseString函数应当返回倒序字符串', () => {
  // 此时reverseString还不存在,测试应该失败
  expect(reverseString('hello')).toBe('olleh'); 
});

运行测试会报错,就像交通红灯提醒我们"此路不通"。这个阶段帮我们明确目标,避免过度设计。

2. 绿灯阶段:用最简单的方式通过测试

// 最简陋的实现方案
function reverseString(str) {
  return 'olleh'; // 硬编码通过当前测试
}

虽然这个实现很蠢,但它验证了测试的有效性。就像学自行车时先装辅助轮,确保基础机制可行。

3. 重构阶段:优化代码结构

// 完善后的实现
function reverseString(str) {
  return str.split('').reverse().join(''); 
}

// 补充边缘测试用例
test('处理空字符串', () => {
  expect(reverseString('')).toBe('');
});

现在我们有了一套安全网,可以放心重构。测试会立即告诉我们是否破坏了原有功能。

三、TDD带来的隐性收益

1. 设计引导作用

当你要测试一个购物车计算总价的功能时,测试用例会逼你思考:

// 测试驱动出清晰的接口设计
test('计算含折扣的总价', () => {
  const cart = new ShoppingCart();
  cart.add({ price: 100, discount: 0.8 });
  expect(cart.getTotal()).toBe(80);
});

这个测试迫使你明确:折扣是存储在商品还是购物车?计算逻辑放在哪层?这些问题在传统开发中可能到联调时才会暴露。

2. 文档即测试

好的测试套件就是活文档。新人通过阅读测试可以快速理解:

// 清晰地展示API边界
test('超过库存数量时抛出异常', () => {
  expect(() => warehouse.pick('iPhone', 999)).toThrow();
});

这比看万字文档更直观,且永远不会过时。

四、TDD适用场景与避坑指南

适合场景:

  • 算法逻辑明确的功能(如支付计算)
  • 长期维护的核心模块
  • 需要频繁重构的代码

需要谨慎:

  • 快速原型开发阶段
  • 强UI交互的部分
  • 第三方服务集成

常见误区:

  1. 过度测试:测试私有方法、每个getter/setter
  2. 测试耦合:修改实现导致大量测试报错
  3. 虚假绿灯:测试永远通过但没验证真实逻辑

五、进阶技巧:从单元测试到架构

TDD可以向上延伸到集成测试:

// 测试API端点(示例技术栈:Node.js + Supertest)
test('POST /orders返回201状态码', async () => {
  const res = await request(app)
    .post('/orders')
    .send({ productId: 1 });
  expect(res.statusCode).toBe(201);
});

这种分层测试就像建筑行业的"钢筋验收→楼层压力测试→整体抗震测试"的递进过程。

六、总结:测试是更好的设计工具

TDD本质上是通过测试用例来具象化需求,其价值不在于测试覆盖率数字,而在于:

  • 迫使你从调用者角度思考接口设计
  • 创建可重复验证的安全网
  • 降低认知负荷(每次只需关注当前测试用例)

刚开始可能会觉得节奏被打断,就像刚学打字要看键盘。但坚持20个小时后,你会发现代码质量、设计能力和开发节奏都有质的提升。