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
2
3
4
5
6
7
8
9
10
11
┌──────────────────────────────────────────┐
│ │
│ ① RED ② GREEN ③ REFACTOR │
│ 写一个 写最少代码 重构代码 │
│ 失败的 让测试通过 消除坏味道 │
│ 测试 ✅ 🔧 │
│ ❌ │ │
│ │ │ │
│ └───────────────────────┘ │
│ 循环 │
└──────────────────────────────────────────┘

第一步:Red(红)。先写一个测试,运行它,确认它失败。这一步的意义在于验证测试本身是正确的——如果还没写代码测试就绿了,说明你的测试有问题。

第二步:Green(绿)。写最少量的代码让测试通过。注意「最少」两个字——不提前设计、不过度抽象,就写刚好能让测试绿起来的代码。哪怕直接 return true 都没毛病。

第三步:Refactor(重构)。测试绿了之后,你有了安全网。这时候可以放心地消除重复、改善命名、提取方法、调整结构。改一步跑一次测试,测试绿灯意味着你的重构没有破坏行为。

为什么这个顺序很重要

如果先写实现再补测试,你会不自觉地写出「方便测试」的代码吗?恰恰相反——你会写出「测试很难写的代码」,然后说服自己「这段逻辑太简单了不用测」。

而先写测试,你被逼着从调用者的角度思考:这个函数叫什么?参数是什么?返回值长什么样?边界条件有哪些?这种视角切换是 TDD 最大的价值——不是说事后写测试没价值,而是先写测试会倒逼你设计出更合理的接口。

实践篇:我用 TDD 修 Bug 的真实工作流

理论讲完了,说说我怎么用的。说实话我很少严格按 TDD 写新功能——需求变数大的时候,先写测试的成本确实不低。但我发现了一个更趁手的用法:修 Bug

为什么修 Bug 特别适合 TDD

修 Bug 和写新功能有个本质区别:Bug 是一个已经被定义好了的「失败行为」。你不需要凭空设想测试用例,Bug 本身就是最好的测试用例。

我的修 Bug 流程是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
发现 Bug


① 写一个失败的测试,精确复现 Bug
│ ── 测试红了 ❌,Bug 被"钉"在了代码里

② 修复代码,让测试变绿
│ ── 测试绿了 ✅,Bug 被修好了

③ 补充边界测试,确认没有引入新问题
│ ── 全部绿灯 ✅,放心了

④ 重构,消除修复过程中引入的脏代码
│ ── 有测试兜底,改得大胆

收工,Bug 永远不会再悄悄回来了

这个流程有两个关键好处:

第一,Bug 不会复现。 测试用例留在代码库里,以后每次跑 CI 都会验证这个 Bug 不会再回来。没有测试的修复等于赌命——你怎么知道三个月之后不会有人在重构时把同一行逻辑改坏?

第二,修 Bug 的心态变了。 没有测试的时候修 Bug 是焦虑的——「改了这里,会不会把别的功能搞崩?」有了测试就是——「测试全绿,稳了」。从「猜着改」变成了「瞄着改」。

案例实战:一个排序 Bug 的全流程

真实场景比道理好讲。下面用一个我遇到过的那种 Bug 来走一遍完整流程。

Bug 描述

有一个任务列表展示功能,每个任务有优先级(priority创建时间(createdAt。排序规则是:

先按优先级排序(high > medium > low),同优先级再按创建时间升序(旧的在前)。

问题反馈:优先级相同的任务,创建时间排序有时候是对的,有时候是乱的。

第一步:写一个失败的测试,钉死 Bug

先不动代码,打开测试文件写用例。我写了三个场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// sortTasks.test.js
const { sortTasks } = require('./taskSorter');

describe('sortTasks', () => {
const tasks = [
{ id: 1, priority: 'medium', createdAt: '2026-08-01T10:00:00Z' },
{ id: 2, priority: 'high', createdAt: '2026-08-03T12:00:00Z' },
{ id: 3, priority: 'low', createdAt: '2026-08-01T08:00:00Z' },
{ id: 4, priority: 'high', createdAt: '2026-08-02T09:00:00Z' },
{ id: 5, priority: 'medium', createdAt: '2026-07-30T14:00:00Z' },
];

test('应按优先级排序:high > medium > low', () => {
const sorted = sortTasks(tasks);
const priorities = sorted.map(t => t.priority);
expect(priorities).toEqual([
'high', 'high',
'medium', 'medium',
'low',
]);
});

test('同优先级的任务应按创建时间升序排列', () => {
const sorted = sortTasks(tasks);
// 两个 high 任务:8月2日应排在 8月3日前面
const highIds = sorted
.filter(t => t.priority === 'high')
.map(t => t.id);
expect(highIds).toEqual([4, 2]); // id=4 是 8月2日,id=2 是 8月3日

// 两个 medium 任务:7月30日应排在 8月1日前面
const mediumIds = sorted
.filter(t => t.priority === 'medium')
.map(t => t.id);
expect(mediumIds).toEqual([5, 1]); // id=5 是 7月30日,id=1 是 8月1日
});

test('空数组应返回空数组', () => {
expect(sortTasks([])).toEqual([]);
});
});

跑一下,红了 ❌。 第二个测试 同优先级的任务应按创建时间升序排列 失败——测试框架告诉我 expected [4, 2] 但实际返回的不对。

Bug 已经被钉在测试里了。现在我知道只要让这个测试变绿,Bug 就修好了。

第二步:定位根因

打开 sortTasks 函数,看到了问题所在:

1
2
3
4
5
6
7
8
9
10
11
12
// taskSorter.js(有 Bug 的原版)
function sortTasks(tasks) {
const PRIORITY_ORDER = { high: 1, medium: 2, low: 3 };

return tasks.sort((a, b) => {
if (PRIORITY_ORDER[a.priority] !== PRIORITY_ORDER[b.priority]) {
return PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority];
}
// bug 在这里:日期用字符串比较了,而不是时间戳
return a.createdAt - b.createdAt;
});
}

看一眼就明白了。createdAt 是 ISO 字符串 '2026-08-02T09:00:00Z',两字符串相减结果是 NaN,而 NaN 在比较函数里的行为是未定义的(或者说得更直白一点——随缘)。

因为 '2026-08-02T09:00:00Z' - '2026-08-01T10:00:00Z' 在 JavaScript 里是 NaNsort 遇到 NaN 时的排序行为取决于引擎实现,所以「有时候是对的,有时候是乱的」。

第三步:修代码,让测试变绿

最小改动就是把日期字符串转成时间戳再比较:

1
2
3
4
5
6
7
8
9
10
11
12
// taskSorter.js(修复后)
function sortTasks(tasks) {
const PRIORITY_ORDER = { high: 1, medium: 2, low: 3 };

return tasks.sort((a, b) => {
if (PRIORITY_ORDER[a.priority] !== PRIORITY_ORDER[b.priority]) {
return PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority];
}
// 修复:用 getTime() 转成数字时间戳再比较
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
});
}

再跑测试——全绿 ✅

第四步:补充边界测试,确保万无一失

测试绿了不意味着可以收工。Bug 之所以是 Bug,往往是因为开发者忽略了边界情况。修完之后顺手补几个边界测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
test('只有一个任务时应返回该任务', () => {
const single = [{ id: 1, priority: 'low', createdAt: '2026-01-01T00:00:00Z' }];
expect(sortTasks(single)).toEqual(single);
});

test('不应修改原数组', () => {
const original = [...tasks];
sortTasks(tasks);
expect(tasks).toEqual(original); // 原数组不变
});

test('相同优先级且相同时间时排序应稳定', () => {
const same = [
{ id: 1, priority: 'high', createdAt: '2026-08-01T00:00:00Z' },
{ id: 2, priority: 'high', createdAt: '2026-08-01T00:00:00Z' },
];
const sorted = sortTasks(same);
expect(sorted[0].id).toBe(1); // 相对顺序保持不变
});

跑一下,不应修改原数组 直接红了——sort() 会原地修改数组。稳定排序那个测试在旧版 V8 上也过不了——sort() 不保证稳定。两个问题都指向同一个根因:我们不应该直接操作原数组。所以顺手修掉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function sortTasks(tasks) {
const PRIORITY_ORDER = { high: 1, medium: 2, low: 3 };

// 先拷贝再排序,避免修改原数组
return [...tasks].sort((a, b) => {
const priorityDiff =
PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority];
if (priorityDiff !== 0) return priorityDiff;

const timeDiff =
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
if (timeDiff !== 0) return timeDiff;

// 时间也相同时,用 id 保证稳定排序
return a.id - b.id;
});
}

测试全绿 ✅。这就是第三步「重构」的价值——有测试网兜底,你可以大胆地改进实现质量,不用担心改错。

为什么我没有用 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) — 领域驱动设计