基于测试接缝的 TDD 实践指南:GitHub_Trending/skills13/skills 中tdd技能的 Red-Green 循环与反模式解析
【免费下载链接】skillsSkills for Real Engineers. Straight from my .agents directory.项目地址: https://gitcode.com/GitHub_Trending/skills13/skills
tdd是 Matt Pocock skills 仓库中一套以"测试接缝(seam)"为中心的测试驱动开发参考规范:它定义了一条"先写失败测试、再写恰好够用的实现、逐条推进"的 Red-Green 循环,并给出好测试的标准、Mock 的使用边界,以及三类会悄悄毁掉测试套件的反模式。读完本文,你将掌握如何在既有技能链(to-spec → implement → code-review)中落地这套循环,理解"只在预先约定的接缝上写测试"这一绝对规则,并能够用仓库提供的 tests.md 与 mocking.md 中的 TypeScript 示例直接指导日常开发。
一、tdd 是什么:一套"参考",而不是一个"驱动"
在 docs/engineering/tdd.md 的定位里,tdd通过测试优先的方式构建功能或修复缺陷:一次一个失败测试,然后写恰好够让测试通过的代码,再进入下一个行为。它承载的是让这个循环"产出值得保留的测试"的标准:什么是一个好测试、测试放在哪里、Mock 用来做什么,以及三类会悄悄毁掉测试套件的反模式。
两个关键约束定义了它的性格:
- 不在未经同意的接缝上写任何测试。在任何一个测试存在之前,它会先点名打算测试的公共边界并停下来等你确认。原因是测试投入是有限的,应该把精力花在关键路径上,而不是每一个边缘用例上。
tdd是参考(reference),不是驱动(driver)。它只持有循环的规则;真正运行"会话"的是你本人,或者由 implement 技能按 ticket 逐个驱动它。
这一点在 tdd/SKILL.md 的元信息里得到了印证:该技能被归类为Model-invoked(模型可自动调用),其 frontmatter 描述为:
--- name: tdd 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. ---也就是说,模型会在任务符合"测试优先构建功能/修复 bug""提到 red-green-refactor"或"需要集成测试"时自动抓起这个技能——这是它区别于 implement、to-spec 等用户显式调用的技能(disable-model-invocation: true)的关键差异(见 skills/engineering/README.md 的分类说明)。
二、何时使用 tdd:先判断"行为是否已被钉住"
触发方式是输入/tdd,或由 Agent 在任务契合时自动选用。它的适用判据非常具体:存在一个具有输入和可观察输出的具体行为,并且你希望测试能在重构中存活。
原文档给出了一张"场景 → 去向"的决策表,完整继承如下:
| 你的场景 | 该去哪里 |
|---|---|
| 有明确输入/输出的行为(业务逻辑、请求/响应契约、转换、校验) | tdd |
| 行为还没被钉住 | to-spec,它同样会在写任何代码前先约定测试接缝 |
| 真正的问题是接口的形态,而不是测试 | codebase-design |
| 已有 spec 或 tickets,想让人把整个构建跑完 | implement,它按 ticket 驱动tdd |
| 配置、粘合代码、类型注解、直白的 CRUD 委托 | 这里没有合适的技能;见下方"空白"说明 |
最后一行是真实的空白,不是风格偏好。这个技能决定的是"接缝放在哪里";没有任何东西决定"这次变更是否值得跑这条循环"。对一个没有独立真值来源可断言的变更跑它,得到的将是一个"复述实现"的测试——即技能自己警告的同义反复(tautological)反模式,只不过是从另一个方向撞上的。原文档记录这对应 upstream 仓库的 issue #746(当前仍为打开状态)。在该 issue 关闭前,这个判断要由你或你的CLAUDE.md承担。
三、前置条件:与 codebase-design 共享接口词汇
原文档明确列出一个前置条件:需要安装 codebase-design。
历史背景:tdd曾自带 deep-module(深模块)与接口设计笔记;在 v1.0 中这些内容被删除,统一收进codebase-design,tdd现在借用它来获得接口设计的词汇。除此之外不需要任何别的东西——该技能是**无状态(stateless)**的,不写任何自己的文件。
tdd的 SKILL.md 也明确指示:当"接口的形态本身存疑"(模块该多深、接缝放哪里、接口该暴露什么)时,应调用 Skill 工具加载codebase-design的词汇表。这份词汇表(见 codebase-design/SKILL.md)严格定义了本技能使用的术语:
- 模块(Module):任何"有接口 + 有实现"的东西,刻意与规模无关(函数、类、包、跨层的切片)。
- 接缝(Seam,Michael Feathers 术语):可以在不修改该处的前提下改变行为的位置;即模块接口所在的"地点"。
- 深度(Depth):接口层面上的杠杆——调用者每学一点接口就能支配的行为量。
- 适配器(Adapter):在接缝处满足接口的具体东西,描述的是"角色"而非"实质"。
这也解释了为什么tdd反复强调"测试穿过与调用者相同的接缝"——codebase-design 中"接口即测试面(The interface is the test surface)"原则正是tdd接缝理论的底层支撑。
四、循环与它运行的接缝:三个关键词
原文档用三个词承载这个技能:
Red-green(红-绿)。先写失败测试,然后只写恰好够让测试通过的代码,不提前预判"后一个测试"。需要特别注意的是:这个循环没有重构阶段。原文档记载,重构阶段在 2026 年 6 月被移除,原因是 Agent 实际上几乎从不执行它,而且把评审与实现拆成独立会话效果更好。重构现在归属于 code-review。
垂直切片(Vertical slice)。一次一个接缝、一个测试、一个最小实现,然后重复;第一个循环是一条曳光弹(tracer bullet),用来证明单一路径端到端跑通。与之相反的是水平切片(horizontal slicing):先写完所有测试,再写所有代码。批量测试验证的是想象出来的行为——它们检查的是事物的"形状"而不是用户真正做的事,而且会在你理解实现之前就把测试结构锁定下来。
预先约定的接缝(Pre-agreed seam)。接缝是你观察行为所经由的公共边界,无须窥探内部。规则是绝对的:不在未确认的接缝上写测试。在完整链路中,接缝会在更早的 to-spec 阶段就约定好——原文档引用链路原话:"/tdd被指示只在预先约定的测试接缝上工作,/code-review检查是否只使用了约定的接缝。"单独调用tdd时,它会直接向你询问。
4.1 三条循环规则(源自 SKILL.md)
tdd/SKILL.md 把循环规则浓缩为三条,每个循环都必须遵守:
- 先红后绿(Red before green):先写失败测试,再写恰好够用的实现。不要预判未来的测试,不要加投机性的功能。
- 一次一个切片(One slice at a time):每个循环只做"一个接缝、一个测试、一个最小实现"。
- 重构不属于循环:重构归评审阶段(见
code-review技能),不属于 red → green 实现循环。
这与原文档中"red-green-refactor 的触发短语与正文不一致"的问题直接相关,详见第六节的常见问题。
五、三类反模式:实现耦合、同义反复、水平切片
原文档给出了一张反模式速查表,完整继承:
| 反模式 | 特征 |
|---|---|
| 实现耦合(Implementation-coupled) | 重命名内部函数时测试就挂了,尽管行为没变。Mock 内部协作者、断言调用次数、用数据库查询来验证而不是用接口。 |
| 同义反复(Tautological) | 期望值是用代码算它的方式算出来的,测试"因构造而通过"。期望值必须来自别处:已知正确的字面量、手算的例子、spec。 |
| 水平切片(Horizontal slicing) | 一批测试在实现之前就落地了。 |
下面结合 tdd/tests.md 中的真实示例逐一展开。
5.1 实现耦合:测试跟着内部结构一起碎掉
// BAD: 测试实现细节 test("checkout calls paymentService.process", async () => { const mockPayment = jest.mock(paymentService); await checkout(cart, payment); expect(mockPayment.process).toHaveBeenCalledWith(cart.total); });红线信号包括:Mock 内部协作者、测试私有方法、断言调用次数/顺序、重构后行为未变但测试崩掉、测试名描述 HOW 而不是 WHAT、绕开接口用外部手段验证。
// 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"); });5.2 同义反复:期望值"由构造而通过"
// 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); });同义反复的典型形态还有"手算方式和代码一样的快照(snapshot)""常量断言它等于自己"。SKILL.md 的表述是:这类测试因构造而通过,永远不可能与代码产生分歧——因此也就没有任何验证价值。
5.3 水平切片:批量测试验证的是"想象中的行为"
先写所有测试、再写所有实现,会导致三宗罪:测试验证的是想象中的行为;测试的是事物的"形状"而非用户可见的行为;测试对真实变更变得不敏感;以及在理解实现之前就承诺了测试结构。对策是垂直切片:一个测试 → 一个实现 → 重复,每个测试都是一颗曳光弹,回应上一个循环教给你的东西。
六、Mock 的边界:只在系统边界,从不包住自己的模块
原文档的规则一句话:Mock 只用于系统边界——外部 API、时间、随机性、有时还有文件系统或数据库。不要 Mock 你自己的模块。
tdd/mocking.md 给出了更细的清单:
可 Mock 的(系统边界):
- 外部 API(支付、邮件等)
- 数据库(有时——更倾向测试库)
- 时间/随机性
- 文件系统(有时)
不可 Mock 的:
- 你自己的类/模块
- 内部协作者
- 任何你控制的东西
6.1 为可 Mock 性设计接口
在系统边界处,要设计容易 Mock 的接口,两个具体手法:
手法一:依赖注入。把外部依赖作为参数传进来,而不是在内部创建:
// 易 Mock:依赖由外部注入 function processPayment(order, paymentClient) { return paymentClient.charge(order.total); } // 难 Mock:依赖在内部硬编码创建 function processPayment(order) { const client = new StripeClient(process.env.STRIPE_KEY); return client.charge(order.total); }手法二:优先 SDK 风格的接口,而不是通用 fetcher。为每个外部操作创建具体函数,而不是用一个带条件逻辑的通用函数:
// GOOD: 每个函数都可以独立 mock const api = { getUser: (id) => fetch(`/users/${id}`), getOrders: (userId) => fetch(`/users/${userId}/orders`), createOrder: (data) => fetch('/orders', { method: 'POST', body: data }), }; // BAD: mock 时需要写条件逻辑 const api = { fetch: (endpoint, options) => fetch(endpoint, options), };SDK 方式的收益:每个 mock 只返回一种具体形状、测试 setup 里没有条件逻辑、更容易看出测试覆盖了哪些端点、每个端点都有类型安全。
七、什么是一个好测试:读起来像一份规格说明书
tdd/SKILL.md 开篇定义:"测试通过公共接口验证行为,而不是验证实现细节。代码可以彻底改变,测试不应改变。"
一个好的测试读起来像规格说明书:"user can checkout with valid cart"精确告诉你存在什么能力,并且因为不关心内部结构,它能挺过重构。
// GOOD: 测试可观察行为 test("user can checkout with valid cart", async () => { const cart = createCart(); cart.add(product); const result = await checkout(cart, paymentMethod); expect(result.status).toBe("confirmed"); });好测试的特征(源自 tests.md):
- 测试用户/调用者关心的行为
- 只用公共 API
- 挺过内部重构
- 描述 WHAT,而不是 HOW
- 每个测试一个逻辑断言
tdd在探索代码库时还有一个硬性习惯:如果存在CONTEXT.md就先读它,让测试命名和接口词汇与项目的领域语言对齐,并尊重所触及区域的 ADR。
八、常见问题:原文档的七个 Q&A
原文档记录了大量真实使用中的摩擦与决策,完整继承如下。
Q1:为什么不重构?描述明明写着"red-green-refactor"。
因为重构步骤被移除了,而描述没有被更新。移除是刻意的:Agent 实际上几乎从不执行重构,把实现与评审放在独立会话中效果更好。与其纠结结果还算不算教科书意义上的 TDD,不如关心循环是否产出了更好的代码。"red-green-refactor"这个触发短语与正文的不一致被记为 upstream issue #589(仍为打开状态),因此这个短语继续作为触发tdd的有效词。你实际得到的是 red → green,重构交给 code-review。
Q2:它让我选测试接缝,我却完全不知道选哪个。
这是对该技能反馈最多的一处摩擦(upstream issue #607)。提示只按名称列出候选接缝,对每个接缝能捕捉什么、会漏掉什么只字未提,你等于在几个标签之间做选择。目前还没有修复方案。实用的变通办法是:在回答之前,先让 Agent 说明取舍——组件级接缝会漏掉哪些集成级接缝能捕捉的东西,以及集成级会慢多少。这也是链路要在to-spec阶段就预先约定接缝的原因——在to-spec里你能看到整个功能的全局,而不是面对一条孤立的提示。
Q3:技能说先红,它却先写了实现。
确实会发生。原文档记载了一位用户追问模型后得到的异常诚实的回答:"我知道技能说了'一次一个测试,看着它因正确的原因失败'。我读了。我只是默认回到了我的日常习惯。"这个技能就是为此而写的:没有任何指令能让 Agent 100% 遵守,把要求逼得更紧只会以很小的收益限制 Agent 的创造力;即使没有被严格遵循,这条循环仍然值得跑,因为整体结果仍然更好。如果某个特定切片必须严格遵循,那就盯着运行过程,而不是指望技能强制执行。
Q4:应该先写浏览器或端到端测试吗?
通常不应该,而且技能不会阻止你。原文档记录了一个真实案例:用户让 Agent 先写 Playwright 测试,结果在功能根本还不存在时,花了很长循环反复重跑、并错误地断定是测试坏了。请在仓库的CLAUDE.md里配置这一点。浏览器测试慢到让 red-green 反馈循环不再划算;应在行为可用之后再写它们。
Q5:/tdd会取代/implement,或课程里的/do-work吗?
不会。/tdd记录方法论;/implement是一个极简的"工作 → 反馈 → 提交"循环,是/do-work的直接替代品。课程中单一的/do-work步骤现在被拆到了/implement、/tdd和/code-review三个技能里。如果问"对一个 ticket 该跑哪一个",答案几乎总是/implement。
Q6:deep-modules 和接口设计指导去哪里了?
在 v1.0 中收进了 codebase-design,被泛化成多个技能共享的一套词汇。refactoring.md也在同一时间离开;重构现在是 code-review 的职责,那个技能带走了 Fowler 的坏味道基线。
Q7:它知道我的其他 ticket 吗?
不知道。对着一个 ticket 运行,它会乐意提出属于兄弟 ticket 的工作,因为它看不到 issue 图的其余部分(upstream issue #129)。Matt 的立场是这不是tdd的职责。把 spec 与 ticket 一起传给它会有帮助;先把 ticket 切分得大小合适,帮助更大。
九、怎样算"起作用了":可验证的成功清单
原文档给出了明确的验收标准,完整继承:
- 在任何一个测试文件存在之前,它停下来、点名它打算测试的接缝,然后等待。
- 一次出现一个测试:变红、拿到恰好够用的代码使其通过,然后才出现下一个测试——而不是"一批测试 + 一批代码"。
- 测试名读起来像能力("user can checkout with valid cart"),而不是像内部结构("checkout calls paymentService.process")。
- 断言里的期望值是能追溯到 spec 的字面量,而不是用代码的方式重算出来的值。
- 重命名一个内部函数,套件里什么都不碎。
- Mock 只出现在外部边界(支付 API、时钟),从不围绕你自己的模块。
十、它在哪里:构建步骤中的"引擎"而非独立环节
tdd是主链构建步骤内部的引擎,而不是独立的一步:
grill-with-docs → to-spec → to-tickets → implement → code-review各环节的分工(均有仓库源码佐证):
- to-spec在写任何代码之前预先约定测试接缝。其流程第 2 步明确要求:"草拟你将在其上测试该功能的接缝。优先采用既有接缝,使用尽可能高的接缝,理想数量是一个,并与用户确认这些接缝符合预期。"
- implement按 ticket 驱动
tdd。其正文第一句就是Use /tdd where possible, at pre-agreed seams.,并规定"定期跑类型检查、定期跑单个测试文件、最后跑一次完整测试套件",完工后用/code-review评审再提交。 - code-review事后检查是否只用了约定的接缝,并接管
tdd不再做的重构。它的双轴评审(Standards / Spec)保证了"只用了约定接缝"这件事被独立核查。 - 另一个邻居 codebase-design是
tdd所讲的接缝与深模块词汇的共同来源。
此外,tdd也可以单独使用:只要有一个具体行为要构建、且没有完整的 spec 在案。当你拿不准该用哪个技能时,ask-matt 负责路由。
十一、落地建议:把这条循环带进你自己的仓库
综合原文档与仓库源码,以下是可直接执行的落地清单:
- 判断是否值得跑循环:先确认变更存在"独立于实现的真值来源"(spec、已知正确的字面量、手算例子)。没有它,宁可先走 to-spec。
- 先约接缝,再写测试:无论单独用
/tdd还是在implement驱动下,都坚持"点名接缝 → 用户确认 → 才写测试"的次序;接缝尽量少,理想情况只有一个。 - 垂直切片推进:一次一个"接缝 + 测试 + 最小实现",让第一个测试当曳光弹。
- Mock 只放在系统边界:外部 API、时间、随机性、必要时文件系统/数据库;用依赖注入和 SDK 风格接口让边界易于替换。
- 把"浏览器测试后置"写进
CLAUDE.md:避免慢反馈循环吃掉 red-green 的价值。 - 重构交给 code-review:实现阶段只管 red → green,重构作为独立会话由 code-review 承接。
这套循环的价值不在于字面意义上的"严格 TDD",而在于它持续产出能挺过重构、有独立真值来源、只在系统边界使用 Mock 的测试——这正是原文档在"反模式与成功清单"两节中反复强调的底线。
【免费下载链接】skillsSkills for Real Engineers. Straight from my .agents directory.项目地址: https://gitcode.com/GitHub_Trending/skills13/skills
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考