TDD 工作流 - 用 Chai 驱动测试先行开发
什么是 TDD
测试驱动开发(Test-Driven Development)是一种开发方法论,核心循环:
- 红灯(Red):写一个测试,它会失败
- 绿灯(Green):写最小量的代码让测试通过
- 重构(Refactor):不改变行为的前提下优化代码
Chai 是这个循环中的"红灯检测器"——它告诉你测试是失败(红灯)还是通过(绿灯)。
一个完整的 TDD 例子
需求:写一个计算器模块,支持加、减、乘、除。
第一步:写第一个测试(红灯)
// test/calculator.test.js
const { expect } = require('chai');
const calculator = require('../src/calculator');
describe('Calculator', function () {
describe('#add()', function () {
it('1 + 1 应该等于 2', function () {
expect(calculator.add(1, 1)).to.equal(2);
});
});
});
此时 src/calculator.js 还不存在。运行测试:
npm test
// 失败:Cannot find module '../src/calculator'
红灯亮。
第二步:写最小实现(绿灯)
// src/calculator.js
module.exports = {
add: (a, b) => a + b
};
运行测试:通过。绿灯亮。
第三步:重构(可选)
当前实现没有什么可重构的。继续下一个功能。
第四步:再写一个测试(红灯)
describe('#add()', function () {
it('负数相加', function () {
expect(calculator.add(-1, -2)).to.equal(-3);
});
});
运行测试:通过。绿灯。
第五步:覆盖剩余功能
describe('#subtract()', function () {
it('减法', function () {
expect(calculator.subtract(5, 3)).to.equal(2);
});
});
describe('#divide()', function () {
it('正常除法', function () {
expect(calculator.divide(10, 2)).to.equal(5);
});
it('除以 0 应该抛错', function () {
expect(() => calculator.divide(10, 0)).to.throw();
});
});
每个测试 → 红灯 → 绿灯 → 重构。循环下去。
用 describe / context / it 组织规格
BDD 风格的命名让测试读起来像一份规格说明书:
describe('订单服务', function () {
context('创建订单', function () {
context('库存充足', function () {
it('应该成功创建订单', function () { /* ... */ });
it('应该扣减库存', function () { /* ... */ });
it('应该记录操作日志', function () { /* ... */ });
});
context('库存不足', function () {
it('应该返回库存不足错误', function () { /* ... */ });
it('不应该创建订单记录', function () { /* ... */ });
});
});
context('查询订单', function () {
it('存在的订单应该返回详情', function () { /* ... */ });
it('不存在的订单应该返回 null', function () { /* ... */ });
});
});
Mocha 运行时输出的报告读起来像这样:
订单服务
创建订单
库存充足
✓ 应该成功创建订单
✓ 应该扣减库存
✓ 应该记录操作日志
库存不足
✓ 应该返回库存不足错误
✓ 不应该创建订单记录
查询订单
✓ 存在的订单应该返回详情
✓ 不存在的订单应该返回 null
这就是"规格说明"的真正样子——不仅描述系统应该做什么,还能自动执行、自动验证。
何时用 TDD
- 新功能开发:每写一个测试定义一个行为 → 写实现让它通过。
- Bug 修复:先写一个能复现 bug 的失败测试 → 修 bug 让它通过 → 这个测试就成了 bug 的回归保护。
- 重构现有代码:如果没有测试保护,先补测试再重构。
何时不用严格 TDD
- 原型 / 验证性代码:可以快速写功能和测试,事后补齐。
- UI / 视觉层:TDD 在纯视觉组件上效率较低。
- 学习新技术时:先跟着教程跑起来,再考虑写测试。
但"不用严格 TDD"不等于"不写测试"。测试应该在开发过程中自然生长,而不是等开发完才补。
Chai 断言在 TDD 中的角色
Chai 的断言就是"红灯检测器":
// 这条断言 = "这个功能应该 X"
// 如果不通过 → 红灯(代码还没实现或实现错了)
// 如果通过 → 绿灯(行为正确)
expect(result).to.equal(expected);
assert 风格和 BDD 风格都能驱动 TDD,选哪个都行。关键是测试先写、实现后写、红转绿。
让 .throw 精确到错误类型
错误处理也是 TDD 应该覆盖的行为:
// 红灯
expect(() => calculator.divide(10, 0)).to.throw(DivideByZeroError, '除数不能为零');
这定义了"除以 0 时应该抛 DivideByZeroError 且消息包含'除数不能为零'"。实现:
class DivideByZeroError extends Error {
constructor() {
super('除数不能为零');
this.name = 'DivideByZeroError';
}
}
function divide(a, b) {
if (b === 0) throw new DivideByZeroError();
return a / b;
}
重构阶段
全部测试通过后,重构阶段的任务是让代码更好但保持所有测试仍然绿色:
- 提取公共函数
- 改善命名
- 消除重复
- 引入模式
- 优化结构
重构完跑一遍测试——全绿就说明行为没变。
下一步
- describe / it 组织 → 搭配 Mocha
- 异步 TDD → 异步测试
- HTTP 接口测试 TDD → chai-http 插件