跳转到正文

测试驱动开发

铁律:在没有失败测试的前提下,不得编写任何生产代码。


为什么 TDD 不可妥协

测试驱动开发(TDD)是软件工程中争议最多、也最常被跳过的实践。开发者会说先写测试太慢、对某些代码来说很别扭、截止日期太近了、或者说以后再补测试。

Superpowers 将所有这些论点视为自我合理化,没有一条是跳过测试优先纪律的可接受理由。

以下是先写代码的核心问题:当你先写代码再写测试时,测试是为了匹配代码而写的,而不是为了验证意图。 你最终测试的是代码做了什么,而不是它应该做什么。测试变成了文档制品,而不是正确性保证。原始代码中存在的 bug 被编码进测试,成为"预期行为"。

当你先写测试时:

  • 你被迫在决定如何实现之前,先明确你想要的确切行为
  • 你在构建实现之前就发现了接口问题(此时修复很便宜)
  • 失败的测试证明你的测试确实在测试某些东西
  • 实现后通过的测试证明你构建了你所意图的东西

TDD 并不慢。它消除的是缓慢的部分:调试从未被正确规格化的代码所花费的时间。


RED-GREEN-REFACTOR 循环

TDD 对每一个功能点都遵循简单的三阶段循环:

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│   RED ──────────────────→ GREEN ──────────────→ REFACTOR   │
│                                                             │
│   为所需行为编写         用尽可能简单的代      在不改变行为   │
│   一个失败测试。         码使测试通过。        的前提下改善   │
│                                               代码。        │
└─────────────────────────────────────────────────────────────┘

RED:编写失败测试

编写一个测试,精确描述一个所需行为。运行它。看着它失败。

这一步不可省略。 如果你写了一个测试但它在没有任何实现的情况下立即通过,那么以下两种情况之一是真的:

  1. 功能已经存在(你应该检查原因)
  2. 你的测试是错误的——它并没有真正测试你以为它在测试的东西

在实现存在之前就通过的测试毫无价值。red 阶段的存在是为了确认你的测试是真实的。

// RED:这个测试失败,因为 calculateDiscount 还不存在
test('applies 10% discount for orders over $100', () => {
  const order = { total: 150, items: [] };
  expect(calculateDiscount(order)).toBe(15);
});

GREEN:使测试通过(用最简单的方式)

编写使失败测试通过的最简单代码。不是最优雅的代码。不是最可扩展的代码。而是最简单的代码。

这条规则防止在实现阶段过度设计。你不被允许为尚未有测试的情况构建代码。如果你想"那 $500 以上订单 20% 折扣怎么办?"——先为那个情况写测试,再使其通过。

// GREEN:使测试通过的最简单实现
function calculateDiscount(order: Order): number {
  if (order.total > 100) {
    return order.total * 0.10;
  }
  return 0;
}

REFACTOR:在不破坏的前提下改善

一旦测试通过,你就有了安全网。现在你可以改善代码:更好的命名、提取辅助函数、消除重复、提升可读性——任何能提升质量而不改变行为的事情。

每次重构后运行测试。如果测试仍然通过,你没有破坏任何东西。如果测试失败,撤销上一次修改。

// REFACTOR:更清晰的命名和常量
const DISCOUNT_THRESHOLD = 100;
const DISCOUNT_RATE = 0.10;

function calculateDiscount(order: Order): number {
  const qualifiesForDiscount = order.total > DISCOUNT_THRESHOLD;
  return qualifiesForDiscount ? order.total * DISCOUNT_RATE : 0;
}

测试仍然通过。代码更清晰。循环完成。


代码在测试之前编写时该怎么做

这种情况会发生。AI agent 会在测试之前编写实现代码。处于截止日期压力下的开发者会这样做。问题是当它发生时该怎么办。

Superpowers 的立场很明确:

删除它。重新开始。

不是"现在补测试"。不是"差不多就行了"。删除实现代码,先写测试。

这看起来很苛刻,直到你理解原因。在没有失败测试的情况下编写的代码没有经过验证的规格。实现编码了未经挑战的假设。当你事后写测试时,你在写一个你知道会通过的测试——这意味着你没有发现任何东西。你只是在检查你的代码是否做了你的代码所做的事情。

测试优先纪律不仅仅是关于拥有测试。它是关于在实现之前规格化行为的过程。事后补充的测试跳过了这一步。

如果:生产代码在没有前置失败测试的情况下被编写
那么:
  1. 删除生产代码
  2. 提交删除操作
  3. 编写一个失败测试
  4. 针对测试进行实现
  5. 继续

合理化借口对照表

当跳过 TDD 的压力累积时,以下是常见的论点以及它们为何站不住脚:

借口现实
"我以后会补测试的"事后添加的测试测试的是代码做了什么,而不是它应该做什么。"以后"很少真的到来。
"这段代码太简单了,不需要测试"简单的代码也有 bug。测试只需要 2 分钟。
"明天就是截止日期了"快速交付未经测试的代码产生的 bug,修复时间比写测试的时间还长。
"我无法为这种代码写测试"你可以。如果真的很难测试,那是代码设计有问题的信号。
"我们先做探索性编码,再正式化"探索性代码会变成生产代码。删除它或测试它——没有第三个选项。
"是 AI 生成的,应该没问题"AI 生成的代码有 bug。没有测试的 AI 生成代码有未被发现的 bug。
"现在添加测试会让我们减速"在生产环境中发现 bug 之后再添加测试要慢得多。
"测试这个需要 mock 太多东西了"太多 mock 意味着设计有太多依赖。修复设计。

完成前的验证

在声明任何功能完成之前,Superpowers 验证协议要求一个 5 步关卡:

┌────────────────────────────────────────────────────────────────┐
│                       验证关卡                                  │
│                                                                │
│  第 1 步:IDENTIFY — 列出与此功能相关的每个测试                   │
│  第 2 步:RUN      — 执行测试套件(不要只是假设)                 │
│  第 3 步:READ     — 逐行阅读实际输出                            │
│  第 4 步:VERIFY   — 在输出中确认每个测试都通过了                 │
│  第 5 步:CLAIM    — 只有到这时才能声明功能已完成                 │
└────────────────────────────────────────────────────────────────┘

第 1 步:IDENTIFY

列出每个涵盖正在完成的功能的测试文件和测试用例。不只是你今天写的测试——还包括任何触及受影响代码的现有测试。

第 2 步:RUN

实际运行测试套件。不是在脑子里。不是假设。运行它。

npm test -- --coverage --testPathPattern=order

第 3 步:READ

阅读实际输出。每一行。不只是"整体是通过还是失败"。查看:

  • 运行了哪些测试
  • 哪些测试通过了
  • 哪些测试被跳过了
  • 覆盖率数字

第 4 步:VERIFY

对于你的 IDENTIFY 列表中的每个测试,确认它出现在输出中并显示为通过。如果某个测试在输出中缺失,它没有运行。如果某个测试失败,功能没有完成。

第 5 步:CLAIM

只有在完成第 1–4 步之后,AI(或开发者)才可以声明:"这个功能已完成。"

说"它应该能用"不是完成第 5 步。 只有实际的测试输出才能完成第 5 步。


需要停止工作的红色警报

如果在 TDD 过程中出现以下任何情况,立即停止工作并升级:

红色警报含义
测试在实现存在之前就通过了测试可能是错误的
测试通过但功能行为不正确测试测试了错误的东西
大型重构后第一次运行就全部通过可疑——验证没有测试被无意中跳过
添加新功能时测试覆盖率下降正在引入空白
一个测试需要修改 15 个其他测试才能通过设计变更太大;将其分解为更小的步骤
AI 声称测试通过但没有显示输出不可接受——在继续之前要求实际输出

TDD 在 Superpowers 计划中的应用

撰写计划中所述,计划中的每项实现任务之前必须有一项测试任务。计划结构在计划层面强制执行 TDD:

任务 N:   为 [行为] 编写失败测试       ← RED
任务 N+1: 实现 [行为]                  ← GREEN
[重构发生在任务 N+1 内,或如果范围需要则作为任务 N+2]

在执行计划时,如果 subagent 被分派到任务 N+1(实现)而任务 N(测试)尚未完成,subagent 必须拒绝该任务并将其报告为 BLOCKED。没有前置失败测试的实现任务是计划缺陷。


使用 Superpowers 运行 TDD

要显式调用 TDD 技能:

/test-driven-development I need to implement user authentication

AI 将引导你完成 RED-GREEN-REFACTOR 循环,强制执行失败测试优先要求,并在标记任何事情为完成之前应用 5 步验证关卡。


TDD 是相信你的代码能用和知道你的代码能用之间的区别。 在专业软件开发中,相信不是可接受的标准。证据才是。