🧪 superpowers之TDD 小故事:老王的魔法测试护身符

0 阅读7分钟

用故事讲清楚:为什么“先写测试,再看它失败”是程序员的超能力


一、从前有个叫老王的程序员

老王写代码有个坏习惯:噼里啪啦先写一堆功能,最后补几个测试
他觉得这样快,反正“代码能跑就行”。

直到有一天,他写了一个用户注册功能。
他手动试了:输入邮箱、密码、点注册——成功!🎉 上线!

结果第二天,用户炸了:
“我输入空密码也能注册?!”
“邮箱随便打个‘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 时序图 展示开发者与测试框架、代码之间的交互。
注意:这里不是系统运行时序,而是 开发过程的时序

测试驱动开发流程.png


六、最佳用法总结(给小白的口诀)

  1. 先写测试后写码,红色失败笑哈哈
    —— 没看见失败,等于没测试。
  2. 最小改动让它绿,别加功能 YAGNI
    —— 只做让当前测试通过的事,不多不少。
  3. 绿灯亮了再重构,保持绿色不迷路
    —— 重构前先跑测试,确认没坏。
  4. 测试莫测 Mock 事,真实行为才靠谱
    —— 不要断言“模拟对象有没有被调用”,要断言真实结果。
  5. 一个测试一件事,名字清楚如批注
    —— 测试名要像文档:test('空邮箱应该抛出错误')
  6. 遇 bug 先写测试,修复之后永不退
    —— 发现bug?先写一个失败的测试重现它,再修复。从此回归测试帮你挡。

七、老王最后的顿悟

一个月后,老王成了团队里的 TDD 布道师。
他的代码几乎不出生产bug,新同事看他的测试用例就能理解功能。

领导问:“你现在写代码怎么变慢了?”

老王说:“我写第一行测试时慢,但后面不用半夜起来修bug。算总账,快了三倍。”

神秘人再次出现,留下一句话:

“TDD 不是测试技巧,是设计技巧。它逼你先想清楚‘要什么’,再去做‘怎么做’。”

老王把这句话刻在了自己的 IDE 启动画面上:

No production code without a failing test first.

附:TDD 决策树

TDD 决策树.png


希望这个故事让你理解:TDD 不是宗教仪式,而是让你少掉头发的实用魔法。下次写代码前,先问自己:“我的失败测试在哪?” 🔴→🟢→🔵