你让 AI 给购物车加一个折扣功能。它一口气写出十二个测试,再一口气写完全部实现,
跑一遍全绿,看起来很专业。两周后你把内部一个函数改了个名字,十二个测试红了九个——
行为其实一点没变。这就是 tdd 这条 skill 要防的事:
测试写了一大堆,却什么也没锁住。
tdd 是 red → green 循环(先写一个失败的测试,再写刚好够让它通过的代码)的纪律参考书:
什么是好测试、测试写在哪、三个反面模式、循环的三条规则。
它是 model-invoked 的(人和 AI 都能启动),也是 implement 在内部驱动的那台引擎。
学完这节课,你能说出它什么时候被触发、每个循环会改哪些文件、
为什么它坚持「一次只切一片」、以及行为不对时该改哪个文件的哪一段。
0001 把 22 个已发布 skill 分成三层:配置层(跑一次性的初始设置)、编排层(你手动启动的完整流程)、
纪律层(被反复调用的基本功,一般不独立交付什么,而是被编排层调用)。
tdd 属于纪律层,和 codebase-design(0005)、domain-modeling(0004)同层。
但同层里有分工:那两个是词汇地板(只统一用词,不规定动作),
tdd 是动作纪律——它规定一个循环怎么转:先红后绿、一次一片、重构不进循环。
它在主流程里的位置很特殊:它不是主流程上独立的一步,而是某一步内部的引擎。
主流程的构建链是 grill-with-docs → to-spec → to-tickets → implement → code-review,
其中 implement(只能人启动)是构建步骤,它在内部驱动 tdd
把每张工单(Issue)test-first 地做出来,然后才交给 code-review。
人读文档的原话是:tdd 是「the engine inside that step rather than a step of its own」
(那一步内部的引擎,而不是独立的一步)。
grill-with-docs → to-spec → to-tickets → implement → code-review
│
├─ 内部驱动 /tdd(一次一片红绿切片)
│ · 会读 CONTEXT.md / ADR 对齐命名
│ · 只在预先确认的接缝上写测试
│
└─ 收尾跑 /code-review,然后 commit
它同时也是一个可以单独伸手拿的工具:当你手里有一个具体行为要做、
又不想走完整的 spec 流程时,直接 /tdd 就行。
ask-matt 的路由原话:「Reach for /tdd on its own when you just want to build
a concrete behaviour test-first without a full spec」
(只想 test-first 地做一个具体行为、不需要完整 spec 时,单独用它)。
地图:0001 系统地图(纪律层一行、主流程一节)· 路由:ask-matt/SKILL.md 主流程第 3 步 · 人读文档:docs/engineering/tdd.md 的 Where it fits 一节
tdd 的 SKILL.md frontmatter 里没有
disable-model-invocation 字段,目录下的 agents/openai.yaml
也只有展示名(display_name: "TDD")和一句展示简介,没有禁止隐式调用的策略——
所以它是 model-invoked:你可以手动敲 /tdd,
AI 也会在任务合适时自己加载它。AI 自动加载的依据是 frontmatter 的
description 字段,原文是:
Test-driven development. Use when the user wants to build features
or fix bugs test-first, mentions "red-green-refactor", or wants
integration tests.
三个触发条件,逐个翻译一下:
人读文档把边界划得很清楚:手里有一个具体行为(concrete behaviour)要构建, 并且想要扛得住重构的测试——用它。行为本身还没定下来、或者问题出在别处时,去别的地方:
| 情境 | 该用 tdd 吗 |
更该去哪 |
|---|---|---|
| 行为已经说清楚,想 test-first 地做出来,测试要扛得住重构 | 该(手动 /tdd 或让 AI 自动加载) | — |
| 行为还没定下来,需求本身还模糊 | 不该(先定 spec,再谈测试) | to-spec(0011 之前的上游,见 0009) |
| 真正的问题是接口的形状,不是测试 | 它会为词汇去引用 codebase-design,但主场不在它 | codebase-design(0005) |
| 一个棘手的 bug:间歇复现、不知道在哪坏的 | 简单的 bug 可以直接 tdd 修;硬骨头不该 | diagnosing-bugs(0015):先要一条能复现的反馈环,再用回归测试锁死 |
| 手上是一张工单 / 一份 spec,要完整交付 | 会被 implement 在内部拉进去 | implement(0011,只能人启动) |
| 不知道该走哪条流程 | 不该 | ask-matt(0019) |
tdd 的 description 里写了「fix bugs test-first」,所以简单 bug 直接用它修是正当的。
但 ask-matt 把「Something's broken」路由给 diagnosing-bugs,
那条 skill 的规矩是:手里还没有一条已经能复现这个 bug 的命令(tight feedback loop,
紧凑的反馈环)之前,不许推测病因。区分方法很简单:bug 一眼能看到在哪、改法明确 → tdd;
bug 要破案 → diagnosing-bugs,它的收尾(回归测试)和 tdd 的红绿纪律是同源的。
触发条件: skills/engineering/tdd/SKILL.md 的 frontmatter · 展示配置:agents/openai.yaml · 边界划分:docs/engineering/tdd.md 的 When to reach for it 一节 · 硬 bug 路由:ask-matt/SKILL.md 的 On-ramps 一节
SKILL.md 开篇第一句给这个 skill 定了性:
「TDD is the red → green loop. This skill is the reference that makes that loop
produce tests worth keeping」(TDD 就是红 → 绿循环;这条 skill 是让这个循环
产出值得留下的测试的参考书)。它还立了一条使用规矩:
「Every section applies on every cycle — consult them before and during the loop,
not after」(每一节在每个循环上都适用——在循环之前和之中查阅,而不是事后)。
换句话说,这不是一份「出问题了才翻」的故障手册,而是每转一圈循环都要对照的戒律。
循环本身只有三条规则,全部照录 SKILL.md 的 Rules of the loop 一节:
| 规则 | 意思 | 违反它的典型后果 |
|---|---|---|
| Red before green(先红后绿) | 先写那个失败的测试,然后只写刚好够让它通过的代码。不许预判后面的测试,不许加投机性的功能 | 实现跑在测试前面:多写的代码没有测试锁定,而且你永远不知道测试是不是真的能失败(没看过它红,就可能是恒真测试) |
| One slice at a time(一次一片) | 每个循环只处理:一条接缝、一个测试、一份最小实现 | 一批测试配一批实现,就退化成了 horizontal slicing(水平切片,第 6 节的反面模式之三) |
| Refactoring is not part of the loop(重构不属于循环) | 重构属于 review 阶段(见 code-review 那条 skill),不属于红 → 绿的实现循环 |
边写边重构,循环失去焦点:你不知道刚变绿的测试是被新代码喂绿的,还是被顺手重构弄绿的 |
agents/openai.yaml 的展示简介里都有 "red-green-refactor" 这个经典口号,
人读文档也说「Refactoring only happens once the suite is green; never while red」
(只在测试套件全绿后才重构,红的时候绝不重构)。
但 SKILL.md 的规则更硬:重构根本不属于这条循环,它被挪到了
code-review 那个 review 阶段。所以在这套系统里,经典三段式被改成了
「red → green →(把循环转完)→ review 阶段再重构」。
想调整这个分工,改的是 SKILL.md 的 Rules of the loop,不是那句展示简介。
规则原文: skills/engineering/tdd/SKILL.md 的 Rules of the loop 一节 · 展示简介:agents/openai.yaml · 「绿了才重构」:docs/engineering/tdd.md 的 Red-green, one slice at a time 一节
seam(接缝)这个词你在 0005 已经学过:codebase-design 给它的定义是
「不用改那处代码就能换行为的位置,也就是接口所在的位置」。
tdd 的 SKILL.md 给了同一个词一个面向测试的工作定义:
「A seam is the public boundary you test at: the interface where you observe behavior
without reaching inside」(接缝是你在上面做测试的公共边界:不伸手进内部、
就能观察到行为的那个接口)。两句话说的是同一件事:
测试活在接缝上,永远不顶着内部写。
为什么要先确认?SKILL.md 给了理由:你不可能什么都测,
事先约定接缝,是让测试火力落在关键路径和复杂逻辑上、而不是撒在每个边角案例上的方法。
这也解释了为什么这条 skill 和 codebase-design 是邻居:
找「值得测的接缝」需要的正是深模块词汇——接缝选在哪,本质上是模块形状问题(0005 第 3、6 节)。
人读文档明说:tdd 在规划阶段会调用 codebase-design 的深模块词汇。
上游也在为这句话做准备:to-spec 写需求文档时会先画测试接缝的草图并和你确认
(0009 会讲),implement 的正文只有几行,其中一行就是
「Use /tdd where possible, at pre-agreed seams」(尽量用 /tdd,在预先约定的接缝上)。
所以「先确认接缝」不是 tdd 一条 skill 的怪癖,而是从 spec 到 implement 到 tdd
一路传下来的接力棒。
接缝定义与确认纪律: skills/engineering/tdd/SKILL.md 的 Seams 一节 · 词汇同源:codebase-design/SKILL.md(0005 第 3 节)· 接力棒:implement/SKILL.md
SKILL.md 的判据:测试通过公共接口验证行为,不验证实现细节
(implementation details,代码内部的具体写法)。代码可以整个换掉,测试不应该跟着动。
一个标志是:好测试读起来像一份规格说明——
「user can checkout with valid cart」(用户能用有效购物车结账)这种名字,
一看就知道系统具备什么能力,而且它不关心内部结构,所以扛得住重构。
sibling 文件 tests.md 把好测试的特征列成了五条,照录并翻译:
tests.md 给的对照例(「绕过接口去外部验证」是最常见的坏味道):
// BAD:绕过接口,直接查数据库验证
test("createUser saves to database", async () => {
await createUser({ name: "Alice" });
const row = await db.query("SELECT * FROM users WHERE name = ?", ["Alice"]);
expect(row).toBeDefined();
});
// GOOD:通过接口验证——存进去的东西能再取出来
test("createUser makes user retrievable", async () => {
const user = await createUser({ name: "Alice" });
const retrieved = await getUser(user.id);
expect(retrieved.name).toBe("Alice");
});
坏测试的名字「saves to database」描述的是 HOW(怎么存的),好测试的名字 「makes user retrievable」描述的是 WHAT(调用者得到什么能力)。哪天实现从 SQL 数据库换成内存仓库,坏测试全红,好测试照绿。
还有一条容易漏看的规矩:SKILL.md 要求探索代码库时读项目的
CONTEXT.md(如果存在),让测试名字和接口用词与项目的领域语言对齐,
并尊重改动区域里的 ADR(架构决策记录,0004 讲过)。
也就是说 tdd 不只往仓库里写测试,它还消费 0004 那条词汇地板产出的领域语言——
测试名里的 Order、Account 这些词,应该和 CONTEXT.md 里的一模一样。
判据原文: skills/engineering/tdd/SKILL.md 的 What a good test is 一节 · 五条特征与对照例:skills/engineering/tdd/tests.md · 领域语言对齐:SKILL.md 第 10 行(读 CONTEXT.md、尊重 ADR)
SKILL.md 的 Anti-patterns 一节列了三个反面模式,每个都给了「识别信号」(tell)。
这张表值得背下来——AI 写测试跑偏,基本都落在这三个坑里。
| 反面模式 | 长什么样 | 识别信号(tell) | 解药 |
|---|---|---|---|
| Implementation-coupled (耦合实现的测试) |
mock 内部协作者、测私有方法、或者通过旁路验证(比如不走接口、直接查数据库) | 你只重构了内部结构、行为没变,测试却红了 | 测试只走公共接口(第 5 节);内部重构完,测试应该一个不红 |
| Tautological (同义反复测试) |
断言的期望值是用和代码相同的方式算出来的:expect(add(a, b)).toBe(a + b)、手工照代码逻辑推出来的快照、一个常量断言等于它自己 |
测试「结构上必然通过」——它永远不可能和代码意见不合,因此什么也没告诉你 | 期望值必须来自独立的真值来源:已知正确的字面量、手工算过的例题、spec 里写的数字 |
| Horizontal slicing (水平切片) |
先把所有测试写完,再写所有实现 | 批量测试验证的是想象出来的行为:测的是事物的形状而不是面向用户的行为;测试对真实变化变迟钝;你在理解实现之前就对测试结构下了注 | 改成 vertical slices(垂直切片):一个测试 → 一份实现 → 重复,每个测试都是一颗 tracer bullet(曳光弹),回应上一个循环教会你的东西 |
tests.md 给同义反复测试的对照例,很直观:
// BAD:期望值用和代码一样的方式现算
test("calculateTotal sums line items", () => {
const items = [{ price: 10 }, { price: 5 }];
const expected = items.reduce((sum, i) => sum + i.price, 0);
expect(calculateTotal(items)).toBe(expected);
});
// GOOD:期望值是独立的、已知正确的字面量
test("calculateTotal sums line items", () => {
expect(calculateTotal([{ price: 10 }, { price: 5 }])).toBe(15);
});
SKILL.md 则把每个垂直切片都叫
tracer bullet:每个测试都回应上一个循环学到的东西。两种读法不冲突:
第一片是探路的曳光弹,后面每片是顺着弹道校准的下一发。
上游 to-tickets 也借这个词形容工单的形状(tracer-bullet tickets,
每张票打通一条端到端的薄路径),0010 会讲。
人读文档还给了一段「它运转正常的标志」(It's working if),可以当验收清单用:
三反模式原文: skills/engineering/tdd/SKILL.md 的 Anti-patterns 一节 · 对照例:skills/engineering/tdd/tests.md · 曳光弹与验收清单:docs/engineering/tdd.md
mock(仿冒:用一个受控的假对象替换真实协作者)是测试变脆的最大来源,
所以 sibling 文件 mocking.md 整篇只做一件事:划定哪里可以 mock。
规则一句话:只在系统边界 mock。
| 可以 mock(系统边界) | 不许 mock(你控制的东西) |
|---|---|
| 外部 API(支付、邮件这类第三方服务) | 你自己的类和模块 |
| 数据库(有时——优先用真实的测试数据库) | 内部协作者(同仓库里互相调用的对象) |
| 时间 / 随机性 | 任何你控制得了的东西 |
| 文件系统(有时) | — |
注意「数据库」和「文件系统」都标了「有时」,而且数据库那条附了「优先测试数据库」—— 这和 0005 讲的 DEEPENING 分类完全对得上:本地可替代的依赖(比如用 PGLite 顶替 Postgres) 应该用本地替身,而不是开 mock;只有你控制不了的真外部依赖才落到 mock。
mocking.md 还给了两条设计建议,让边界上的接口天然好 mock:
processPayment(order, paymentClient) 好 mock,
因为 paymentClient 是传进来的;processPayment(order) 内部
new StripeClient(process.env.STRIPE_KEY) 难 mock,因为边界被焊死在函数体里。
这正是 0005 第 6 节「Accept dependencies, don't create them」的落地。
getUser(id)、getOrders(userId)、
createOrder(data)),而不是一个带条件逻辑的通用
fetch(endpoint, options)。好处原文列了四条:每个 mock 只返回一种固定形状;
测试准备里没有条件分支;一眼看出测试覆盖了哪些端点;每个端点有独立的类型安全。
全部引文: skills/engineering/tdd/mocking.md · 对照 0005: codebase-design/DEEPENING.md 的依赖四类
┌──────────────────────────┐
│ codebase-design(0005) │ 词汇地板:seam / interface
└────────────┬─────────────┘
│ 规划时引用深模块词汇来找值得测的接缝
▼
CONTEXT.md / ADR ──读──► ┌──────────┐ 写完测试+最小实现
(domain-modeling 产出) │ tdd │ ──────────────────────► 代码库
└────┬─────┘
▲
│ 内部驱动(每张工单逐片红绿)
┌──────────────────┴───────────┐
│ implement(0011,User) │ ──收尾──► code-review(0013)──► commit
└──────────────────────────────┘
平级表亲:diagnosing-bugs(0015)——硬 bug 走它,收尾的回归测试与红绿纪律同源
| Skill | 启动 | 和 tdd 的关系 |
|---|---|---|
| implement | User | tdd 最主要的调用方。它的正文只有几行,其中一行是「Use /tdd where possible, at pre-agreed seams」;它还要定期跑类型检查、定期跑单个测试文件、最后跑一遍完整测试套件,然后交给 code-review、提交。你在 implement 里看到 AI 做红绿循环,那就是 tdd 在转。 |
| codebase-design | Model | tdd 的词汇来源。找值得测的接缝、判断「接口就是测试面」,都用这套深模块词汇;人读文档明说 tdd 在规划阶段会调用它。 |
| domain-modeling | Model | tdd 消费它的产物:读 CONTEXT.md 对齐测试命名和接口用词,尊重改动区域的 ADR。方向是单向的——tdd 只读,不写。 |
| code-review | Model | tdd 的下游。tdd 的规则明说「重构属于 review 阶段(见 code-review)」;implement 收尾时跑它,做 Standards + Spec 双轴审查。 |
| diagnosing-bugs | Model | 平级表亲,不互相调用。硬 bug 走它的破案流程,最后用回归测试锁死;简单 bug 直接 tdd 修。两条路都以「一个先变红的测试」收场。 |
| to-spec / to-tickets | User | 上游。to-spec 画测试接缝草图,to-tickets 把工单切成 tracer-bullet 形状——都是为 tdd 的「约定接缝、逐片红绿」提前备料。 |
调用方原文:implement/SKILL.md · 路由与表亲:ask-matt/SKILL.md · 词汇来源:docs/engineering/tdd.md 的 Where it fits 一节
| 动作 | 会不会发生 | 说明 |
|---|---|---|
| 写测试文件 | 会 | 每个循环一个测试,落在预先确认的接缝上;测试名对齐 CONTEXT.md 的领域语言 |
| 写 / 改实现代码 | 会 | 每个循环只写「刚好够让当前测试通过」的最小实现,不预判后面的测试 |
| 读 CONTEXT.md / ADR | 会(存在就读) | 目的是命名对齐和尊重既有决策;它不写这两个文件 |
| 写 Issue tracker | 不会 | 它不碰工单系统;工单状态流转是 triage / implement 那条线的事 |
| 重构 | 不在循环内 | 留给 code-review 阶段;循环里只允许「红 → 刚好够绿」 |
| 提交 commit | 不会(单独用时) | 提交是 implement 收尾的动作;你单独跑 /tdd 时它只管循环 |
/code-review(0013)、提交。看到它在循环里,等它走完就行。/code-review 做双轴审查(Standards + Spec),重构也在那个阶段做;然后由你或 implement 提交。to-spec(0009)把 spec 定下来再继续;tdd 的触发条件本来就要求「行为已具体」。codebase-design(0005)做形状设计,必要时走它的 design-it-twice;形状定了再回来转循环。improve-codebase-architecture(0017)找加深机会。| 症状 | 先查 / 先改 | 不要误改 |
|---|---|---|
| AI 从不主动加载它,或者对无关任务也加载 | tdd/SKILL.md frontmatter 里 description 的 "Use when…" 一句——触发条件全在那里 |
agents/openai.yaml(那只是展示名和简介,不管触发) |
| AI 不确认接缝就直接开写测试 | SKILL.md 的 Seams 一节(「Test only at pre-agreed seams」和标准问句) | tests.md 的例子(例子不管流程纪律) |
| AI 一批写完全部测试再写实现 | SKILL.md 的 Anti-patterns(Horizontal slicing)+ Rules of the loop(One slice at a time) | implement 的正文(它只说「尽量用 tdd」,不管切片粒度) |
| AI 在循环里边写边重构 | SKILL.md 的 Rules of the loop 第三条(重构属于 review 阶段) | openai.yaml 的 "red-green-refactor" 简介(口号不改纪律;想改纪律改规则原文) |
| 测试 mock 了自己的内部模块、一重构就红 | mocking.md 的两张清单 + SKILL.md 的 implementation-coupled 一条 |
codebase-design 的词汇段(那是形状问题恶化后再去的地方) |
| 断言的期望值跟着实现走、测试恒绿 | SKILL.md 的 Tautological 一条 + tests.md 的字面量对照例 |
测试运行器的配置(问题在纪律,不在工具) |
| 测试名和项目领域语言对不上(Order / order 混用) | SKILL.md 第 10 行(读 CONTEXT.md、尊重 ADR 那条); CONTEXT.md 本身由 domain-modeling 维护 | tdd 的测试例子(例子里的 cart/checkout 只是示例词汇) |
| implement 里没有出现红绿循环 | implement/SKILL.md 的「Use /tdd where possible, at pre-agreed seams」一句 |
tdd 自己(被调用方管不了调用方用不用它) |
先别往回翻,凭记忆答。选项长度刻意对齐,不会从版式泄题。判据以本课和 SKILL.md 的原文为准。
本课主一手材料(请打开原文读,不要只背本页摘要):
skills/engineering/tdd/SKILL.md
—— 定性、好测试判据、接缝纪律、三个反面模式、循环三规则。本课的单一权威出处。
…/tests.md
—— 好测试五条特征、坏测试红旗清单、旁路验证与同义反复的对照例。
…/mocking.md
—— 只在系统边界 mock、依赖注入、SDK 风格接口。
docs/engineering/tdd.md
—— 给人看的叙事版:曳光弹、「不开过头灯」、验收清单、在流程里的位置
(aihero.dev/skills-tdd)。
速查页(本课同步): reference/tdd.html
导航: 上一课 0011 implement (tdd 最主要的调用方,那条只有几行正文的 user-invoked 入口)。 总览仍回 0001 系统地图; 词汇地板见 0005 codebase-design。
建议下一课(0013): 0013 code-review——tdd 把重构赶出循环时点名的地方。 红绿循环只管「让行为被测试锁住」,代码干不干净、和 spec 对不对得上, 是 review 阶段那条双轴审查(Standards + Spec)的事。implement 的收尾也是它: 跑完 code-review 才 commit。
SKILL.md / tests.md / mocking.md 的原文,不会临场编造。
做完检索练习后,回复「练习结果 / 哪里卡住 / 开 0013 或先补 0011」,我们安排下一课。