TDD 在 AI 时代:为什么 Agent 比人类更需要"先写测试"
上一篇讲了 Subagent-Driven Development 的执行和 review 机制。但 review 能发现问题,不能从源头预防问题。Superpowers 从源头预防问题的方式是:强制 Agent 必须先写测试,再写实现。
这不是新概念------TDD 已经存在 20 多年了。但 Superpowers 对 TDD 的处理方式和传统截然不同:它不是在讲"TDD 是一种好的开发实践",而是在说"对 Agent 来说,TDD 是唯一能证明代码正确的方式"。
为什么 Agent 比人类更需要 TDD
人类开发者不做 TDD,至少还有几层兜底:手动测试、IDE 调试器、运行程序看输出、凭经验判断边界条件。Agent 没有这些------它不能"运行程序看看对不对"(除非你配了自动化执行环境),它的"验证"只有:
- 自己推理代码逻辑是否正确(不可靠------模型对自己写的代码有盲点)
- 跑自动化测试
如果没有测试,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