TDD 与 Coding Agent:从失败测试到正确实现

用订单取消与库存释放串起 Red、Green、Refactor,理解测试如何推动接口设计,以及 TDD 与 SDD 怎样协作。区分状态规则、数据库并发与跨服务幂等,避免把测试通过当成业务正确的保证。

本文使用humanizerdocumd-visuals

让 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 的错误。它有用的地方,是把一次大范围修改拆成较小、可观察的行为变化,让人更容易发现错误出在哪一步。

TDD 从场景选择到失败确认、最小实现与重构的循环

图中的停止分支很重要。依赖没有下载、测试没有被发现、数据库连不上,都需要处理,但它们不能说明目标业务行为已经被测试捕获。只有搞清楚失败原因,才知道接下来应该修改测试环境还是业务代码。

二、先确定“只能释放一次”究竟是什么意思

“订单取消只能释放一次库存”听上去很清楚,真正开始写代码时仍然有很多空白:订单支付后还能取消吗?重复取消算错误还是成功?系统超时后客户端重试,应该得到什么结果?如果取消状态写成功,库存调用失败了,下一次请求应该如何处理?

这里采用一组示例规则,方便讨论。这是文章的教学模型,不代表某家公司真实的订单系统:

当前状态或场景 期望行为 库存释放责任
待支付订单首次取消 转为已取消 新增一项释放预占库存的意图
已取消订单再次取消 保持已取消,按幂等成功处理 不新增释放意图
已支付订单请求普通取消 拒绝这条取消路径 不产生释放意图,退款另走流程
订单不存在、调用者无权限 返回对应错误 不进入状态转换
取消与支付并发发生 只能有一个合法状态转换获胜 依据最终转换结果处理

“释放意图”是一个需要后续执行的业务动作,不等于库存已经恢复。将两者分开,是因为订单状态和库存可能由不同服务管理。订单侧需要确保该产生的动作不丢失;库存侧需要确保重复收到同一个动作不会重复增加可售量。

因此,目标至少分成三个可以独立检查的要求。第一,状态规则不允许重复取消生成新的释放意图。第二,多请求、多进程情况下,同一订单的取消转换只能提交一次。第三,消息重试时,同一笔预占库存只能有效释放一次。只写第一个要求的单元测试,不能宣称后两个也得到了保证。

订单不存在、权限校验、退款规则都重要,但本文的第一轮测试不把它们挤进同一个用例。我们先研究状态转换,再把存储和外部副作用接回来。场景清单保留这些事项,避免小步实现变成永久遗漏。

这个划分也是 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。本文建议的组合属于团队选择的工作方式,不是术语本身规定的唯一顺序。

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 最后交付的,不只是“代码写完了,测试绿了”。更有用的报告应当说明:这次改变了哪一个行为,错误原本在哪个断言上暴露,相关旧行为是否保留,以及哪些风险还没有获得验证。

参考资料