测试驱动开发(Test-Driven Development,TDD)
概述
先写测试,看它失败,再写最少的代码让它通过。
核心原则:如果你没有亲眼看到测试失败,你就不知道它是否测试了正确的东西。
违反规则的字面意思,就是违反规则的精神。
何时使用
始终使用:
- 新功能
- Bug 修复
- 重构
- 行为变更
例外情况(需先询问用户):
- 一次性原型
- 生成的代码
- 配置文件
在想“就这一次跳过 TDD”?停下来。那是自我合理化。
铁律
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
在测试之前写了代码?删掉它,从头再来。
没有例外:
- 不要把它留作“参考”
- 不要在写测试时“改造”它
- 不要去看它
- 删除就是删除
从测试出发全新实现。就这么简单。
红-绿-重构循环(Red-Green-Refactor)
RED —— 编写失败的测试
编写一个最小的测试,展示应该发生什么。
好的测试:
def test_retries_failed_operations_3_times():
attempts = 0
def operation():
nonlocal attempts
attempts += 1
if attempts < 3:
raise Exception('fail')
return 'success'
result = retry_operation(operation)
assert result == 'success'
assert attempts == 3
命名清晰,测试真实行为,只测一件事。
坏的测试:
def test_retry_works():
mock = MagicMock()
mock.side_effect = [Exception(), Exception(), 'success']
result = retry_operation(mock)
assert result == 'success' # What about retry count? Timing?
命名含糊,测试的是 mock 而不是真实代码。
要求:
- 每个测试只测一个行为
- 命名清晰且具有描述性(名称里出现“和”?拆分它)
- 测试真实代码,而非 mock(除非确实无法避免)
- 名称描述行为,而非实现方式
验证 RED —— 亲眼看到它失败
强制要求。绝不跳过。
# Use terminal tool to run the specific test
pytest tests/test_feature.py::test_specific_behavior -v
确认:
- 测试失败(而不是因拼写错误导致的报错)
- 失败信息符合预期
- 失败的原因是功能尚未实现
测试立即通过了?说明你在测试已有的行为。修正这个测试。
测试报错了?修复错误,重新运行,直到它以正确的方式失败。
GREEN —— 最少代码
编写能让测试通过的最简单的代码。仅此而已。
好的:
def add(a, b):
return a + b # Nothing extra
坏的:
def add(a, b):
result = a + b
logging.info(f"Adding {a} + {b} = {result}") # Extra!
return result
不要添加超出测试范围的功能、重构其他代码,或进行任何“改进”。
在 GREEN 阶段“作弊”是可以的:
- 硬编码返回值
- 复制粘贴
- 重复代码
- 跳过边界情况
我们会在 REFACTOR 阶段修复它。
验证 GREEN —— 亲眼看到它通过
强制要求。
# Run the specific test
pytest tests/test_feature.py::test_specific_behavior -v
# Then run ALL tests to check for regressions
pytest tests/ -q
确认:
- 测试通过
- 其他测试仍然通过
- 输出干净无瑕(没有错误和警告)
测试失败了?修复代码,而不是修改测试。
其他测试失败了?立即修复回归问题。
REFACTOR —— 清理
只有在变绿之后:
- 消除重复
- 改进命名
- 提取辅助函数
- 简化表达式
全程保持测试通过。不要添加新行为。
如果重构过程中测试失败:立即撤销。采用更小的步子。
重复
为下一个行为编写下一个失败的测试。一次一个循环。
为什么顺序很重要
“我会在写完代码后再写测试来验证它是否有效”
代码之后写的测试会立即通过。立即通过什么也证明不了:
- 可能测错了东西
- 可能测的是实现方式,而非行为
- 可能遗漏了你忘记的边界情况
- 你从未见过它捕获过 bug
测试先行迫使你看到测试失败,从而证明它确实在测试某些东西。
“我已经手动测试过所有边界情况了”
手动测试是临时的、随意的。你以为你测过了一切,但是:
- 没有记录你测试了什么
- 代码变更后无法重新运行
- 压力大时很容易忘记某些情况
- “我试的时候是好的” ≠ 全面
自动化测试是系统化的。它们每次都以相同的方式运行。
“删掉 X 小时的工作太浪费了”
这是沉没成本谬误。时间已经花掉了。你现在的选择是:
- 删掉并用 TDD 重写(高置信度)
- 保留它并在事后补测试(低置信度,很可能有 bug)
真正的“浪费”是保留你无法信任的代码。
“TDD 太教条了,务实意味着灵活变通”
TDD 本身就是务实的:
- 在提交前发现 bug(比事后调试更快)
- 防止回归(测试会立即捕获破坏性改动)
- 记录行为(测试展示了如何使用代码)
- 支持重构(放心修改,测试会捕获破坏)
“务实”的捷径 = 在生产环境中调试 = 更慢。
“事后写测试也能达到同样的目的——重要的是精神而非仪式”
不。事后测试回答的是“这是什么?”测试先行回答的是“这应该是什么?”
事后测试会被你的实现所带偏。你测试的是你写了什么,而不是需要什么。测试先行迫使你在实现之前发现边界情况。
常见的自我合理化
| 借口 | 现实 |
|--------|---------|
| “太简单了不用测试” | 简单的代码也会出错。写个测试只要 30 秒。 |
| “我稍后再测” | 测试立即通过什么也证明不了。 |
| “事后测试也能达到同样目的” | 事后测试 = “这是干什么的?”测试先行 = “这应该干什么?” |
| “已经手动测试过了” | 随意测试 ≠ 系统化测试。没有记录,无法重跑。 |
| “删掉 X 小时的工作太浪费” | 沉没成本谬误。保留未经验证的代码才是技术债。 |
| “留作参考,重新写测试” | 你会不自觉地照抄它。那就是事后测试。删除就是删除。 |
| “需要先探索一下” | 可以。把探索的代码扔掉,从 TDD 开始。 |
| “测试很难写 = 设计不清晰” | 听测试的话。难以测试 = 难以使用。 |
| “TDD 会拖慢我” | TDD 比调试快。务实 = 测试先行。 |
| “手动测试更快” | 手动测试无法证明边界情况。每次改动你都得重测。 |
| “现有代码没有测试” | 你正在改进它。为你接触到的代码添加测试。 |
危险信号 —— 立即停下,从头再来
如果你发现自己在做以下任何事情,删掉代码并用 TDD 重来:
- 先写代码后写测试
- 实现完成后再补测试
- 测试第一次运行就立即通过
- 无法解释测试为什么失败
- 测试“稍后”再补
- 自我安慰“就这一次”
- “我已经手动测试过了”
- “事后测试也能达到同样目的”
- “留作参考”或“改造现有代码”
- “已经花了 X 小时,删掉太浪费”
- “TDD 太教条,我很务实”
- “这次不一样,因为……”
以上任何一条都意味着:删除代码,用 TDD 从头再来。
验证清单
在标记工作完成之前:
- [ ] 每个新函数/方法都有测试
- [ ] 实现之前亲眼看到每个测试失败
- [ ] 每个测试都因预期原因失败(功能缺失,而非拼写错误)
- [ ] 为每个测试编写了最少代码使其通过
- [ ] 所有测试通过
- [ ] 输出干净无瑕(没有错误和警告)
- [ ] 测试使用真实代码(仅在无法避免时使用 mock)
- [ ] 覆盖了边界情况和错误
无法勾选所有选项?说明你跳过了 TDD。从头再来。
遇到困难时
| 问题 | 解决方案 |
|---------|----------|
| 不知道怎么测试 | 先写你期望的 API。先写断言。询问用户。 |
| 测试太复杂 | 设计太复杂。简化接口。 |
| 必须 mock 一切 | 代码耦合过重。使用依赖注入。 |
| 测试准备工作庞大 | 提取辅助函数。仍然复杂?简化设计。 |
Hermes Agent 集成
运行测试
使用 terminal 工具在每一步运行测试:
# RED — verify failure
terminal("pytest tests/test_feature.py::test_name -v")
# GREEN — verify pass
terminal("pytest tests/test_feature.py::test_name -v")
# Full suite — verify no regressions
terminal("pytest tests/ -q")
配合 delegate_task
在派发子代理执行实现任务时,在目标中强制要求 TDD:
delegate_task(
goal="Implement [feature] using strict TDD",
context="""
Follow test-driven-development skill:
1. Write failing test FIRST
2. Run test to verify it fails
3. Write minimal code to pass
4. Run test to verify it passes
5. Refactor if needed
6. Commit
Project test command: pytest tests/ -q
Project structure: [describe relevant files]
""",
toolsets=['terminal', 'file']
)
配合 systematic-debugging
发现 bug 了?编写一个能复现它的失败测试。遵循 TDD 循环。这个测试能证明修复有效并防止回归。
绝不修复没有测试的 bug。
测试反模式
- 测试 mock 行为而非真实行为 —— mock 应该用于验证交互,而不是替换被测系统
- 测试实现细节 —— 测试行为/结果,而非内部方法调用
- 只测正常路径(happy path) —— 始终测试边界情况、错误和临界值
- 脆弱的测试 —— 测试应该验证行为而非结构;重构不应破坏测试
最终规则
Production code → test exists and failed first
Otherwise → not TDD
没有用户的明确许可,不得有任何例外。
安装指南
复制下方命令,在终端运行即可安装:
需已安装 GenHub 桌面端
使用指南
安装完成后,在对话框中直接使用此技能。