TDD 与 Coding Agent:从失败测试到正确实现
用订单取消与库存释放串起 Red、Green、Refactor,理解测试如何推动接口设计,以及 TDD 与 SDD 怎样协作。区分状态规则、数据库并发与跨服务幂等,避免把测试通过当成业务正确的保证。
让 Coding Agent 写一个订单取消接口,再让它补测试,可能很快就能得到一份“测试全部通过”的报告。但这里有个问题:测试里的预期结果,到底来自业务规则,还是来自 Agent 刚写好的代码?
假如实现每收到一次取消请求就释放一次库存,随后补出的测试只检查“取消后订单状态正确”,重复释放的错误会被完整地留在系统里。测试并没有撒谎,它验证的范围太窄了。
上一篇 SDD:把模糊需求编译成 Coding Agent 能执行的开发流程讨论了怎样保存需求、约束和技术方案。这篇接着往实现阶段走:当规则已经比较清楚,如何让 Agent 一小步一小步地实现,并且及时发现它偏离了预期?
TDD 提供了一种工作方式。先选一个行为,写出能执行的测试,确认它在当前实现上失败,再修改代码让它通过,最后在测试保护下整理设计。文章用“订单取消只能触发一次库存释放”贯穿这个过程;也会解释为什么这个例子的一份单元测试,不能替代数据库和库存服务的验证。
一、TDD 不只是把测试写在代码前面
TDD 是 Test-Driven Development,测试驱动开发。Kent Beck 在 Canon TDD中强调的顺序是:列出需要覆盖的场景,选择一个具体测试,实现使它通过的代码,必要时重构,再选择下一个场景。场景清单可以随着理解变化;它不是一次写完全部测试、最后集中实现的瀑布流程。
常见的三个阶段叫 Red、Green、Refactor:Red 是测试因尚未满足的行为而失败,Green 是实现让相关测试通过,Refactor 是保持行为不变地改善设计。这三个阶段反复交替,开发者持续观察反馈。Martin Fowler 的 TDD 概述也将重构放进这个循环,并指出先写测试有助于在实现之前考虑接口的使用方式。
它与“开发完补测试”的差别,不只在文件保存顺序。测试先提出一个要求,实现再回答它。开发者必须先想清楚调用者如何使用接口、输入有什么边界、输出凭什么算正确。若方法的输入要准备十几种无关对象才能测试,或者结果只能靠读私有字段判断,测试过程也会暴露接口设计的问题。
不过,测试先写也可能没有驱动任何设计。比如一次生成两百行实现和两百行测试,再把测试文件排在前面;或者测试预期直接复制实现的返回值。这些操作看起来符合“先写测试”,却没有经历有意义的失败,也没有利用反馈决定下一步。
本文把 TDD 用在 Coding Agent 上,是一种工程实践建议,并不意味着已有证据证明它能消除 Agent 的错误。它有用的地方,是把一次大范围修改拆成较小、可观察的行为变化,让人更容易发现错误出在哪一步。
图中的停止分支很重要。依赖没有下载、测试没有被发现、数据库连不上,都需要处理,但它们不能说明目标业务行为已经被测试捕获。只有搞清楚失败原因,才知道接下来应该修改测试环境还是业务代码。
二、先确定“只能释放一次”究竟是什么意思
“订单取消只能释放一次库存”听上去很清楚,真正开始写代码时仍然有很多空白:订单支付后还能取消吗?重复取消算错误还是成功?系统超时后客户端重试,应该得到什么结果?如果取消状态写成功,库存调用失败了,下一次请求应该如何处理?
这里采用一组示例规则,方便讨论。这是文章的教学模型,不代表某家公司真实的订单系统:
| 当前状态或场景 | 期望行为 | 库存释放责任 |
|---|---|---|
| 待支付订单首次取消 | 转为已取消 | 新增一项释放预占库存的意图 |
| 已取消订单再次取消 | 保持已取消,按幂等成功处理 | 不新增释放意图 |
| 已支付订单请求普通取消 | 拒绝这条取消路径 | 不产生释放意图,退款另走流程 |
| 订单不存在、调用者无权限 | 返回对应错误 | 不进入状态转换 |
| 取消与支付并发发生 | 只能有一个合法状态转换获胜 | 依据最终转换结果处理 |
“释放意图”是一个需要后续执行的业务动作,不等于库存已经恢复。将两者分开,是因为订单状态和库存可能由不同服务管理。订单侧需要确保该产生的动作不丢失;库存侧需要确保重复收到同一个动作不会重复增加可售量。
因此,目标至少分成三个可以独立检查的要求。第一,状态规则不允许重复取消生成新的释放意图。第二,多请求、多进程情况下,同一订单的取消转换只能提交一次。第三,消息重试时,同一笔预占库存只能有效释放一次。只写第一个要求的单元测试,不能宣称后两个也得到了保证。
订单不存在、权限校验、退款规则都重要,但本文的第一轮测试不把它们挤进同一个用例。我们先研究状态转换,再把存储和外部副作用接回来。场景清单保留这些事项,避免小步实现变成永久遗漏。
这个划分也是 SDD 能提供帮助的地方:先在规格中确定哪些行为属于本次功能,再讨论怎样验证。需求没有确认时,测试中的 expected 只是一个猜测。TDD 无法替业务负责人决定支付后的退款规则。
三、Red:让错误在具体断言上暴露
为了看清规则,先把取消决策表达为一个函数。输入订单状态,输出新的状态和是否需要生成库存释放意图。它不读取数据库,也不发送消息。下面使用 Java 17+ 的 record 表达结果,测试采用 JUnit Jupiter 的写法;片段用于说明设计,不是完整项目。
enum Status { PENDING_PAYMENT, CANCELLED, PAID }
record Cancellation(Status nextStatus, boolean releaseReservation) {}
第一个场景可以是首次取消待支付订单。测试要求返回已取消状态,并要求产生释放意图。先实现这一个场景,下一轮再处理重复请求。下面展示的是第二轮增加的回归用例:
@Test
void repeatedCancellationDoesNotCreateAnotherReleaseIntent() {
Cancellation first = decideCancellation(Status.PENDING_PAYMENT);
Cancellation repeated = decideCancellation(first.nextStatus());
assertEquals(Status.CANCELLED, first.nextStatus());
assertTrue(first.releaseReservation());
assertEquals(Status.CANCELLED, repeated.nextStatus());
assertFalse(repeated.releaseReservation());
}
这里没有断言“内部必须调用哪个私有方法”。它关心调用者能够观察到的决策结果:第一次有动作,第二次没有新动作。测试还明确地使用首次决策后的状态,避免第二次仍然输入待支付状态,却误以为自己覆盖了重复取消。
假设当前实现是:
static Cancellation decideCancellation(Status status) {
return new Cancellation(Status.CANCELLED, true);
}
它满足首次取消,却无法满足新增场景。重复调用返回的 releaseReservation 仍然是 true,最后一个断言会失败。这就是本轮需要的 Red:错误实现和业务预期之间的冲突,有一个明确的落点。
实际开发时,Agent 应执行仓库已有的测试命令,并报告失败用例与断言。上面展示的是代码层面的推演;判断实际项目中的 Red 是否有效,应查看真实输出,而不是只看“测试失败”四个字。
有几个常见误区值得区分。如果方法还不存在,编译失败可以是创建接口时的起点,但随后仍要确认断言能够检验目标行为。如果测试框架没有发现这个测试,即使命令退出成功,也没有获得证据。如果测试调用了取消方法却没有任何断言,它通常只能说明代码没有抛出异常。如果用宽泛的 assertThrows(Exception.class, ...) 检查业务拒绝,还可能把空指针等意外异常当成正确结果。
已有缺陷的修复尤其适合这一步:先让新增测试在修复前失败,再修改实现。否则,一个始终通过的回归测试,很难说明它是否真的能挡住同类问题。
四、Green:实现当前规则,不提前搭整套框架
现在增加已取消状态的分支,让第二轮测试通过:已取消订单返回原状态,且不产生新的释放意图。随后,从场景清单选出“已支付订单拒绝普通取消”,先写针对该行为的失败测试,再添加拒绝分支。
下面是完成这几轮后得到的决策函数,不是要求 Agent 在第一次 Green 就一次实现所有分支:
static Cancellation decideCancellation(Status status) {
return switch (status) {
case PENDING_PAYMENT ->
new Cancellation(Status.CANCELLED, true);
case CANCELLED ->
new Cancellation(Status.CANCELLED, false);
case PAID ->
throw new IllegalStateException("Paid order requires refund flow");
};
}
对应已支付场景的测试,应检查确切的拒绝行为。示例使用标准异常保持片段简短;真实系统通常用明确的领域错误,交由接口层映射成错误码。输入为 null 的处理也需要由接口契约决定,不能把 Java 的意外异常当成业务设计。
“最小实现”容易被误解成“随便写,只要眼前这一个测试通过”。Green 必须保留之前通过的相关测试,也必须遵守既有安全和架构约束。不能为了让重复取消测试变绿,就把首次取消的释放意图也删掉;不能因为单元测试没有覆盖权限,便移除接口原有的授权检查。
同时,没有必要在这个阶段预先引入通用状态机引擎、插件接口和十种取消策略。目前只有三个状态,直接表达规则就足够。等真实需求出现第二种规则来源,或分支增长到难以维护时,再判断是否需要抽象。测试驱动可以推动设计演进,但不会自动给出最好的抽象层次。
这个函数刻意没有“执行库存释放”的职责。测试先问的是状态转换产生什么决策,结果让我们得到一个容易独立验证的规则入口。后续应用服务负责读取状态、提交转换、记录释放动作。这样的分工来自当前问题,也不要求所有业务都拆成相同的函数形状。
五、Refactor:改善设计时,测试保护的是行为
经过几轮实现,规则可能散落在接口、定时取消任务和运营后台里。三处各写一个判断,支付状态增加后就容易只改其中两处。这时,重构可以把共同的取消决策收回一个领域入口,并统一结果与错误的表达。
重构前,先确认相关测试通过。重构过程中尽量不改变外部行为;每完成一小步,再看原有场景是否仍成立。如果同一轮既修改退款规则,又搬迁服务,再替换存储接口,一旦失败,很难判断是需求变化还是搬迁错误。业务变化与结构整理应尽量分开。
测试本身也需要整理。重复的数据准备可以提取辅助方法,含糊的用例名可以改成具体行为。但断言不能因此变弱。如果原来检查“重复取消不产生新动作”,重构后只检查“调用没有抛异常”,即使命令仍然全绿,保护范围也已经缩水了。
这里也能看出测试粒度的影响。若用例要求内部一定调用 loadOrder 两次、先调用某个私有方法,再调用另一个方法,内部结构一调整就会失败。某些交互顺序确实属于契约,例如扣款前必须授权;其他细节只是当前实现路径,不宜全部固定下来。
Fowler 在 Mocks Aren’t Stubs中区分了状态验证与交互验证,也讨论了交互断言对实现的耦合。本文采用的取舍是:业务结果优先用状态或返回值断言;重要的外部调用才检查交互,并明确哪些参数、次数或顺序属于要求。这不是禁止 Mock,也不是宣称某一种测试风格适合所有代码。
对于库存侧的测试替身,记录“同一个释放键收到哪些请求”通常比模拟一串私有方法更有用。不过,替身只具有我们写进去的行为。一个永远成功的库存 Fake,无法证明真实服务在超时、重试和冲突时也这样工作。
六、单元测试变绿之后,并发问题还在
决策函数针对已取消状态不再产生动作,看起来已经解决了重复取消。但线上可能出现两个请求都读到待支付状态,各自算出 releaseReservation = true,然后各自提交一次释放。刚才的串行单元测试无法发现这个问题,因为第二次调用使用的是第一次的结果,没有模拟两个调用者读到同一旧状态。
因此,下一层需要检查数据库如何决定谁获得取消资格。一种常见设计是,在事务中做带状态条件的更新:
UPDATE orders
SET status = 'CANCELLED'
WHERE id = :order_id AND status = 'PENDING_PAYMENT';
应用必须检查更新行数。只有成功将待支付状态转成已取消的请求,才有资格记录新的释放动作。更新零行可能表示已取消、已支付或订单不存在,需要继续按契约区分,而不是统统报告成功。支付路径也要使用相容的条件转换;只约束取消一方,不能保证整个状态机正确。
如果采用事务 Outbox,订单转换与待发送事件应在同一个本地数据库事务里提交。Outbox 是与业务数据共同提交的待发送事件记录;发送器再读取记录并投递。这样避免订单已经取消、进程却在记录释放任务之前崩溃,导致动作永远丢失。这里仍然需要选择实际数据库支持的事务和约束方式,不能只在测试替身上检查一次 save 调用就认为原子性成立。
投递还可能重复:发送器已经发出消息,却在标记成功前退出,重启后再次发送。所以库存服务必须针对同一笔预占的释放动作做持久化幂等。幂等键要标识逻辑操作,例如某个 reservation 的 release,而不能在每次重试时生成新的随机键。
库存侧也不能先写“已处理”,再独立地更新库存。一旦两步之间崩溃,重试可能被挡住,库存却没有恢复。幂等记录与库存变更需要落在相应的原子边界内;具体可用事务、唯一约束或状态条件更新,取决于库存模型。
这套设计的目标是允许消息重复到达,但同一逻辑释放只产生一次有效结果,并且在可恢复故障后继续完成。它不意味着消息在网络上只发送一次,也不保证业务动作在任意永久故障下必然完成。超时后的查询、重试策略和人工兜底仍是系统责任。这些边界在 超时、重试与幂等一文中有更完整的讨论。
于是,原来的一句“只能释放一次”变成不同层次的验证任务:
| 验证范围 | 需要回答的问题 | 单靠这一层不能证明什么 |
|---|---|---|
| 决策函数的单元测试 | 已取消状态是否再产生释放意图,已支付状态是否拒绝 | 两个进程能否同时提交转换 |
| 真实数据库的集成测试 | 条件更新、事务回滚、约束是否按预期工作 | 库存服务是否接受并正确处理消息 |
| 库存接口的契约与实现测试 | 幂等键、重复请求、错误语义是否一致 | 完整链路在故障恢复后能否收敛 |
| 跨组件验收与故障场景检查 | 从取消到释放的链路是否兑现业务规则 | 所有未列出的故障与业务情况 |
这里的“集成测试”明确指应用与真实存储等组件的连接验证,避免不同团队对这个名称的范围理解不一致。上线前应根据系统风险补足这些证据,而不是用一张全绿的单元测试截图替代。
七、SDD 与 TDD 怎样分工
SDD 和 TDD 可以一起用,也可以分别用。TDD 比 Coding Agent 早得多,并不依赖规格工具;一个局部缺陷的修复,也不需要为了使用 TDD 先生成整套规格文档。
两者的关注点有差别,但也有交叉:
| 对比维度 | SDD | TDD |
|---|---|---|
| 主要反馈对象 | 需求、约束、方案、任务与实现是否一致 | 选定行为是否已实现,已有行为是否被破坏 |
| 常见工件 | Spec、Plan、Tasks、验收记录 | 场景清单、自动化测试、实现与重构后的代码 |
| 推动设计的方式 | 提前显式讨论边界与技术取舍 | 通过具体调用和断言暴露接口、依赖与设计问题 |
| 变化时要做什么 | 修订相关工件并确认新的要求 | 调整对应测试,再按新预期实现 |
| 无法独自解决的事 | 文档合理但代码或运行行为错误 | 测试预期错误、场景遗漏、验证范围不足 |
所以,把 SDD 简化成“写需求”,把 TDD 简化成“测正确”,都不够完整。SDD 也需要验收证据,TDD 也参与接口与设计的形成。更实用的组合方式,是用 SDD 管理功能层面的意图,在一个个任务内部用 TDD 推进具体行为。
Spec Kit 的 Agentic SDD 文档把规格、规划、任务、实现和收敛组织为工作流;是否安排测试任务与项目要求有关,并非所有 SDD 工具天然强制 TDD。本文建议的组合属于团队选择的工作方式,不是术语本身规定的唯一顺序。
在订单例子里,Spec 先确认待支付、已取消、已支付分别怎么办,以及重复释放和动作丢失的风险。Plan 决定采用条件转换、Outbox 与库存幂等边界。Tasks 再按可验证行为拆工作:决策规则、数据库提交、事件投递、库存处理。Agent 执行每项任务时选择适合的测试粒度,经历失败、实现与重构。
最后的验收检查整条链路。它不能只看任务列表是否打勾,也不能只看某个决策函数通过了三条测试。数据库事务证据、接口约定和链路结果都要对应回规格中的要求。
如果实现中发现库存模型不支持原来的幂等键,应回到技术方案讨论;如果发现“已支付拒绝取消”并非真实业务要求,则需要业务确认并修订规格。不能让 Agent 为了消除测试失败,悄悄把预期改成当前实现的样子。需求可以变化,但变化要有来源和确认。
八、给 Agent 的任务,要把失败证据写进去
只对 Agent 说“严格遵循 TDD”,很容易得到一份描述完整、证据缺失的报告。更有效的指令需要明确当前行为、已确认规则、可修改范围以及每一阶段的检查要求。
例如,给一个已经存在取消入口的仓库,可以这样描述局部任务:
本次只修复重复取消产生新库存释放意图的问题。
已确认规则:首次取消待支付订单产生一个释放意图;
已取消订单重复取消按幂等成功处理,不产生新意图;
已支付订单仍使用既有拒绝规则,不修改退款行为。
先读取当前取消入口、相关测试和仓库测试命令,确认基线。
新增一个针对重复取消的回归用例,执行并说明具体失败断言。
如果失败来自环境、测试发现或数据准备,先修复验证条件。
再做满足该行为的最小实现,执行新增用例和相关旧用例。
测试通过后才整理结构,不改变已确认的业务预期。
不要跳过测试、删除关键断言或更改预期来迁就实现。
若发现规格冲突,说明冲突并等待确认。
交付时写出实际命令、结果和未覆盖边界;
不得用串行单元测试声称已保证数据库并发或跨服务幂等。
这段指令没有强迫 Agent 使用某种测试框架。仓库已有 JUnit、Vitest 或其他工具时,优先沿用现有约定。引入新依赖、迁移测试配置、重写全部旧测试,都可能扩大任务范围,不能自动算作“为了 TDD 必须做”。
还应区分执行事实和解释。执行事实包括跑了什么命令、发现多少用例、哪个断言失败、修复后哪些相关检查通过。解释包括为什么这组测试能够约束重复取消。两者都重要,只提供其中一个不够:只有命令日志,读者不知道验证了什么;只有“已遵循 TDD”的说明,读者不知道测试是否真的运行。
同一个 Agent 可以写测试也写实现,但两份代码并不会因此成为两份独立的业务判断。人至少要检查关键规则和断言:取消的状态范围有没有写错,重复请求有没有真的使用已取消状态,库存释放是否被偷换成发消息次数。高风险行为可以使用独立评审,但评审者也需要依据确认过的规则,而不是凭措辞判断哪份代码更像正确答案。
覆盖率在这里是辅助信息。它可以提示哪些代码没有被执行,却不能判断断言是否有意义,也不能说明并发时序是否覆盖。Agent 若以“覆盖率达到目标”为唯一方向,就可能增加很多只执行、不验证的测试。关键业务场景应该有自己的检查理由。
九、什么时候值得用,什么时候先做别的事
对于明确的局部缺陷,我会优先使用回归测试驱动修复。输入和错误行为已经知道,失败用例能够直接保留这次排查结果。以后有人重构相关代码,这条测试还能提醒他不要把问题带回来。
对于金额计算、权限判断、订单状态转换、去重和幂等决策等确定性规则,TDD 也很合适。输入输出边界清楚,反馈通常较快,测试能够促使复杂规则与外部依赖分开。但危险程度越高,越不能只靠几个示例;应考虑边界值、组合规则、性质检查及更高层验证。
复杂的新功能更适合先澄清规格,再对关键行为使用 TDD。比如增长系统的优惠券发放,先确认谁有资格、重复触达如何处理、预算怎样限制,再决定测试与实现。若一开始连预算口径都没有共识,快速把某个预期写进测试,只会更快固定一个未经确认的假设。
探索性的 UI 则可以先做有限原型。字体比例、留白和动效手感很难仅靠一个自动断言判断。视觉方向确认后,再为键盘导航、表单校验、状态切换等明确行为补测试。截图回归也需要基准和人工判断,不能拿“像素没变”代替体验质量。
旧系统没有测试、职责缠在一起时,可以先用特征测试记录当前行为,再逐步建立可测试边界。特征测试的价值是保存现状;它不保证现状就是正确需求。遇到历史错误,需要先确认应有行为,再修改预期和实现,不能把所有旧结果永远当成规范。
对于包含模型输出的 Agent 产品,工具权限、状态管理、输入校验和错误处理这些确定性部分,仍然可以使用 TDD。开放式回答质量则通常还需要评测集、评分标准和人工抽查。把一段自然语言输出固定成字符串快照,未必能检验推理质量;测试工具流程通过,也不等于模型在新问题上可靠。
选择的依据应该是规则清晰程度、反馈成本和错误风险。无需给每次文案修改准备完整规格,也无需因为 Agent 生成代码很快,就跳过高风险行为的检查。
十、测试通过以后,还要知道自己证明了什么
回到订单取消:决策测试约束的是“不同状态下应该产生什么结果”;数据库检查约束的是“谁能提交状态转换”;库存幂等检查约束的是“重试不会重复产生业务效果”。这些证据相互补充,不能彼此替代。
SDD 帮我们保存并确认要求,TDD 帮我们在实现过程中不断收到反馈。两者结合时,规格给测试提供预期来源,测试让部分规格变得可执行,验收再检查整个功能有没有兑现。需求变化仍然需要确认,未覆盖的边界仍然需要如实交代。
我希望 Agent 最后交付的,不只是“代码写完了,测试绿了”。更有用的报告应当说明:这次改变了哪一个行为,错误原本在哪个断言上暴露,相关旧行为是否保留,以及哪些风险还没有获得验证。
参考资料
- Kent Beck:Canon TDD:场景清单与逐个测试驱动的工作顺序。
- Martin Fowler:Test Driven Development:测试、实现与重构循环,以及测试先行对接口设计的影响。
- Martin Fowler:Mocks Aren’t Stubs:状态验证、交互验证与实现耦合的取舍。
- GitHub Spec Kit:Agentic SDD Workflow:规格到实现、收敛的流程与可选检查。
如果这篇文章对你有帮助