Appearance
TDD 的核心不是"要有测试",而是顺序——测试必须在代码之前写,并且必须亲眼看到它失败。这个顺序不是仪式,是验证手段:只有看见测试失败,才能证明这个测试在测真实行为,而不是测一个永远为真的假设。本文围绕这个核心展开,讲清楚红绿重构三步怎么做,哪些情况算违反 TDD,以及写测试时最常见的五类陷阱。
test-driven-development skill:先写失败的测试,再写最少的代码
TDD 到底在解决什么问题
写代码遇到 bug 的常见处理方式:手动复现一遍,改掉,再手动试一遍,觉得好了就提交。问题在于"觉得好了"不等于"确认好了",下次改这块代码时,没有任何东西会告诉你有没有把这个 bug 重新引入。
TDD 解决的就是这个问题。它强制在每个功能点上建立一条可以反复运行的安全网,让你在任何时刻都能知道代码是否还在正确工作。但 TDD 的核心要求只有一条:
没有失败的测试,就不能写生产代码。
这不是口号,是一条可执行的铁律。违反它,后面的一切都会打折扣。
红绿重构:三步循环
TDD 的操作单元是"红绿重构"循环,每次只推进一个小的行为点。
RED:写一个会失败的测试
先想清楚"这段代码应该做什么",然后把这个期望写成测试。测试要满足三个条件:
- 只测一件事:测试名能用一句话说清楚,不需要"and"
- 名字描述行为:
test('rejects empty email')而不是test('email validation') - 测真实代码:尽量不用 mock,除非测不了
写完测试,先不要写实现。
Verify RED:亲眼看到它失败
这一步是整个 TDD 中最容易被跳过、也最关键的一步。
bash
npm test path/to/test.test.ts运行测试,确认:
- 测试失败(不是报错,是断言失败)
- 失败原因是"功能还不存在",而不是语法错误或 typo
- 失败信息符合预期
如果测试立刻通过了,说明你在测一个已经存在的行为,或者测试写错了。停下来修测试,不要继续写代码。
为什么这步这么重要?一个从来没有失败过的测试,无法证明它在测正确的东西。它可能永远为真,可能测的是 mock 而不是真实代码,可能断言条件写反了。只有看到它因为"功能缺失"而失败,才能证明这个测试是有效的。
GREEN:写最少的代码让测试通过
目标是让测试通过,不是把功能做完。写够用的代码,不要多写。
typescript
// 只需要让这个测试过就够了,不要提前加 maxRetries 参数、
// 不要加 backoff 策略、不要加 onRetry 回调
async function retryOperation<T>(fn: () => Promise<T>): Promise<T> {
for (let i = 0; i < 3; i++) {
try {
return await fn();
} catch (e) {
if (i === 2) throw e;
}
}
throw new Error('unreachable');
}过度设计在这一步最容易出现:测试只需要"重试 3 次",但实现里加了可配置次数、指数退避、重试回调……这些都是 YAGNI,都是未来的技术债。
Verify GREEN:确认测试通过,其他测试没有被破坏
bash
npm test不只是跑新写的测试,要跑完整的测试套件。新代码不能破坏已有行为。如果有其他测试挂了,现在就修,不要往下走。
REFACTOR:在绿灯下整理代码
所有测试都通过之后,可以做整理:去掉重复、改好名字、提取辅助函数。整理过程中保持测试全绿——如果整理导致测试挂了,说明整理引入了 bug,不是"先修复再说",是撤回整理。
重构只整理代码结构,不加新行为。新行为需要新测试。
为什么"事后写测试"不等于 TDD
这是最常见的误解,也是最常见的逃避方式。
事后写测试回答的是:"这段代码做了什么?"
你已经知道实现细节,会在有意无意间把测试写成符合你实现方式的形状。你想到的边界用例是你在写代码时已经处理过的那些,你没想到的漏洞不会出现在测试里。
事前写测试回答的是:"这段代码应该做什么?"
在没有实现的情况下思考 API 的形态和边界条件,往往会发现事后回想不到的场景。测试先行是一种设计工具,不只是验证工具。
还有一个更直接的问题:事后写的测试通常会立刻通过。立刻通过说明你测的是"现有代码做了什么",不能证明"代码是否做了正确的事"。你永远不知道这个测试能不能抓住未来的回归问题。
Anti-Patterns:五类常见陷阱
反模式 1:测试 mock 的行为而不是真实行为
typescript
// 错误:测的是 mock 是否存在,不是组件是否工作
test('renders sidebar', () => {
render(<Page />);
expect(screen.getByTestId('sidebar-mock')).toBeInTheDocument();
});这个测试告诉你"mock 被渲染了",不告诉你"页面正确地包含了导航栏"。当 mock 的 testId 改了,测试挂了,但实际功能可能没变;反过来,真实 sidebar 出了问题,这个测试也不会告诉你。
修法:要么不 mock sidebar,直接测真实组件;要么如果必须 mock,就测 Page 在 sidebar 存在时表现出的真实行为。
反模式 2:把只有测试用的方法加到生产类里
typescript
// 错误:destroy() 只在测试的 afterEach 里被调用
class Session {
async destroy() {
await this._workspaceManager?.destroyWorkspace(this.id);
}
}这个方法在生产代码里从来不会被调用,却出现在生产类里,还可能在某个意外的地方被调到。
修法:把测试清理逻辑放到 test-utils/ 里的辅助函数中,生产类保持干净。
反模式 3:不理解依赖就开始 mock
typescript
// 错误:mock 了一个有副作用的方法,但这个副作用正是测试依赖的
vi.mock('ToolCatalog', () => ({
discoverAndCacheTools: vi.fn().mockResolvedValue(undefined)
}));
await addServer(config); // config 没被写入,因为被 mock 掉了
await addServer(config); // 检测不到重复,因为没有写入过mock 的目的是隔离慢的、外部的、不稳定的依赖。如果 mock 掉了测试本身需要的副作用,测试就失去了意义,甚至会在错误的原因下通过。
修法:写 mock 之前先搞清楚被 mock 的方法有哪些副作用,测试依赖其中的哪些。在正确的层级 mock(mock 真正慢/外部的那一层),保留测试需要的行为。
反模式 4:mock 数据结构不完整
typescript
// 错误:只 mock 了自己目前关心的字段
const mockResponse = {
status: 'success',
data: { userId: '123', name: 'Alice' }
// 漏了 metadata.requestId,下游代码用到了这个字段
};测试通过,集成时挂。不完整的 mock 制造出一种虚假的安全感。
修法:mock 数据结构要完整地镜像真实 API 返回。不确定有哪些字段,就先查文档或用真实接口跑一遍看结果。
反模式 5:把测试当收尾动作,不当开发流程的一部分
最隐蔽的反模式:实现完成,然后说"接下来写测试"。这在任务清单里看起来很合理,实际上是把测试降格为"可选的验收步骤"。
一旦进入这种思维,测试覆盖率会下降,覆盖的用例会变少,边界情况会被遗漏——因为你是在"补",不是在"发现"。
什么时候可以不用 TDD
TDD 不是所有场景的最优解,以下情况可以商量:
- 一次性原型:目的是快速验证想法,代码跑完就扔
- 自动生成的代码:框架生成的样板代码、脚手架
- 纯配置文件:JSON/YAML/TOML 等
但"这个功能太简单了,不需要测试"不在豁免范围内。简单的代码同样会在后来的改动中出问题,30 秒写一个简单测试的成本远低于未来调试的成本。
验证清单
做完一个功能点,用这个清单确认是否真正走了 TDD 流程:
- [ ] 每个新函数/方法都有对应测试
- [ ] 亲眼看到每个测试在实现前失败
- [ ] 失败原因是"功能不存在",不是语法错误
- [ ] 实现代码是最小可通过代码,没有过度设计
- [ ] 所有测试通过,包括已有测试
- [ ] 测试测的是真实行为,不是 mock 行为
- [ ] 边界条件和错误路径有覆盖
有任何一项打不上勾,说明某个地方跳过了 TDD,需要补回来。
FAQ
Q: 写测试比写代码花的时间更长,TDD 不是在降低效率吗?
A: 短期看是慢了,但测试之后省掉的调试时间远超写测试的成本。更重要的是,TDD 改变的不只是测试的时机,还是思考的方式——在写代码之前就把 API 设计和边界条件想清楚,反而会减少实现阶段的返工。
Q: 已有的代码没有测试,加新功能还需要 TDD 吗?
A: 需要。新功能本身要走 TDD 流程。如果新功能依赖的已有代码没有测试,可以顺手补一些关键路径的测试。不需要一次性补全所有历史欠债,但不能用"已有代码没测试"作为新代码不写测试的理由。
Q: 测试很难写,是不是设计有问题?
A: 很可能是。测试难写通常意味着代码耦合过紧、依赖注入不充分、或者一个函数做了太多事情。测试的"写起来费劲"是设计问题的早期信号,比在生产环境里发现设计问题要便宜得多。
Q: Bug 修复也需要先写失败测试吗?
A: 是的,这是 TDD 中 Bug 修复的标准做法。先写一个能复现 Bug 的失败测试,再修代码让它通过。这样修复是有证据的,而且这个测试会持续守住这个回归点。