vertical-tdd:一条测试等于一个用户故事
2026/8/8 6:42:34 网站建设 项目流程

你让 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(违反任何一条 = 本次输出作废)

  1. 一条未绿,不开下一条。第一条 vertical slice 未绿之前,禁止写第二条 slice 的测试、实现、甚至伪代码。
  2. 自己的层之间真实调用。service 调 repository、controller 调 service 一律真实。Mock 只出现在系统边界,判定见## Mock 判定
  3. 红和绿是两次输出。先输出测试运行结果(红),再输出实现后的运行结果(绿)。禁止"应该能过"——运行它。
  4. 测试名是领域动作,不是实现描述。test_[user]_[domain_verb]_[domain_object],如test_user_exports_cargo_as_csv;出现test_export_service_returns_200这类实现描述 = 重写。
  5. 行为提交和整理提交分开。绿的提交只改行为,重构的提交只改结构。
  6. 重构由 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。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询