tdd 精读:没商定接缝就不写测试,重构不在这条循环里
tdd 是一份只讲标准、不讲步骤的参照物:开写之前先把要测的接缝写下来并请你确认——没有确认过的接缝,一行测试都不写。本文走一遍它的真实触发路径(定接缝 → 一次一片 → 红 → 最小实现),讲清它为什么把三个反模式写得比正面例子还细、为什么 2026 年 6 月删掉了重构这一相而 description 还留着 red-green-refactor,以及它主动留白的那个缺口:没有任何东西替你判断『这次改动值不值得跑循环』。
(本文读的是 mattpocock/skills 快照 5beb35b,2026-10-19。引用原文都能按文中路径回仓库核对。这是「逐个技能精读」系列的第十六篇,聚焦 tdd 一个技能。)
它怎么工作:先用一句话把接缝定下来
skills/engineering/tdd/SKILL.md 只有 38 行,开头就自报身份:
TDD is the red → green loop. This skill is the reference that makes that loop produce tests worth keeping: what a good test is, where tests go, the anti-patterns, and the rules of the loop. Every section applies on every cycle: consult them before and during the loop, not after.
触发方式有两种:敲 /tdd;或者任务本身落进它射程——想 test-first 地做功能或修 bug、嘴上说了「red-green-refactor」、想要集成测试。
真正开工前有一步强制动作,写在 ## Seams: where tests go 里:
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.
并且要求给每个候选接缝配一行说明:它逮得住什么、漏掉什么。理由不是洁癖,是预算:
You can’t test everything, so agreeing the seams up front is how testing effort lands on the critical paths and complex logic instead of every edge case.
确认之后才进入循环,而且是一片一片地走:写一个测试、看它红、写刚好够过的实现、再写下一个。每一轮的测试都是一个 tracer bullet——它既验证一条端到端路径,又把上一轮的发现带进下一轮。产物不是某个文件,而是一串「测试 + 实现」的对;tdd 自己不落任何文件,是无状态的。
两个子文件是它的弹药库,按需读:tests.md 给好 / 坏测试的对照代码(集成风格 vs 实现细节 vs 同义反复),mocking.md 给 mock 的边界——只在系统边界 mock(外部 API、时间、随机,有时是文件系统和数据库),绝不 mock 你自己的模块——外加两条「为可 mock 而设计」的接口原则:依赖注入,以及偏好 SDK 式接口而不是一个万能 fetcher。
它还在文档里给自己画了一张去向表,这是「参照物」该有的自我定位:行为已经有确定的输入输出 → tdd;行为还没钉死 → to-spec(它会在写代码之前把测试接缝一起商定);真正的问题是接口的形状 → codebase-design;手里已经有 spec 或工单、想让整次构建被跑完 → implement(它逐张工单驱动 tdd)。最后一行是它主动承认的空白:配置、接线、粘合代码、类型标注、直通的 CRUD 委托,Nothing here fits well——这类改动没有可独立断言的真相,硬套循环只会造出同义反复的测试。
它和「一般技能」的差别:一份标准,不是一个会话
一般技能是「输入 → 步骤 → 产物」的驱动者。tdd 明确不做驱动者,它当参照物:文档写 tdd 是 reference, not a driver——别人(你,或者 implement)跑会话,它提供每一轮循环都要遵守的规则。
第二个差别是决定权留在人手里。它不替你挑接缝。挑接缝决定「测试预算花在哪」,是产品判断而不是工程细节,所以必须你点头。文档说清了完整链条里谁接这一步:接缝在 to-spec 阶段就商定(/tdd is told to only work at pre-agreed test seams, /code-review checks that only agreed-upon test seams were used),单独调用 tdd 时它就直接问你。
第三个差别是它肯为了共享而变薄。v1.0 把自带的那套深模块 / 接口设计说明删掉,改为调用 codebase-design 的词表——它连「接缝」这个词的定义权都交了出去。
它有意识摒弃了什么
一、摒弃了重构这一相。 2026 年 6 月删的,理由两条:agent 几乎从不真的执行它;而且实现与评审分开跑效果更好。重构改归 code-review,那里有 Fowler 的坏味道基线。有意思的是 description 里的 red-green-refactor 没改——文档解释得很直白:这个说法留着,是因为它仍然能触发技能(issue #589,未修)。触发词和正文不一致,是主动接受的成本。
二、摒弃了「什么都测」。 预商定接缝的唯一目的就是花掉有限预算,它不追求覆盖率。
三、摒弃了 mock 的自由。 只允许系统边界;对内部协作者的 mock 被点名为第一个反模式。
四、摒弃了把例子塞进正文。 正面定义只有两段,好 / 坏对照全在子文件里:主文件留规则,例子按需调。
五、摒弃了更强硬的措辞。 文档承认 agent 会违反「先红后绿」(有用户逼问模型,模型答「我知道技能这么说,我只是默认了老习惯」),但作者选择不加更严的措辞——那会限制 agent 的创造力,而放宽了跑的循环也比不跑好。
六、留白了一件事:值不值得跑循环。 文档自己承认这是个缺口(issue #746):tdd 决定接缝在哪,但没有任何东西决定这次改动该不该走循环。跑在没有独立真相可断言的地方,你得到的就是它自己警告的同义反复测试。
还有一处文档替技能认下的摩擦:接缝提示只给名字,不给「各自逮住什么、漏掉什么」,于是你是在标签之间做选择(issue #607,未修)。正文要求的那行说明,实际输出里常常没有——这也正好说明为什么完整链条把这一步提前放进 to-spec:那时你手里有整个功能,而不是一条提示。
原文证据
三个反模式的原文,写法本身就值得抄——每一条都给「怎么认出来」:
- Implementation-coupled: mocks internal collaborators, tests private methods, or verifies through a side channel … The tell: the test breaks when you refactor but behavior hasn’t changed.
- Tautological: the assertion recomputes the expected value the way the code does (
expect(add(a, b)).toBe(a + b), a snapshot derived by hand the same way, a constant asserted equal to itself), so it passes by construction … Expected values must come from an independent source of truth: a known-good literal, a worked example, the spec.- Horizontal slicing: writing all tests first, then all implementation. Bulk tests verify imagined behavior: you test the shape of things rather than user-facing behavior …
循环规则只有三条,其中第三条是整个技能最反直觉的决定:
- Red before green. Write the failing test first, then only enough code to pass it. Don’t anticipate future tests or add speculative features.
- One slice at a time. One seam, one test, one minimal implementation per cycle.
- Refactoring is not part of the loop. It belongs to the review stage (see the
code-reviewskill), not the red → green implementation cycle.
至于运行完怎么算成功,文档给的是几条现场可见的信号:任何测试文件出现之前,它先停下来报出准备测的接缝并等你;测试是一个一个出现的(一个红、补够代码、再下一个),不是一批测试后面跟一批代码;测试名读起来是能力(user can checkout with valid cart)而不是内部结构(checkout calls paymentService.process);断言里的期望值能追到 spec;重命名一个内部函数,套件不该有任何反应;mock 只出现在外部边界上。
可迁移结论
- 让「起点」变成一个必须打勾的动作。 「先写下要测的接缝并确认」比「注意要选好接缝」有用一个数量级:前者是一道门,后者是一句祝愿。
- 给候选方案配「逮住什么 / 漏掉什么」。 一行说明就够,但它把一次猜测变成一次比较。
- 技能可以只是标准,但要把「我不驱动」写在明处。
reference, not a driver这句话替使用者省掉了「为什么它没帮我干活」的困惑。 - 删功能时留着旧触发词是合理的。 触发词面向人的说法,正文面向 agent 的行为;两者不必同步,但要有人把它记下来(issue #589)。
- 反模式要写得比对错例子更具体。 「同义反复」这个词没人会自认,但
expect(add(a, b)).toBe(a + b)会当场让人低头。
tdd 是这套技能里少见的「薄正文 + 厚标准」:它不指挥一次会话,只把每次红绿循环必须满足的条件钉死一条——没有商定过的接缝,就不要写测试。 剩下的一切(重构、接口形状、评审)都被它交还给别的技能,包括那件最难的、它公开承认自己没做的事:判断这次改动到底值不值得跑循环。
讨论
用 GitHub 账号留言;评论保存在公开仓库chengshu-blog-discussions的 Discussions 里。也可通过 RSS 订阅后续文章。