用故事讲清楚:为什么“先写测试,再看它失败”是程序员的超能力
一、从前有个叫老王的程序员
老王写代码有个坏习惯:噼里啪啦先写一堆功能,最后补几个测试。
他觉得这样快,反正“代码能跑就行”。
直到有一天,他写了一个用户注册功能。
他手动试了:输入邮箱、密码、点注册——成功!🎉 上线!
结果第二天,用户炸了:
“我输入空密码也能注册?!”
“邮箱随便打个‘a’也能过?!”
“同一个邮箱注册了三次?!”
老王慌了,赶紧打补丁,修了半天。
修完一个bug,又冒出一个新bug——因为他的代码已经像意大利面一样缠在一起。
领导说:“老王,你写的功能叫‘注册’,用户管它叫‘漏洞大会’。”
老王很委屈:“我明明测试过啊!”
这时候,一位神秘的老程序员递给他一张纸条,上面写着:
“没有先写失败测试的代码,都是技术债。”
纸条背面画着一个奇怪的循环:
🔴 RED → 🟢 GREEN → 🔵 REFACTOR → 重复
老王问:“这是什么魔法?”
神秘人说:“这叫 TDD(测试驱动开发) 。
从今天起,你不写测试就不准写代码。写测试之前,必须先看它失败。”
老王半信半疑,试了一个星期……
二、TDD 到底是什么?—— 三个颜色的仪式
TDD 就像做菜先尝汤,而不是做完一整桌再发现没放盐。
🔴 第一步:RED —— 写一个会失败的测试
铁律:没有失败测试,就不写任何生产代码。
老王想加一个功能:isAdult(age),输入年龄,返回是否成年(>=18)。
他先写测试(不是先写函数):
test('年龄18岁应该是成年人', () => {
expect(isAdult(18)).toBe(true);
});
然后运行测试:
npm test
结果:
FAIL: isAdult is not defined
老王看到红色失败,反而笑了:“好!测试确实在检查真实行为,不是摆设。”
为什么要看它失败?
如果你写测试时它直接通过了,说明:要么功能已经存在(你在测旧代码),要么你的测试是假的(比如永远返回true)。
没看到失败 = 没证明测试有用。
🟢 第二步:GREEN —— 写最少代码让它通过
老王现在只为了通过这个测试写代码,不加任何多余功能:
function isAdult(age) {
return age >= 18;
}
再运行测试:
PASS: 年龄18岁应该是成年人 ✅
够用就行,不写if、不写错误处理、不加缓存。
因为你现在只有一个测试。
常见错误:写测试的时候顺便把“年龄不能为负数”也实现了。
TDD说:NO!只做让当前测试变绿的最小工作。后面的测试会驱动你增加逻辑。
🔵 第三步:REFACTOR —— 整理代码,保持绿色
现在测试通过,老王可以安心重构(比如改变量名、提取函数),只要测试保持绿色。
例如他觉得isAdult名字不够清晰,改成isAdultByAge——改完立刻跑测试,还是绿色。
重构只能在绿灯下进行。没有测试保护的重构,等于高空走钢丝不带保险。
然后重复 —— 写下一个失败测试
老王想处理负数年龄:
test('负数年龄应该返回false', () => {
expect(isAdult(-5)).toBe(false);
});
运行 → 红色(因为-5 >=18? false? 等一下,-5>=18是false,其实当前函数返回false……咦?测试可能直接通过?)
等等,这里有个陷阱:当前isAdult(-5)返回false,测试期望false,就会直接通过。
但这不是我们想要的!因为我们希望“负数年龄抛出错误”或明确拒绝,而不是悄悄返回false。
所以失败测试没出现,说明测试写错了——TDD逼你立刻发现这个坑。
修正测试:
test('负数年龄应该抛出错误', () => {
expect(() => isAdult(-5)).toThrow('年龄不能为负数');
});
现在红色(因为没有抛错)→ 再改代码:
function isAdult(age) {
if (age < 0) throw new Error('年龄不能为负数');
return age >= 18;
}
绿色 ✅
就这样,老王一步步构建了一个有完整边界测试的函数,而且每个测试都亲眼见过它失败。
三、TDD 的“铁律”—— 故事里的惨痛教训
铁律1:没有失败测试,就不写生产代码
老王以前总说:“我先写个大概,最后补测试。”
结果测试永远不写,或者写了也是“绿油油一片”——因为他写代码时已经把bug藏在里面了。
TDD说:如果你写了代码再写测试,那测试很可能会顺着你的错误思路通过,抓不到真正的bug。
必须先写测试,让它因为“功能缺失”而失败,然后你实现功能,它变绿。这样你才确信测试是在检验应有的行为,而不是现有代码的行为。
铁律2:看到失败才能进入绿色
老王有一次假装“我手动测试过了”,结果漏了边缘情况。
自动化测试的失败是铁证:某条路径确实没实现。
TDD流程里:
写测试 → 运行 → 看到红色 ❌ → 写代码 → 看到绿色 ✅
少了“看到红色”这一步,整个循环就断了。
铁律3:删除代码重来,不要“参考”
神秘人告诉老王:“如果你忍不住先写了代码,那就删掉它,从测试重新开始。”
老王心疼:“我写了3小时啊!”
神秘人:“那3小时已经是沉没成本。不删掉,你会一直‘参考’它,实际上就是绕过TDD。最后花3天修bug。”
老王删了代码,用TDD重写,只花了1.5小时,而且后面没出bug。
四、TDD 反模式故事 —— 测试假护身符
反模式1:测试 Mock 而不是真实行为
老王为了省事,测试里写:
// 模拟了一个假的“支付接口”
const mockPayment = { charge: jest.fn().mockReturnValue('success') };
test('支付成功', () => {
expect(mockPayment.charge()).toBe('success');
});
结果真实支付接口返回的是{ status: 'ok', transactionId: 'xxx' },不是'success'。
测试永远绿,上线后支付永远失败。
正确做法:测试真实的支付调用(或至少mock完整结构),并且亲眼看到因接口不匹配而失败。
反模式2:给生产类加 test-only 方法
老王在User类里加了一个destroy()方法,只在测试里清理数据用。
结果生产代码不小心调用,把用户数据全删了。
正确做法:测试清理逻辑放在test-utils里,不污染生产类。
反模式3:部分 Mock
// 只mock了response.data,没mock response.metadata
const mockRes = { data: { userId: 1 } };
下游代码用res.metadata.requestId,测试报错。
老王花了一小时找原因,最后发现是mock不全。
正确做法:mock完整数据结构,或者用真实组件做集成测试。
五、时序图:TDD 的完整调用过程(以添加“登录验证”为例)
下面用 mermaid 时序图 展示开发者与测试框架、代码之间的交互。
注意:这里不是系统运行时序,而是 开发过程的时序。
六、最佳用法总结(给小白的口诀)
- 先写测试后写码,红色失败笑哈哈
—— 没看见失败,等于没测试。 - 最小改动让它绿,别加功能 YAGNI
—— 只做让当前测试通过的事,不多不少。 - 绿灯亮了再重构,保持绿色不迷路
—— 重构前先跑测试,确认没坏。 - 测试莫测 Mock 事,真实行为才靠谱
—— 不要断言“模拟对象有没有被调用”,要断言真实结果。 - 一个测试一件事,名字清楚如批注
—— 测试名要像文档:test('空邮箱应该抛出错误') - 遇 bug 先写测试,修复之后永不退
—— 发现bug?先写一个失败的测试重现它,再修复。从此回归测试帮你挡。
七、老王最后的顿悟
一个月后,老王成了团队里的 TDD 布道师。
他的代码几乎不出生产bug,新同事看他的测试用例就能理解功能。
领导问:“你现在写代码怎么变慢了?”
老王说:“我写第一行测试时慢,但后面不用半夜起来修bug。算总账,快了三倍。”
神秘人再次出现,留下一句话:
“TDD 不是测试技巧,是设计技巧。它逼你先想清楚‘要什么’,再去做‘怎么做’。”
老王把这句话刻在了自己的 IDE 启动画面上:
No production code without a failing test first.
附:TDD 决策树
希望这个故事让你理解:TDD 不是宗教仪式,而是让你少掉头发的实用魔法。下次写代码前,先问自己:“我的失败测试在哪?” 🔴→🟢→🔵