第三课结束后,我们已经有了一份正式 spec 和一组纵向 tickets。每张票都能在一个新的 Agent 上下文里完成,也提前确认了测试应该落在哪个公开 seam。
终于可以开始写代码了。
但这里又有一个熟悉的风险:把 ticket 丢给 Agent,十几分钟后得到几十个文件的 diff、几十条“已完成”说明,以及一组从来没有真正失败过的测试。
代码看起来很多,证据却很少。你不知道测试是在约束需求,还是在给现有实现补一张通行证。
第四课要解决的是实现纪律:让 /implement 按已经确认的 ticket 推进,并让 /tdd 把每一个行为锁进 Red -> Green 的小反馈环。一次只实现一条能力,先看到正确的失败,再写刚好够用的实现。
/implement 和 /tdd 分别负责什么
这两个 skill 不是同一级别的两个步骤。
/implement 负责完成整张 spec 或 ticket
└── /tdd 负责其中每一条行为的 Red -> Green 循环
/implement 是实现阶段的总控。它读取已经确定的工作,使用预先确认的 testing seams,持续运行类型检查和当前测试文件,结束时运行完整测试套件,再调用 /code-review,最后把结果提交到当前分支。
/tdd 是它内部的开发引擎。它不决定需求,也不重新拆票,只围绕一个具体行为反复执行:
写一个测试 -> 看到它正确失败 -> 写最小实现 -> 看到它通过 -> 下一条行为
如果没有完整 spec,只想对一个明确行为进行 test-first 开发,也可以直接使用 /tdd。但在这套团队流程中,默认由 /implement 驱动它。
开始前,先检查四个前提
第一,只拿一张当前 frontier 上的 ticket。它的 blocker 必须已经完成。不要把三张票同时塞进一个上下文,否则第三课精心设计的交付边界会被重新揉成一个大任务。
第二,ticket 要有明确验收条件。比如“个人月度订阅用户可以停止续订,并看到权益结束日期”,而不是“开发订阅相关功能”。
第三,testing seam 已经在 spec 阶段确认。对于停止续订,团队可能决定通过现有 Subscription API 发起操作,再通过同一公开 API 读取状态。第四课不应该为了测试方便,临时改成直接调用内部 repository。
第四,当前分支可以安全提交,并且工作区中的其他改动归属明确。/implement 完成后会 review 并 commit,不要让一张 ticket 顺手带上无关修改。
准备好后,在 Agent 对话框运行 /implement,并告诉它要执行的 ticket 或 spec 引用。例如:
/implement
实现 Ticket 01:个人月度订阅用户可以停止续订。
来源:#318
只处理这一张票,沿用 spec 已确认的 Subscription API seam。
如果使用本地 tracker,也可以传入对应的 .scratch/.../issues/01-....md 文件。
第一步:把整张 ticket 再切成行为顺序
ticket 是一个完整的纵向交付,但通常仍然包含多个可以逐步验证的行为。
“个人月度订阅用户可以停止续订”至少可能包含:
- 符合条件的用户可以成功停止续订;
- 返回结果包含固定的权益结束日期;
- 再次提交相同请求不会产生第二次状态变化;
- 企业套餐不允许使用这个入口;
- 已进入支付处理中的本次扣款不被撤销。
不要先把五个测试全部写出来。那叫横向批量测试:测试描述的是 Agent 预想中的系统,而不是上一轮实现真正教会我们的行为。
正确做法是先选择最窄的一条 tracer bullet,例如第一条成功路径,跑通以后再决定第二个测试需要什么。
第二步:先确认 seam,再写任何测试
/tdd 有一条硬规则:没有确认的 seam,不写测试。
seam 是测试观察公开行为的边界。它可以是 HTTP API、公开领域服务、CLI 命令或页面交互,但不能只是为了方便而暴露的私有方法。
可以先让 Agent 明确写下来:
本 ticket 使用的测试 seam:Subscription HTTP API
发起行为:POST /subscriptions/{id}/stop-renewal
观察结果:GET /subscriptions/{id}
这两个端点只是示例,实际项目必须使用 spec 已经确认的入口。重点是测试通过用户或调用方可见的接口完成验证,不绕过接口直接查询数据库。
判断 seam 是否合格,可以问一句:如果我们彻底重写内部实现,但公开行为没变,这组测试还需要修改吗?如果答案是“大量修改”,测试大概率绑住了实现细节。
第三个动作:写下第一个会失败的行为测试
测试名应该像规格,而不是像调用记录。
下面是一段示意代码:
test("paid user can stop renewal until the end of the current period", async () => {
const subscription = await fixtures.paidSubscription({
accessEndsAt: "2026-08-31",
});
await subscriptions.stopRenewal(subscription.id);
const result = await subscriptions.get(subscription.id);
expect(result).toMatchObject({
renewalStatus: "stopped",
accessEndsAt: "2026-08-31",
});
});
它测试的是调用方关心的结果:续订状态改变,权益结束日期不变。它没有断言某个内部 handler 调用了几次,也没有检查某张表的字段。
测试中 2026-08-31 是已知的输入和预期值,来源独立于实现。不要在测试里重新运行一遍生产代码的计算逻辑来生成 expected,否则实现和测试可能用同一种错误算法一起通过。
Red 不是“有任何错误都算失败”
写完测试后,只运行当前测试文件,确认它失败。
真正有价值的 Red 必须因为目标行为尚未实现而失败。例如返回状态仍是 active、公开接口不存在,或者权益结束日期被错误清空。
下面这些不算有效 Red:
- 测试文件有语法错误;
- 测试数据库没有启动;
- fixture 名字拼错;
- import 路径不存在;
- 断言根本没有执行到。
如果失败原因不对,先修测试环境,直到它准确指向缺失行为。否则后面的 Green 只证明你修好了测试,不证明功能成立。
培训时建议保留一段最小证据:
Test: paid user can stop renewal until the end of the current period
Red: expected renewalStatus "stopped", received "active"
这比一句“测试先写了”更可信。
Green 只写刚好能通过的代码
看到正确 Red 后,Agent 才能开始实现。
“最小实现”不是写死返回值,也不是故意写一段低质量代码。它的含义是:只实现当前测试要求的真实行为,不提前支持未来可能出现的套餐类型、退款策略和通知渠道。
第一轮只需要让个人付费订阅的续订状态正确改变,并保持当前权益结束日期。企业套餐校验、重复请求和支付处理中状态,留给各自的下一轮测试。
写完后重新运行同一个测试文件。通过以后,再运行相关类型检查。然后才进入下一条行为:
Cycle 1:成功停止续订 Red -> Green
Cycle 2:重复请求保持幂等 Red -> Green
Cycle 3:企业套餐被明确拒绝 Red -> Green
Cycle 4:支付处理中不撤销本次扣款 Red -> Green
每一轮都由前一轮获得的信息驱动,而不是一开始就设计出一套庞大抽象。
这套 TDD 为什么没有把 Refactor 放进循环
很多人熟悉的是 Red -> Green -> Refactor,但这套 skill 明确规定:实现循环只负责 Red -> Green,重构放到 review 阶段。
原因很现实。Agent 在红灯阶段同时重构,很容易把“修复当前行为”和“调整整体设计”混在一起。失败原因变多,diff 范围扩大,定位成本也会上升。
所以本课的纪律是:
Red 时:只让失败指向当前行为
Green 时:只让当前行为通过
设计异味:先记录,留到 /code-review 判断和处理
这不是反对重构,而是把重构放到证据更完整、全套测试已经绿色的时候进行。
好测试保护行为,不监视内部过程
下面两类测试看起来很认真,实际很脆弱。
第一类是实现耦合:
test("stopRenewal calls billingRepository.update once", async () => {
await stopRenewal(subscriptionId);
expect(billingRepository.update).toHaveBeenCalledTimes(1);
});
用户不关心 repository 调用了几次。只要内部从数据库改成事件存储,测试就会失败,即使行为完全没变。
第二类是绕过公开接口验证:调用 API 创建状态,然后直接查数据库确认字段。这样会跳过正常读取逻辑,也无法证明调用方真的能看到正确结果。
更好的测试始终从同一个公开世界进入和离开:通过 API 发起停止续订,再通过公开查询读取最新状态。
Mock 只应该出现在系统边界
不是所有依赖都要使用真实环境。支付平台、邮件服务、系统时间、随机数和部分文件系统操作,确实适合在边界处 mock。
但不要 mock 自己控制的内部类和模块。过多内部 mock 会把测试变成“我期望自己的代码按我刚写的方式互相调用”,而不是“用户行为真的成立”。
常见原则是:
- 外部支付、邮件、第三方 API:可以 mock;
- 时间和随机数:使用可注入的固定实现;
- 数据库:条件允许时优先测试数据库;
- 自己的 service、repository、domain object:尽量走真实路径;
- 不要断言内部调用顺序和次数,除非它本身就是公开契约。
如果某个外部 API 需要 mock,优先封装成具体的 SDK 风格接口,例如 chargePayment()、cancelPayment(),不要只暴露一个万能 fetch(endpoint, options)。具体接口的返回形态更清楚,测试也不需要在 mock 里再写一套条件路由。
/implement 期间应该怎样运行检查
只在最后跑一次所有检查,反馈太慢;每改一行都跑完整套件,又会拖慢循环。
/implement 给出的节奏是:
每个循环:运行当前单个测试文件
实现过程中:经常运行类型检查
ticket 完成:运行一次完整测试套件
全部绿色:调用 /code-review
评审处理完成:提交到当前分支
单文件测试负责快速反馈,类型检查负责尽早发现接口漂移,完整套件负责确认没有跨模块回归。三者作用不同,不能用其中一个替代另外两个。
怎样记录一条可回放的实现证据
团队不需要保存每次终端输出,但每张训练 ticket 至少保留下面这张表:
| Cycle | 行为 | Red 失败原因 | Green 证据 |
|---|---|---|---|
| 1 | 个人用户停止续订 | 状态仍为 active | 单测试文件通过 |
| 2 | 重复请求保持幂等 | 产生第二条状态事件 | 单测试文件通过 |
| 3 | 企业套餐不可操作 | 请求被错误接受 | 单测试文件通过 |
| 4 | 不撤销处理中扣款 | 支付被标记撤销 | 单测试文件通过 |
这份记录的价值不是审计 Agent,而是让评审者知道每个行为都曾经独立失败,并由一段针对性的实现变绿。
常见误区
Agent 先写实现,再补测试。 删除或搁置实现,回到尚未具备该行为的状态,先得到有效 Red。天然通过的测试不能证明它有约束力。
一次写完所有测试。 改回一个测试、一个实现的纵向循环。后面的测试应该吸收前一轮学到的信息。
测试私有函数最方便。 方便不等于稳定。回到 spec 确认的公开 seam,不为测试新增内部入口。
Red 是环境报错。 先修测试环境,直到失败准确描述缺失行为。
expected 用生产算法重新算了一遍。 改用 spec、固定示例或已知正确字面值作为独立真值。
所有依赖都 mock。 只在系统边界 mock。内部模块走真实代码路径,数据库优先使用测试实例。
Green 时顺手设计通用框架。 当前测试没有要求的参数、扩展点和抽象先不加,留给真实需求或 review。
红灯时同时重构。 先让行为变绿。结构问题记录下来,在第五课的 Standards 评审中单独处理。
第四课真正训练的是“让每次改动都有证据”
AI 写代码快并不是问题。真正的问题是,它可以在没有反馈的情况下连续猜很多步。
Red -> Green 把这个过程切短了:一条公开行为、一条先失败的测试、一段最小实现、一次明确通过。Agent 每向前走一步,都要先接受系统给出的反馈。
当 ticket 完成、完整套件变绿时,我们还不能只看“能不能跑”。第五课会用 /code-review 继续回答两个不同的问题:代码是不是按这个项目的标准写的,以及它到底有没有实现原始规格。