TDD 在 AI 时代:为什么 Agent 比人类更需要"先写测试"

TDD 在 AI 时代:为什么 Agent 比人类更需要"先写测试"

上一篇讲了 Subagent-Driven Development 的执行和 review 机制。但 review 能发现问题,不能从源头预防问题。Superpowers 从源头预防问题的方式是:强制 Agent 必须先写测试,再写实现。

这不是新概念------TDD 已经存在 20 多年了。但 Superpowers 对 TDD 的处理方式和传统截然不同:它不是在讲"TDD 是一种好的开发实践",而是在说"对 Agent 来说,TDD 是唯一能证明代码正确的方式"。

源文件:skills/test-driven-development/SKILL.md


为什么 Agent 比人类更需要 TDD

人类开发者不做 TDD,至少还有几层兜底:手动测试、IDE 调试器、运行程序看输出、凭经验判断边界条件。Agent 没有这些------它不能"运行程序看看对不对"(除非你配了自动化执行环境),它的"验证"只有:

  1. 自己推理代码逻辑是否正确(不可靠------模型对自己写的代码有盲点)
  2. 跑自动化测试

如果没有测试,Agent 唯一的验证手段就是自己推理。而模型最擅长的就是"合理化解释自己的错误"------你让它 review 自己的代码,它大概率说"没问题"。

TDD 打破了这个循环:先写测试,测试失败了,写代码让它通过。这个过程有客观的、可验证的中间步骤,不依赖 Agent 的自我判断。


The Iron Law:没有失败的测试就不能写代码

css 复制代码
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST

翻译:没有先失败的测试,就不能写生产代码。

这是 TDD skill 的铁律------全大写、无条件、没有例外。违反了怎么办?

rust 复制代码
Write code before the test? Delete it. Start over.

No exceptions:
- Don't keep it as "reference"
- Don't "adapt" it while writing tests
- Don't look at it
- Delete means delete

Implement fresh from tests. Period.

翻译:先于测试写了代码?删掉。重来。没有例外:不要保留作"参考"、不要在写测试时"改编"它、不要看它。删除就是删除。从测试重新开始实现。句号。

注意这不是"测试没写好要修改"------而是如果你先写了生产代码再补测试,代码直接删掉。为什么这么极端?

因为一旦代码已经存在,你写的测试会无意识地去"验证现有行为"而不是"定义期望行为"。你会看着代码写断言------测试变成了对实现的确认,而不是对需求的规约。


Red-Green-Refactor 循环

TDD 的核心循环有三步,每步都有强制验证点:

lua 复制代码
RED → Verify RED → GREEN → Verify GREEN → REFACTOR → (stay green) → next RED

RED:写一个失败的测试

写一个最小的测试,描述期望行为:

typescript 复制代码
test('retries failed operations 3 times', async () => {
  let attempts = 0;
  const operation = () => {
    attempts++;
    if (attempts < 3) throw new Error('fail');
    return 'success';
  };

  const result = await retryOperation(operation);

  expect(result).toBe('success');
  expect(attempts).toBe(3);
});

一个测试只测一个行为。名字要能说明"什么场景下预期什么结果"。

对比反面教材:

typescript 复制代码
test('retry works', async () => {
  const mock = jest.fn()
    .mockRejectedValueOnce(new Error())
    .mockRejectedValueOnce(new Error())
    .mockResolvedValueOnce('success');
  await retryOperation(mock);
  expect(mock).toHaveBeenCalledTimes(3);
});

问题:名字模糊("retry works"说了等于没说)、测的是 mock 的调用次数而不是真实行为。

Verify RED:确认测试失败了

vbnet 复制代码
MANDATORY. Never skip.

翻译:强制步骤。永远不能跳过。

运行测试,确认它:

  • 失败 (fail)而不是报错(error)------两者不同
  • 失败原因是"功能不存在"而不是"拼写错误"
  • 失败信息符合预期

如果测试通过了呢? 说明你在测试已有行为------要么功能已经存在,要么测试写错了。修改测试。

这一步的意义:证明测试确实能检测到"功能缺失"。如果你跳过这步直接写实现,你永远不知道测试到底有没有在检测什么。

GREEN:最小实现

刚好让测试通过的代码,不多不少:

typescript 复制代码
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');
}

对比过度实现:

typescript 复制代码
async function retryOperation<T>(
  fn: () => Promise<T>,
  options?: {
    maxRetries?: number;
    backoff?: 'linear' | 'exponential';
    onRetry?: (attempt: number) => void;
  }
): Promise<T> {
  // YAGNI - 测试没要求这些功能
}

翻译:YAGNI(You Aren't Gonna Need It)------测试没要求这些功能。

rust 复制代码
Don't add features, refactor other code, or "improve" beyond the test.

翻译:不要添加功能、重构其他代码或超越测试进行"改进"。

Verify GREEN:确认测试通过

运行测试,确认:

  • 新测试通过
  • 其他测试仍然通过
  • 没有 warning 或 error
erlang 复制代码
Test fails? Fix code, not test.
Other tests fail? Fix now.

翻译:测试失败?修代码,不是修测试。其他测试失败了?立即修。

REFACTOR:清理代码

只有在 GREEN 之后才能 refactor:

  • 消除重复
  • 改善命名
  • 提取辅助函数

约束:refactor 不能改变行为------测试必须始终保持绿色。

然后:下一个 RED

回到起点,写下一个测试。整个循环是增量的------每次只前进一小步。


为什么顺序是铁律

Superpowers 用了一整段来解释"为什么不能先写代码后补测试"。这些不是教条论证------而是针对模型最容易犯的错误:

"先写代码再补测试,效果一样"

diff 复制代码
Tests written after code pass immediately. Passing immediately proves nothing:
- Might test wrong thing
- Might test implementation, not behavior
- Might miss edge cases you forgot
- You never saw it catch the bug

Test-first forces you to see the test fail, proving it actually tests something.

翻译:代码写完后补的测试会立即通过。立即通过什么也证明不了:可能测错了东西、可能在测实现而非行为、可能遗漏了你忘掉的边界情况、你从未看到它捕获 bug。先写测试强制你看到测试失败,证明它确实在检测什么。

这对 Agent 尤其致命------Agent 补的测试几乎一定是基于它已经写的实现。它不会故意写出不通过的测试。

"我已经手动验证了所有边界情况"

vbnet 复制代码
Manual testing is ad-hoc. You think you tested everything but:
- No record of what you tested
- Can't re-run when code changes
- Easy to forget cases under pressure
- "It worked when I tried it" ≠ comprehensive

翻译:手动测试是临时的。你以为测全了,但:没有测试记录、代码改了不能重跑、压力下容易遗漏、"我试了能跑"≠ 全面测试。

"删掉已经写好的代码太浪费了"

python 复制代码
Sunk cost fallacy. The time is already gone. Your choice now:
- Delete and rewrite with TDD (X more hours, high confidence)
- Keep it and add tests after (30 min, low confidence, likely bugs)

The "waste" is keeping code you can't trust.

翻译:沉没成本谬误。时间已经花了。你现在的选择是:删掉用 TDD 重写(再花 X 小时,高信心)或保留并补测试(30 分钟,低信心,很可能有 bug)。真正的"浪费"是保留你无法信任的代码。

"TDD 是教条主义,务实才是正道"

java 复制代码
TDD IS pragmatic:
- Finds bugs before commit (faster than debugging after)
- Prevents regressions (tests catch breaks immediately)
- Documents behavior (tests show how to use code)
- Enables refactoring (change freely, tests catch breaks)

"Pragmatic" shortcuts = debugging in production = slower.

翻译:TDD 就是务实:commit 前发现 bug(比之后调试快)、防止回归(测试立即捕获破坏)、记录行为(测试展示如何使用代码)、支持重构(放心改动,测试捕获破坏)。"务实的"捷径 = 生产环境调试 = 更慢。


Common Rationalizations 表

这是 Superpowers 的 Rationalization Prevention 机制在 TDD 领域的具体应用:

借口 现实
"Too simple to test" Simple code breaks. Test takes 30 seconds.
"I'll test after" Tests passing immediately prove nothing.
"Tests after achieve same goals" Tests-after = "what does this do?" Tests-first = "what should this do?"
"Already manually tested" Ad-hoc ≠ systematic. No record, can't re-run.
"Deleting X hours is wasteful" Sunk cost fallacy. Keeping unverified code is technical debt.
"Keep as reference, write tests first" You'll adapt it. That's testing after. Delete means delete.
"Need to explore first" Fine. Throw away exploration, start with TDD.
"Test hard = design unclear" Listen to test. Hard to test = hard to use.
"TDD will slow me down" TDD faster than debugging. Pragmatic = test-first.
"Manual test faster" Manual doesn't prove edge cases. You'll re-test every change.
"Existing code has no tests" You're improving it. Add tests for existing code.

翻译(选摘):

  • "太简单不需要测试" → 简单代码也会坏。写个测试只要 30 秒。
  • "我之后再补测试" → 立即通过的测试什么也证明不了。
  • "后补测试效果一样" → 后补测试问"它做了什么?"先写测试问"它应该做什么?"
  • "删掉 X 小时的工作太浪费了" → 沉没成本谬误。保留未验证的代码才是技术债。
  • "先保留作参考,然后从测试开始写" → 你会"改编"它。那就是后补测试。删除就是删除。
  • "需要先探索" → 可以。探索完扔掉,从 TDD 开始。
  • "测试难写 = 设计不清楚" → 听测试的话。难测 = 难用。

注意第 7 条"Need to explore first"------Superpowers 允许探索性编码(spike),但要求探索完后删掉所有代码,从 TDD 重新开始。探索是为了理解问题,不是为了产出代码。


Red Flags:什么时候 TDD 做错了

diff 复制代码
- Code before test
- Test after implementation
- Test passes immediately
- Can't explain why test failed
- Tests added "later"
- Rationalizing "just this once"
- "I already manually tested it"
- "Tests after achieve the same purpose"
- "It's about spirit not ritual"
- "Keep as reference" or "adapt existing code"
- "Already spent X hours, deleting is wasteful"
- "TDD is dogmatic, I'm being pragmatic"
- "This is different because..."

All of these mean: Delete code. Start over with TDD.

翻译:以上所有信号的含义都是:删除代码。用 TDD 重新开始。

最后一条 "This is different because..." 是最狡猾的------模型总能找到"这次情况特殊"的理由。Superpowers 的回答是:没有"特殊情况"。


Good Tests 的标准

维度 好的测试 坏的测试
Minimal 只测一件事。名字里有"and"?拆开。 test('validates email and domain and whitespace')
Clear 名字描述行为 test('test1')
Shows intent 展示期望的 API 用法 让人看不出代码应该怎么用
Real code 用真实代码(mock 只在不可避免时用) 到处 mock

关键原则:测试应该展示 API 的理想用法。如果你的测试代码很丑陋复杂,说明 API 设计有问题。

css 复制代码
Test hard = design unclear. Listen to test. Hard to test = hard to use.

翻译:测试难写 = 设计不清楚。听测试的话。难测试 = 难使用。


Bug Fix 的 TDD 流程

Bug 修复特别适合 TDD------先用测试重现 bug,再修复:

Bug: 空邮箱被接受了

RED(重现 bug):

typescript 复制代码
test('rejects empty email', async () => {
  const result = await submitForm({ email: '' });
  expect(result.error).toBe('Email required');
});

Verify RED:

bash 复制代码
$ npm test
FAIL: expected 'Email required', got undefined

测试失败了------证明 bug 确实存在。

GREEN(最小修复):

typescript 复制代码
function submitForm(data: FormData) {
  if (!data.email?.trim()) {
    return { error: 'Email required' };
  }
  // ...
}

Verify GREEN:

bash 复制代码
$ npm test
PASS

REFACTOR: 如果有多个字段需要类似验证,提取验证逻辑。

这个流程的好处:修完 bug 后,这个测试永远留在测试套件里------如果以后有人改动了相关代码导致 bug 回归,测试立即捕获。

bash 复制代码
Bug found? Write failing test reproducing it. Follow TDD cycle. 
Test proves fix and prevents regression.

Never fix bugs without a test.

翻译:发现 bug?写一个失败的测试重现它。遵循 TDD 循环。测试证明修复有效并防止回归。永远不要在没有测试的情况下修 bug。


When Stuck:测试写不出来怎么办

问题 解法
不知道怎么测 先写理想的 API 调用方式。先写断言。问你的 human partner。
测试太复杂 设计太复杂了。简化接口。
必须 mock 所有东西 代码耦合太紧。用依赖注入。
测试 setup 太大 提取 helper。还是复杂?简化设计。

核心理念:测试困难是设计问题的症状,不是 TDD 方法论的问题。如果测试很难写,不是"TDD 不适合这个场景",而是"代码设计需要改进"。


Verification Checklist

每次任务完成前的自查清单:

less 复制代码
Before marking work complete:

- [ ] Every new function/method has a test
- [ ] Watched each test fail before implementing
- [ ] Each test failed for expected reason (feature missing, not typo)
- [ ] Wrote minimal code to pass each test
- [ ] All tests pass
- [ ] Output pristine (no errors, warnings)
- [ ] Tests use real code (mocks only if unavoidable)
- [ ] Edge cases and errors covered

Can't check all boxes? You skipped TDD. Start over.

翻译(选摘):

  • 每个新函数/方法都有测试
  • 看到每个测试在实现前失败了
  • 每个测试因为"功能缺失"失败(不是因为拼写错误)
  • 写了最少的代码让测试通过
  • 所有测试通过
  • 输出干净(没有 error、warning)
  • 测试用真实代码(mock 只在不可避免时用)
  • 覆盖了边界情况和错误路径

不能勾上所有项?你跳过了 TDD。重新开始。


TDD 和上一篇的 SDD 怎么配合

在 Subagent-Driven Development 流程中,TDD 在 Implementer subagent 环节发挥作用:

yaml 复制代码
Coordinator 分派 task
    │
    ▼
Implementer subagent 开始执行
    │
    ├── RED: 写失败测试
    ├── Verify RED: 运行确认失败
    ├── GREEN: 最小实现
    ├── Verify GREEN: 运行确认通过
    ├── REFACTOR: 清理
    └── Commit
    │
    ▼
Spec Reviewer: 实现匹配 spec 吗?
    │
    ▼
Code Quality Reviewer: 代码质量如何?

Implementer 内部用 TDD 保证自己写的代码是正确的。Reviewer 从外部视角验证它是否满足 spec 和质量标准。内部保障(TDD)+ 外部审核(Review)= 双重质量闭环。


对 Agent 的特殊意义

TDD 对人类是"更好的实践"。对 Agent 而言,TDD 几乎是"唯一可靠的验证手段":

验证方式 人类 Agent
手动运行程序 ✅ 随时可做 ❌ 需要特殊环境配置
IDE 调试器 ✅ 设断点、看变量 ❌ 不可用
直觉/经验判断 ✅ 有一定可靠性 ❌ 模型会 rationalize
自动化测试 ✅ 可选 唯一客观手段
自我 review ✅ 有一定效果 ⚠️ 严重偏向自己的代码

这就是为什么 Superpowers 把 TDD 从"推荐实践"升级为"铁律"------不是因为教条,而是因为 Agent 没有其他可靠的质量保障手段。


实践建议

你不需要 Superpowers 也能让 Agent 做 TDD

在你的 CLAUDE.md 或 system prompt 里加上类似的约束:

markdown 复制代码
## TDD 铁律

所有新功能和 bug 修复必须遵循 RED-GREEN-REFACTOR:
1. 先写一个失败的测试
2. 运行测试确认它失败了(不是报错,是失败)
3. 写最少的代码让测试通过
4. 运行测试确认通过
5. 需要的话清理代码

违反(先写代码再补测试)→ 删除代码从测试重新开始。

关键是强制"Verify RED"

大多数 Agent 愿意先写测试------但它们经常跳过"运行测试确认失败"这一步。这一步是 TDD 的灵魂:如果你没看到测试失败,你不知道它在检测什么。

vbnet 复制代码
If you didn't watch the test fail, you don't know if it tests the right thing.

翻译:如果你没有看到测试失败,你就不知道它是否在测试正确的东西。

用 Rationalization 表对抗模型的偷懒

模型会找各种理由跳过 TDD。提前把这些理由列出来并标记为"这不是合理的跳过理由"------这就是 Rationalization Prevention 的力量。


总结

Superpowers 对 TDD 的处理揭示了一个深层洞察:AI Agent 需要 TDD 不是因为"好的开发习惯",而是因为自动化测试是它唯一的客观质量验证手段

没有 TDD 的 Agent 只能靠自我推理来判断代码对不对------这恰恰是最不可靠的方式。TDD 提供了客观的、可重复的、不依赖自我判断的验证路径。

下一篇是本系列的终章:当你想创建自己的 Skill(行为塑造代码)时,怎么用"压力测试"来确保它真的能约束 Agent------Writing Skills 的 TDD。


直接拿走:加到 CLAUDE.md 的 TDD 铁律

不需要 Superpowers。把下面这段加到你的 CLAUDE.md 里:

markdown 复制代码
## TDD 铁律

NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST.

违反后果:
- 测试之前写了代码?删掉,从头来。
- 不要保留"参考"、不要"适配"、不要看它。删除就是删除。

Red-Green-Refactor 循环:
1. RED:写一个最小的失败测试,跑一遍确认它失败
2. GREEN:写最少的代码让测试通过,跑一遍确认通过
3. REFACTOR:测试通过后清理代码
4. 每一步都要实际执行命令看输出------"我觉得会通过"不算。

禁止用语:should pass、probably works、seems right、I tested it manually

⚠️ "删除已写好的代码"在实际中会有本能抗拒------尤其当 agent 已经写了一两百行的时候。这套规则的目的是让 agent 在第一行生产代码之前就写测试,而不是真的频繁删代码。如果把"删除"理解为惩罚,你会抗拒;理解成"迫使 agent 先想测试再写代码"的约束机制,就会发现它是有效的。


本文素材来源:obra/superpowers/skills/test-driven-development/SKILL.md