TDD 测试驱动开发:别从新功能开始,从修 Bug 开始
写在前面:开个新坑
最近了解到一堆带 D 的概念:TDD、SDD、FSD,再加上之前就知道的 DDD,初看眼花缭乱,细看各有用处。它们不全是同一层面的东西,也不是互斥的,而是从不同角度在解决同一个问题——怎么让代码更可靠、更好维护。
先交代一下这四兄弟分别是什么,后面这个系列会逐一展开:
| 缩写 | 全称 | 解决什么问题 |
|---|---|---|
| TDD | Test-Driven Development | 用测试倒逼实现,确保代码行为正确 |
| SDD | Specification-Driven Development | 用规格文档倒逼实现,确保做的是对的东西 |
| FSD | Feature-Sliced Design | 前端架构方法论,按功能切片组织代码 |
| DDD | Domain-Driven Design | 后端架构方法论,围绕业务领域建模 |
这篇先聊 TDD。不同于教科书上「TDD 驱动新功能开发」的标准叙事,我这边的真实体验是——TDD 最好用的场景不是写新功能,而是修 Bug。
TDD 概念篇:红 · 绿 · 重构
什么是 TDD
TDD(测试驱动开发)是 Kent Beck 在极限编程(XP)里体系化提出的一套开发方法论。核心思想反直觉:先写测试,再写实现代码。
标准流程是三步循环,俗称「红—绿—重构」:
1 | ┌──────────────────────────────────────────┐ |
第一步:Red(红)。先写一个测试,运行它,确认它失败。这一步的意义在于验证测试本身是正确的——如果还没写代码测试就绿了,说明你的测试有问题。
第二步:Green(绿)。写最少量的代码让测试通过。注意「最少」两个字——不提前设计、不过度抽象,就写刚好能让测试绿起来的代码。哪怕直接 return true 都没毛病。
第三步:Refactor(重构)。测试绿了之后,你有了安全网。这时候可以放心地消除重复、改善命名、提取方法、调整结构。改一步跑一次测试,测试绿灯意味着你的重构没有破坏行为。
为什么这个顺序很重要
如果先写实现再补测试,你会不自觉地写出「方便测试」的代码吗?恰恰相反——你会写出「测试很难写的代码」,然后说服自己「这段逻辑太简单了不用测」。
而先写测试,你被逼着从调用者的角度思考:这个函数叫什么?参数是什么?返回值长什么样?边界条件有哪些?这种视角切换是 TDD 最大的价值——不是说事后写测试没价值,而是先写测试会倒逼你设计出更合理的接口。
实践篇:我用 TDD 修 Bug 的真实工作流
理论讲完了,说说我怎么用的。说实话我很少严格按 TDD 写新功能——需求变数大的时候,先写测试的成本确实不低。但我发现了一个更趁手的用法:修 Bug。
为什么修 Bug 特别适合 TDD
修 Bug 和写新功能有个本质区别:Bug 是一个已经被定义好了的「失败行为」。你不需要凭空设想测试用例,Bug 本身就是最好的测试用例。
我的修 Bug 流程是这样的:
1 | 发现 Bug |
这个流程有两个关键好处:
第一,Bug 不会复现。 测试用例留在代码库里,以后每次跑 CI 都会验证这个 Bug 不会再回来。没有测试的修复等于赌命——你怎么知道三个月之后不会有人在重构时把同一行逻辑改坏?
第二,修 Bug 的心态变了。 没有测试的时候修 Bug 是焦虑的——「改了这里,会不会把别的功能搞崩?」有了测试就是——「测试全绿,稳了」。从「猜着改」变成了「瞄着改」。
案例实战:一个排序 Bug 的全流程
真实场景比道理好讲。下面用一个我遇到过的那种 Bug 来走一遍完整流程。
Bug 描述
有一个任务列表展示功能,每个任务有优先级(priority)和创建时间(createdAt)。排序规则是:
先按优先级排序(
high>medium>low),同优先级再按创建时间升序(旧的在前)。
问题反馈:优先级相同的任务,创建时间排序有时候是对的,有时候是乱的。
第一步:写一个失败的测试,钉死 Bug
先不动代码,打开测试文件写用例。我写了三个场景:
1 | // sortTasks.test.js |
跑一下,红了 ❌。 第二个测试
同优先级的任务应按创建时间升序排列失败——测试框架告诉我expected [4, 2]但实际返回的不对。
Bug 已经被钉在测试里了。现在我知道只要让这个测试变绿,Bug 就修好了。
第二步:定位根因
打开 sortTasks 函数,看到了问题所在:
1 | // taskSorter.js(有 Bug 的原版) |
看一眼就明白了。createdAt 是 ISO 字符串 '2026-08-02T09:00:00Z',两字符串相减结果是 NaN,而 NaN 在比较函数里的行为是未定义的(或者说得更直白一点——随缘)。
因为 '2026-08-02T09:00:00Z' - '2026-08-01T10:00:00Z' 在 JavaScript 里是 NaN,sort 遇到 NaN 时的排序行为取决于引擎实现,所以「有时候是对的,有时候是乱的」。
第三步:修代码,让测试变绿
最小改动就是把日期字符串转成时间戳再比较:
1 | // taskSorter.js(修复后) |
再跑测试——全绿 ✅。
第四步:补充边界测试,确保万无一失
测试绿了不意味着可以收工。Bug 之所以是 Bug,往往是因为开发者忽略了边界情况。修完之后顺手补几个边界测试:
1 | test('只有一个任务时应返回该任务', () => { |
跑一下,不应修改原数组 直接红了——sort() 会原地修改数组。稳定排序那个测试在旧版 V8 上也过不了——sort() 不保证稳定。两个问题都指向同一个根因:我们不应该直接操作原数组。所以顺手修掉:
1 | function sortTasks(tasks) { |
测试全绿 ✅。这就是第三步「重构」的价值——有测试网兜底,你可以大胆地改进实现质量,不用担心改错。
为什么我没有用 TDD 写新功能,但修 Bug 离不开它
写新功能用 TDD 有一个现实障碍:你不知道最终的接口长什么样。需求在变、设计方案在调整、依赖的 API 可能还没定。这时候先写测试等于把设计冻结了,后续改动成本很高。我的经验是,新功能更适合先写一个粗糙实现让它在真实场景里跑一跑,等接口稳定了再补测试。
但修 Bug 完全相反:接口已经定型,输入输出已经明确,Bug 本身就是一个精确的测试规约。TDD 这时候几乎没有额外成本——你本来就应该先摸清 Bug 怎么触发再下手修,把「摸清的过程」形式化为一个测试用例,只是顺手的事。
更重要的是心理层面的差异:
| 无测试修 Bug | TDD 修 Bug | |
|---|---|---|
| 动刀前 | 「大概是这里的问题吧…」 | 「测试已经精确指出了失败的输入和期望的输出」 |
| 改代码时 | 「希望别把别的功能搞崩」 | 「只要测试全绿就没事」 |
| 修完后 | 「应该好了吧…」 | 「测试绿了,上线」 |
| 三个月后 | 同一个 Bug 被同事重构回来了 | 测试挡在 CI 里,没人能合进来 |
总结
TDD 不一定非要严格遵循「红—绿—重构」写每一行新代码。但如果你把它的思想套到修 Bug 上——先用测试把 Bug 钉死,再改代码,最后用测试网兜底重构——你会发现修 Bug 变成了一件心里有底的事。
这个系列的下一篇计划写 SDD(Specification-Driven Development)——如果 TDD 解决的是「代码写得对不对」,那 SDD 解决的就是「做的东西对不对」。敬请期待。
系列文章:
- 本篇:TDD 测试驱动开发 — 测试倒逼实现
- 下一篇:SDD 规格驱动开发 — 规格倒逼设计
- 后续:FSD(Feature-Sliced Design) — 前端架构方法论
- 后续:DDD(Domain-Driven Design) — 领域驱动设计
