Skip to content

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 的失败测试,再修代码让它通过。这样修复是有证据的,而且这个测试会持续守住这个回归点。