你让 Agent 实现功能并让它带上测试,它会生成所有 service 的单元测试,覆盖率 90%,一片绿。你把 service 和 repository 之间的一个参数名改了——没有一条测试变红。
覆盖率 90% 和"改了代码测试不动"同时成立,说明这批测试度量的东西跟你关心的东西无关。它们确实在跑,也确实绿,只是绿的地方不在你改的地方。
水平切只测到 mock
Agent 没偷懒,它选了一条最省力的路。水平切好批量产出,一次能交出"所有 service 的测试";每一层又都能 mock 掉下一层,service 测试里 mock 掉 repository,controller 测试里 mock 掉 service,于是没有一条测试需要碰真实集成。
这样每条测试只证明了一件事,我正确地调用了下一层的 mock。它没有证明用户能完成任何事,也没有证明两层真能拼在一起。改个参数名不红,因为测试从没让这两层握过手;覆盖率照样很高,它度量的只是代码被执行过。
Evans 有一句话可以直接当判据用:
代码是模型的表达,改变某段代码就改变了相应的模型。
反过来读,改了代码而测试不红,测试就没有在表达模型。它表达的是一堆 mock 之间的礼貌寒暄。
测试该证明用户能做成一件事
软件的核心是其为用户解决领域相关的问题的能力,因此测试要证明的是用户完成了一个领域动作,而不是某个函数返回了正确类型。粒度也跟着用户走,只有对用户最重要的部分才值得细测;Agent 把粒度均匀撒在每一层,撒得最厚的那层恰好是 service,而用户从不关心你的 service 层。
把这把尺子变成可执行的约束,就是一条 vertical slice。它从用户入口发起,穿过真实的 service、真实的 repository、真实的 test database,回到用户看得见的响应,一条测试对应一个用户故事。mock 只允许出现在系统边界:第三方 API、系统时间、随机数、外部消息队列。删掉所有 mock,测试还能跑吗?跑不动,说明 mock 已经长进了你自己的模型,而不是留在边界上。
单位换了,测试名也得换。test_user_exports_cargo_as_csv说的是领域动作,test_export_service_returns_200说的是实现细节。产品能读懂测试名,才说明 grill 锁定的词汇真的进了测试。
红和绿必须是两次运行输出
纵切之外,Agent 最爱省略的动作是让测试红一次。它通常先写测试,紧接着把实现写出来,一次提交,测试从未真正失败过。这样的测试很可能是照着实现反写的,而一个从没红过的测试,你无法证明它能捕获错误。
所以红和绿要落成两次可观察的状态,两次运行输出。先写测试,运行,看它因为"功能未实现"而红;再写最小实现,运行,看它绿。少了红那一次,TDD 就退化成给已有实现补一张合格证。一次也只让一条 slice 红,Agent 常常摊开三条 slice 的测试,第一条实现到一半跳去实现第二条,最后没有一条完整路径是绿的。一条走完红、绿、整理,才碰下一条。
代价是跑得慢,红了也不说哪层坏
这套约束的代价该摆在桌上。走真实的层加真实的 test database,一条测试比 mock 版慢得多;它红的时候只告诉你"用户这件事做不成",不告诉你哪一层坏了,定位得自己往下挖。有人因此宁愿要一堆毫秒级的单元测试,红一条就知道坏在哪个函数。
这笔钱换来的是唯一能拦住"修了这个、错了那个"的网。单元测试测的是实现细节,改一层就碎一片,碎得多了谁也不敢动;纵切测的是用户动作,只要用户还能完成这件事,它就是绿的,改任何一层的回归都立刻在这里显形。慢和难定位也都有办法压,系统边界该 mock 就 mock,时间、随机数、第三方 API 注入 fake,真正慢的只剩数据库那一段;slice 切得越小,红了要挖的范围也越小。
测试守行为,形状得另找人守
纵切绿了只说明行为对,代码可以又脏又绿。Agent 写实现时如果没有风格锚,会跟着身边最脏的那段代码依样画瓢,或者被一个歧义错词、一段混杂的会话上下文带偏,然后拿"先写烂,绿了再重构"安慰自己。这笔账后付更贵:脏代码越多,重构成本越高,大量低质量从指缝漏过,最后交出来的东西没人看得懂——这正是很多人对 LLM 代码的第一句吐槽。
所以写最小实现的当下就照一份正向风格写,用领域词汇命名,只写这条 slice 需要的东西,让人从上往下读得懂。"傻瓜都能写出计算机可以理解的代码。唯有能写出人类容易理解的代码的,才是优秀的程序员。“重构留给绿之后才浮现的结构问题,跨 slice 的重复、意外的耦合,而且要由 review lens 发现问题才动手,不由"我觉得该重构了"触发。写的时候守住风格,重构那一步大半时候会"无发现”,这正是它该有的样子。
进度落在文件里
vertical-tdd 不凭空开始,它的输入是 grill-before-coding 落盘的spec.md。## Slices段里每条 slice 已经用锁定的领域词汇命名、按依赖排好序,实现阶段直接取用,不重新分解也不重新命名。没有 spec 就停下来,先回 grill,不猜、不假设。
一条 slice 绿完,进度同样回写进文件,spec 里的[ ]画成[x],plan 里补上真实改到的模块路径。行为提交和整理提交分开,读 log 就能分清谁改了行为、谁只动了结构。两个 skill 靠文件交接,不靠会话记忆——会话会滚出上下文,文件还在仓库里。
注:本 skill 的设计遵循 write-skill。skill 不是教条,是为了铲除一串坏行为:水平切逃避集成、绿-绿-绿假装测试、下笔即泥球让人看不懂。一旦坏行为消失,skill 就该停止维护。
开源地址
vertical-tdd
name: vertical-tdd
description: 写代码、实现功能时使用;grill-before-coding 产出 spec 后进入。
每条测试是一条vertical slice:从用户入口到 DB 到响应的完整领域动作。
红→绿→review+重构,一条 slice 绿了才开始下一条。这样任意时刻,已绿 slice 数量 = 已交付用户价值,用户随时可问"现在能跑通什么"。
Hard Gate(违反任何一条 = 本次输出作废)
- 一条未绿,不开下一条。第一条 vertical slice 未绿之前,禁止写第二条 slice 的测试、实现、甚至伪代码。
- 自己的层之间真实调用。service 调 repository、controller 调 service 一律真实。Mock 只出现在系统边界,判定见
## Mock 判定。 - 红和绿是两次输出。先输出测试运行结果(红),再输出实现后的运行结果(绿)。禁止"应该能过"——运行它。
- 测试名是领域动作,不是实现描述。用
test_[user]_[domain_verb]_[domain_object],如test_user_exports_cargo_as_csv;出现test_export_service_returns_200这类实现描述 = 重写。 - 行为提交和整理提交分开。绿的提交只改行为,重构的提交只改结构。
- 重构由 lens 发现触发,不由"我觉得该重构了"触发。无发现 = 不重构,直接下一条。
输入
docs/开头的路径相对项目根目录(被改的那个代码仓库);references/开头的相对本 skill 目录。
docs/<feature-or-bug>/spec.md,由 grill-before-coding 产出。必须包含## Slices段(每条带[ ]标记)。docs/<feature-or-bug>/plan.md(可选),由 grill-before-coding 产出。- 若无 spec 文件:停下来。先 reach 到
grill-before-coding。不猜测,不假设。
步骤
1. 选 slice
- 从 spec 的
## Slices段中,选取尚未实现的第一条。顺序由 grill 阶段已确定,不重新排序。 - 格式
[用户] [领域动词] [领域对象],直接使用 spec 中的命名。 - 只选一条,不列后续计划。
2. 写测试(红)
- 从用户入口发起,断言用户可观察的结果——一条测试对应一个用户故事,不是一个函数。
- 内部调用走真实路径,用真实 test database。
- 运行。输出红色结果。
- Done when: 测试存在、运行、失败,且失败原因 = “功能未实现”。
3. 最小实现(绿)
- 写让测试通过的最少代码。"最少"度量认知,不是行数。
- 下笔即照
references/coding-style.md写:领域词汇命名、只写这条 slice 需要的、让人从上往下读得懂。不参考身边最脏的代码,不被歧义错词带偏。 - 运行。输出绿色结果。
- Done when: 该 slice 测试绿,无其他测试变红,命名用领域词汇而非
data/result/temp。
4. Review + 重构(整理)
- 打开
references/review-lenses.md,逐条过 Lens 1–6 + 对应 Smell。 - 对每个 lens 输出:
[Lens N] 发现:...或[Lens N] 无发现。 - 命中 smell → 打开
references/refactoring-techniques.md找对应手法,按步骤执行,每步后运行测试。 - 重构只改结构,不改行为。
- Done when: 6 个 lens 都有明确输出,命中的 smell 已用对应手法修复,全部测试绿。
5. 提交
- 行为提交(步骤 3)必有;整理提交(步骤 4)仅在实际重构了才有。
- Commit message 使用领域词汇。
- Done when: 行为与整理未混在同一个提交里。
6. 回写记录
- spec:将当前 slice 的
[ ]改为[x],并在行尾加 pointer→ plan#N(若有 plan)。 - plan(若存在):在当前 slice 对应的
模块:行补入实际修改的文件路径,如模块:src/order/service.ts, src/order/repository.ts。 - Done when: spec 已画勾,plan(若有)的模块位置是真实路径而非 “TBD”——进度落在文件里,不依赖会话上下文。
7. 下一条 slice
- 回到步骤 1。已完成的 slice 不可回退(除非用户要求)。
- Done when: spec 的
## Slices中所有领域动作都有对应的绿色 vertical slice。
8. 最终确认(全部 slice 完成后)
- 运行完整测试套件,输出结果。
- 运行 typecheck(若项目有),输出结果。
- 向用户演示已绿 slice 列表 = 已交付用户价值。
- 全绿后 reach 到
code-review做全量审查。 - Done when: 全绿、typecheck 通过、已向用户演示,且已移交 code-review。
Mock 判定
| 依赖 | 判定 | 做法 |
|---|---|---|
| 自己的 service / repository / model / domain | 我的模型 | 真实调用。禁止 mock。 |
| 自己的 DB | 我的模型 | 真实调用,用 test database |
| 第三方 HTTP API(Stripe、SendGrid、S3) | 外部世界 | Mock / Stub |
| 系统时间 / 随机数 / UUID | 外部世界 | 注入 / Fake |
| 外部消息队列(Kafka、RabbitMQ broker) | 外部世界 | In-memory fake |
判据:删掉所有 mock,测试还能跑吗?不能 = mock 在内部 = 违反 Hard Gate 2。
Context Pointers
docs/相对项目根目录,references/相对本 skill 目录。
docs/<feature-or-bug>/spec.md:grill-before-coding 产出,含## Slices段([ ]/[x]+ pointer),是本 skill 的直接输入,实现后回写。docs/<feature-or-bug>/plan.md:grill-before-coding 的可选产物,实现后补入模块位置。references/coding-style.md:步骤 3 下笔时的正向风格,预防泥球,区别于事后重构。references/review-lenses.md:步骤 4 的 review 判定标准(6 lens + 12 smell)。code-review 和 refactor 共用同一份。references/module-design.md:接口深度的设计语言与判据(深 vs 浅、删除测试、可测性)。步骤 3 设计接口、步骤 4 判定 Lens 2 时经 coding-style / review-lenses 转达到此。refactor 共用同一份。references/refactoring-techniques.md:步骤 4 命中 smell 后的执行手法。refactor 共用同一份。grill-before-coding:产出 spec 和 plan。无 spec 时先 reach 到它。code-review:全部 slice 绿后的全量审查;也用于 review 非 TDD 流程产出的代码。refactor:事后重构。用户要求"整理一下"或 code-review 发现问题时 reach。