Lesson 0012 · Engineering · 人和 AI 都能启动(model-invoked)· 纪律课

tdd:让红绿循环产出值得留下的测试

你让 AI 给购物车加一个折扣功能。它一口气写出十二个测试,再一口气写完全部实现, 跑一遍全绿,看起来很专业。两周后你把内部一个函数改了个名字,十二个测试红了九个—— 行为其实一点没变。这就是 tdd 这条 skill 要防的事: 测试写了一大堆,却什么也没锁住tdd 是 red → green 循环(先写一个失败的测试,再写刚好够让它通过的代码)的纪律参考书: 什么是好测试、测试写在哪、三个反面模式、循环的三条规则。 它是 model-invoked 的(人和 AI 都能启动),也是 implement 在内部驱动的那台引擎。 学完这节课,你能说出它什么时候被触发、每个循环会改哪些文件、 为什么它坚持「一次只切一片」、以及行为不对时该改哪个文件的哪一段。

1. 它在整个系统里站在哪

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 一节

2. 调用方式与触发场景

2.1 两种启动方式

tddSKILL.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.

三个触发条件,逐个翻译一下:

2.2 什么时候该用,什么时候别用

人读文档把边界划得很清楚:手里有一个具体行为(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 一节

3. 红绿循环的三条规则

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 一节

4. 接缝:测试写在哪,谁来定

seam(接缝)这个词你在 0005 已经学过:codebase-design 给它的定义是 「不用改那处代码就能换行为的位置,也就是接口所在的位置」。 tddSKILL.md 给了同一个词一个面向测试的工作定义: 「A seam is the public boundary you test at: the interface where you observe behavior without reaching inside」(接缝是你在上面做测试的公共边界:不伸手进内部、 就能观察到行为的那个接口)。两句话说的是同一件事: 测试活在接缝上,永远不顶着内部写

这条 skill 里最硬的一句话 「Test only at pre-agreed seams. Before writing any test, write down the seams under test and confirm them with the user. No test is written at an unconfirmed seam.」 ——只在预先约定好的接缝上写测试。写任何测试之前,把要测的接缝写下来并跟你确认; 没有确认的接缝上,一个测试也不写。 配套的标准问句是:「What's the public interface, and which seams should we test?」 (公共接口是什么,我们该测哪几条接缝?)

为什么要先确认?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

5. 什么是好测试

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)

6. 三个反面模式

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);
});
tracer bullet 的两个出处,别读混了 曳光弹(tracer bullet:先打一发照亮整条路径的子弹,再校准火力)这个词在两个文件里用法略有差别。 人读文档把它留给第一个循环:先写一个打通单条端到端路径的测试,再从它向外扩展—— 原话是「you never outrun your headlights」(你的灯光照多远,你就开多远, 绝不对还不理解的测试结构提前下注)。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

7. mock 纪律:只在系统边界

mock(仿冒:用一个受控的假对象替换真实协作者)是测试变脆的最大来源, 所以 sibling 文件 mocking.md 整篇只做一件事:划定哪里可以 mock。 规则一句话:只在系统边界 mock

可以 mock(系统边界) 不许 mock(你控制的东西)
外部 API(支付、邮件这类第三方服务) 你自己的类和模块
数据库(有时——优先用真实的测试数据库) 内部协作者(同仓库里互相调用的对象)
时间 / 随机性 任何你控制得了的东西
文件系统(有时)

注意「数据库」和「文件系统」都标了「有时」,而且数据库那条附了「优先测试数据库」—— 这和 0005 讲的 DEEPENING 分类完全对得上:本地可替代的依赖(比如用 PGLite 顶替 Postgres) 应该用本地替身,而不是开 mock;只有你控制不了的真外部依赖才落到 mock。

7.1 为「可 mock」而设计

mocking.md 还给了两条设计建议,让边界上的接口天然好 mock:

  1. 用依赖注入(dependency injection:把依赖从参数传进来,而不是在函数内部自己 new 一个)。 原文的对照例:processPayment(order, paymentClient) 好 mock, 因为 paymentClient 是传进来的;processPayment(order) 内部 new StripeClient(process.env.STRIPE_KEY) 难 mock,因为边界被焊死在函数体里。 这正是 0005 第 6 节「Accept dependencies, don't create them」的落地。
  2. 要 SDK 风格的接口,不要通用 fetcher。 给每个外部操作写一个具体函数(getUser(id)getOrders(userId)createOrder(data)),而不是一个带条件逻辑的通用 fetch(endpoint, options)。好处原文列了四条:每个 mock 只返回一种固定形状; 测试准备里没有条件分支;一眼看出测试覆盖了哪些端点;每个端点有独立的类型安全。
和第 6 节对上 「mock 内部协作者」同时出现在两个地方:mocking.md 的「不许 mock」清单, 和反面模式 implementation-coupled 的定义。这是同一道纪律的两面: mock 纪律管的是「你在哪里允许造假」,implementation-coupled 管的是 「你的测试是不是因此对内部结构上了瘾」。一句话:造假只允许发生在国境线上

全部引文: skills/engineering/tdd/mocking.md · 对照 0005: codebase-design/DEEPENING.md 的依赖四类

8. 它依赖谁、谁依赖它

                ┌──────────────────────────┐
                │  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 一节

9. 会留下什么、用完接什么、想微调改哪里

9.1 副作用:写哪些文件,读哪些文件

动作 会不会发生 说明
写测试文件 每个循环一个测试,落在预先确认的接缝上;测试名对齐 CONTEXT.md 的领域语言
写 / 改实现代码 每个循环只写「刚好够让当前测试通过」的最小实现,不预判后面的测试
读 CONTEXT.md / ADR (存在就读) 目的是命名对齐和尊重既有决策;它不写这两个文件
写 Issue tracker 不会 它不碰工单系统;工单状态流转是 triage / implement 那条线的事
重构 不在循环内 留给 code-review 阶段;循环里只允许「红 → 刚好够绿」
提交 commit 不会(单独用时) 提交是 implement 收尾的动作;你单独跑 /tdd 时它只管循环

9.2 用完之后,下一步去哪

  1. 在 implement 内部被驱动的 → 不用你操心:implement 会继续跑完整测试套件、调 /code-review(0013)、提交。看到它在循环里,等它走完就行。
  2. 单独用 /tdd 做完一个行为 → 套件全绿后,接 /code-review 做双轴审查(Standards + Spec),重构也在那个阶段做;然后由你或 implement 提交。
  3. 循环中发现行为根本没定清楚 → 停下来,回 to-spec(0009)把 spec 定下来再继续;tdd 的触发条件本来就要求「行为已具体」。
  4. 发现真正的问题是接口形状(接缝怎么切都不顺手) → 去 codebase-design(0005)做形状设计,必要时走它的 design-it-twice;形状定了再回来转循环。
  5. 测试怎么写都脆、找不到能锁住行为的接缝 → 这和 diagnosing-bugs 复盘时的发现是同一类信号:交接给 improve-codebase-architecture(0017)找加深机会。

9.3 行为不对时,去改哪个文件的哪一段

症状 先查 / 先改 不要误改
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 自己(被调用方管不了调用方用不用它)

10. 检索练习

先别往回翻,凭记忆答。选项长度刻意对齐,不会从版式泄题。判据以本课和 SKILL.md 的原文为准。

自测(立即反馈)

1. 关于 tdd 的调用方式,哪句是对的?
2. 写第一个测试之前,SKILL.md 要求必须先做什么?
3. 人读文档里的 tracer bullet 指的是什么?
4. 哪条是 tautological(同义反复)测试的判据?
5. horizontal slicing(水平切片)为什么被反对?
6. 按 mocking.md,下列哪个允许 mock?
7. 重构(refactoring)在这套纪律里的位置是?
8. implement 和 tdd 的关系,哪句是对的?
额外提取练习(无选项) 合上本页,默写:红绿循环的三条规则、三个反面模式各自的识别信号(tell)、 mocking.md 里「可以 mock」的四类边界。 然后拿你当前项目里一个真实测试对照:它的期望值来自独立真值来源吗? 把内部一个函数改名,它会红吗?两个问题的答案应该分别是「是」和「不会」。

11. 下一课与一手材料

本课主一手材料(请打开原文读,不要只背本页摘要):

速查页(本课同步): 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。

老师就在会话里。 对本课任何一条纪律的边界有疑问——比如「有时可以 mock 数据库」到底什么时候算「有时」、 tracer bullet 在工单粒度和测试粒度上是不是一回事、重构挪到 review 阶段后循环里发现坏味道怎么办—— 直接在对话里问。回答会回到 SKILL.md / tests.md / mocking.md 的原文,不会临场编造。 做完检索练习后,回复「练习结果 / 哪里卡住 / 开 0013 或先补 0011」,我们安排下一课。