关于 tdd
面向编码 Agent 的测试驱动开发技能,规定先写并提交失败测试,再实现、重构。通过提交标记和钩子检查流程结构,同时强调这些机制不能证明测试确实按预期失败。
主要功能
- 01先写失败测试并提交
- 02按单个行为逐步实现
- 03区分 RED、GREEN 与重构提交
- 04使用标记和脚本检查提交结构
- 05通过公开接口验证行为
适用场景
- 01为新功能按 TDD 流程开发
- 02为缺陷修复编写复现测试
- 03检查提交是否符合 RED/GREEN 约定
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ pandoscope/ skills --skill tdd安装后在 Claude Code 中输入/tdd确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
deep-modules.mdinterface-design.mdlint-red.shmocking.mdrefactoring.mdtesting-anti-patterns.mdtests.md详细介绍
测试驱动开发
先编写测试。提交红色测试。编写通过测试所需的最少代码。提交绿色代码。重构。
**核心原则:**测试通过公共接口验证行为,而非实现。代码可以彻底改变;测试不应改变。如果重命名内部函数(行为不变)就会导致测试失败,那么这个测试测的是实现。删除它。
没有观察到失败,就无法证明测试测对了。提交历史就是证据;钩子会检查其结构。
示例见 tests.md,模拟指南见 mocking.md。
铁律
没有先提交失败的测试,就不能编写生产代码。
先写了代码再写测试?删除代码。从测试出发重新实现。删除就是彻底删除。例外情况(一次性原型、生成的代码、配置)需要人工批准。心里想着“就这一次跳过 TDD”?这就是自我开脱。
1. 规划(编写任何代码之前)
使用项目的领域术语表,确保测试名称和接口用语与代码库一致;遵守你要修改部分的 ADR。
2. 纵向切片,而非横向切片
不要先编写所有测试,再编写所有实现。这是横向切片,会产出糟糕的测试:批量编写的测试针对的是想象出来的行为和事物的形状(签名、数据结构),对真实变更不敏感,还会让你在理解代码之前就受测试结构所限。
采用纵向切片——一个测试 → 一个实现 → 重复。每个测试都应回应上一个周期带来的认知。
WRONG (horizontal): RED: t1 t2 t3 t4 GREEN: i1 i2 i3 i4
RIGHT (vertical): t1→i1 t2→i2 t3→i3 ...
第一个切片是示踪弹:证明整条路径端到端可用。
3. 提交协议
一个行为 = 一个 RED 提交 + 一个 GREEN 提交。每个分支可以有多个周期;在 Go/Rust 仓库中,最好一次只进行一个周期(见标记)。
**每次提交——包括 RED 提交——都必须确保整个代码树通过 lint。**每次提交后,都要对整个代码树运行仓库的完整钩子套件(例如 prek run --all-files):退出代码为 0,且没有文件被修改。如果自动修复器修改了文件,即视为失败——应用修复,并将其修订进引入问题的提交,然后再开始下一个周期。CI 只评估 PR 的最新提交,不能成为中间提交不干净的借口;每次提交的规范都必须在提交时验证,否则就无从验证。
STUB 提交(仅当 RED 测试需要尚不存在的 API 接口时)
RED 提交中只有测试是红色的:lint、格式和类型检查仍须通过。当新测试调用尚不存在的函数、方法或参数时,类型检查会失败——因此,先单独提交一个只有最简签名的存根,前缀为 chore(stub):。签名 + 文档字符串,不包含任何行为:新的可调用项抛出 NotImplementedError;新参数接受但忽略。这是铁律唯一认可的例外:存根定义的是接口,不得包含实现——存根提交中的任何逻辑,都是没有失败测试的生产代码。如果接口已存在,则完全跳过这一步。
RED 提交
- 编写一个失败的测试。只测一种行为,名称清晰,使用真实代码——除非别无选择,否则不要使用 mock。
- 不加标记运行;观察它因正确原因失败(缺少功能——行为错误、断言失败,或桩代码的
NotImplementedError——绝不能是拼写错误、导入错误或缺少符号)。如果立即通过?说明它测试的是现有行为——修正测试。 - 添加标记(见下文),确保测试套件通过,然后提交。仅包含测试,前缀为
test(red):。
| 语言 | 标记 | 严格? |
|---|---|---|
| Python (pytest) | @pytest.mark.xfail(strict=True) | 是——XPASS 会导致测试套件失败 |
| TS/JS (vitest) | test.fails(...) / it.fails(...) | 是 |
| TS/JS (jest) | it.failing(...) | 是 |
| Go | //go:build red + TestRed 前缀 + red-tests 作业 | 仅聚合检查 |
| Rust | #[ignore = "red"] + red_ 前缀 + red-tests 作业 | 仅聚合检查 |
| 其他 | 找到严格的预期失败机制;若不存在 → 不加标记提交,并告知人类仓库缺少 red 强制检查 | 不适用 |
Go/Rust 的构建标签/忽略标记会将测试排除在常规测试套件之外,因此 red-tests CI 作业必须只运行带标记的测试,并预期测试失败(没有此类测试时则不执行)。其退出代码是聚合结果:同时有两个 red 测试在进行,其中一个错误地通过了 → 作业仍会通过。严格标记会逐个测试捕获这种情况;作业本身不会。
GREEN 提交
- 编写能通过测试的最简单代码。遵循 YAGNI——不要推测性地追求通用性。
- 移除本轮的 red 标记。本次提交不做其他测试改动。
- 确保完整测试套件通过,输出无异常。如果失败?修复代码,而不是测试。前缀为
feat:/fix:。
REFACTOR(仅在 green 后)
消除重复、改进名称、提取辅助函数、深化模块。测试保持通过,不添加新行为,单独提交。**绝不要在 red 阶段重构。**然后开始下一轮。
所有测试通过后,查找重构候选项:
强制检查及其局限
lint-red.sh(随 skill 提供;接入 prek + CI):staged/commit 模式会检查 test(red): 提交是否只修改测试文件并添加标记,以及其他提交是否未添加标记;merge 模式会拒绝树中存在的任何标记;如果存在 Go/Rust 标记,则必须有 red-tests 作业。
钩子会验证提交结构和标记规范,但不会验证你是否运行了未加标记的测试,并观察它因正确原因失败——这仍由你负责。每次提交的 lint 也一样:已安装的 pre-commit 钩子会在提交时阻止不规范的提交,但在未安装钩子的环境(全新的远程环境)中,每次提交后运行 --all-files 需要手动坚持——无论如何都要执行。严格标记可部分弥补这一点(XPASS 会捕获对现有行为的测试)。“Lint 通过”≠“TDD 已验证”。
好的测试
| 质量 | 规则 |
|---|---|
| 行为性 | 通过公共 API 覆盖真实路径;重构后仍然有效 |
| 精简 | 一次只测一件事。名称里有“和”?拆开。 |
| 清晰 | 名称描述行为,而不是 test1 |
| 诚实 | 测试代码,而不是 mock |
// Good — tests real behavior (vitest; jest: it.failing)
test.fails('retries failed operations 3 times', async () => {
let attempts = 0;
const op = () => { attempts++; if (attempts < 3) throw new Error('fail'); return 'ok'; };
expect(await retryOperation(op)).toBe('ok');
expect(attempts).toBe(3);
});
危险信号——停止、删除、重来
实现尚不存在时,未加标记的测试就通过了 · 无法解释测试为何失败 · 为了让 GREEN 通过而弱化断言 · 测试 mock · “就这一次” · “我是在讲求实效,TDD 太教条了。”
阅读 testing-anti-patterns.md,避免常见陷阱。
合理化借口一览
| 借口 | 事实 |
|---|---|
| “太简单,不值得测试” | 简单的代码也会出错。测试只需 30 秒。 |
| “我之后再测试” | 事后测试会受实现影响,问的是“这段代码做了什么?”,而不是“它应该做什么?” |
| “删除几小时的代码太浪费” | 沉没成本。未经验证的代码就是债务。 |
| “测试很难写” | 难以测试 = 难以使用。听从它的提示;简化接口。 |
| “必须 mock 所有东西” | 耦合太紧。注入依赖。 |
| “Lint 通过了,TDD 也完成了” | 钩子检查的是结构,而不是你是否观察了失败。 |
遇到困难 / 调试
不知道如何测试 → 先写出期望的 API 和断言。发现 Bug → 编写一个能复现它的失败测试,然后运行完整协议。修复 Bug 时务必先编写测试。