vertical-tdd:一条测试等于一个用户故事

📅 2026/8/8 6:42:42
vertical-tdd:一条测试等于一个用户故事
你让 Agent 实现功能并让它带上测试它会生成所有 service 的单元测试覆盖率 90%一片绿。你把 service 和 repository 之间的一个参数名改了——没有一条测试变红。覆盖率 90% 和改了代码测试不动同时成立说明这批测试度量的东西跟你关心的东西无关。它们确实在跑也确实绿只是绿的地方不在你改的地方。水平切只测到 mockAgent 没偷懒它选了一条最省力的路。水平切好批量产出一次能交出所有 service 的测试每一层又都能 mock 掉下一层service 测试里 mock 掉 repositorycontroller 测试里 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-tddname: vertical-tdddescription: 写代码、实现功能时使用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 APIStripe、SendGrid、S3外部世界Mock / Stub系统时间 / 随机数 / UUID外部世界注入 / Fake外部消息队列Kafka、RabbitMQ broker外部世界In-memory fake判据删掉所有 mock测试还能跑吗不能 mock 在内部 违反 Hard Gate 2。Context Pointersdocs/相对项目根目录references/相对本 skill 目录。docs/feature-or-bug/spec.mdgrill-before-coding 产出含## Slices段[ ]/[x] pointer是本 skill 的直接输入实现后回写。docs/feature-or-bug/plan.mdgrill-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。