区块链 区块链技术 比特币公众号手机端

在 Agent 循环中运用测试驱动开发(TDD)的实验

TDD(测试驱动开发)工作流可以通过多种方式与 AI 辅助编码结合使用:

  1. 人类编写测试:人类以某种形式定义测试场景——自然语言、BDD 风格,或者直接写成代码。随后 AI 编写实现代码让这些测试通过(第一步或许会先把人类的场景转换成代码)。
  2. 人类审查检查点:AI 编写失败的测试,人类审阅该测试,确认它验证的正是期望的行为,然后 AI 再编写实现代码。
  3. 完全在 Agent 循环内:让 Agent 先逐个写出失败的测试,再编写实现代码,并检查之前失败的测试是否变绿。

目前来说,最后一种用法最为常见。但要求 Agent 完全在它自己的循环内遵循 TDD 工作流,真的会有差别吗?它真的能带来价值,还是说,这是少数几个对人类有益、对编码 Agent 却可能无关甚至有害的例子之一?

我搭建了一个探索性评估方案来初步探究这个问题,看看会发现什么。这远非全面、结构化的评估结论,但它确实催生了一些值得深思的假设,尤其适合那些正努力让 Agent 用上 TDD 的人。

TLDR: 根据 Opus 对结果质量的评判,采用或不采用 TDD 工作流,结果没有明显可辨的差异。相反,Opus 不止一次把非 TDD 工作流的方案在设计和测试质量上评为略高。各方案之间的变异测试得分同样没有显著差别。

设置

  • 任务: 我借助 Claude 创建了小型、中型和大型三个任务,都是从零实现的业务逻辑。我让它给出了一连串建议,要求包含独特而具体的逻辑,以提高不同解决方案之间出现差异的可能性,而不是让它们重复训练数据里已有的主流做法。
  • 指令: 每次运行,我都要求至少达到 80% 的代码覆盖率。
  • 模型: 我使用 Sonnet 4.6 生成解决方案。
  • TDD 遵循度评判: 对 TDD 遵循度的评估也由 Sonnet 4.6 完成。
  • 解决方案评判: Opus 4.8 在不知道方案创建方式的情况下,比较了各解决方案及其测试的质量。我并没有就"什么算高质量"给出非常具体的输入,因为这是一次相当开放的探索。根据我的经验,我给出的标准越具体,模型就越可能过度拘泥于我列出的那些质量标准。Opus 已被证明是代码质量评判方面相当有能力的模型。在给方案排名时,它临场创建了一套评分标准,并下发给评估各方案的所有子 Agent。

流程图:在每个批次中,我用同一任务分别运行两次带 TDD 指令和不带 TDD 指令的版本,然后让 Opus 在不知道创建方式的情况下比较四个解决方案,再将会话记录提供给 Opus,请它推测 Agent 的工作方式是否以及如何影响了解决方案的质量。

如果你要从我的结果中得出自己的结论,需要注意的主要事项如下:

  • 这显然只是非常小的样本,请谨慎参考。
  • 对"质量"含义的评判几乎完全留给了 Opus(只对测试质量做了一些提示)。
  • 没有任何一次运行是完美遵循 TDD 的,不过总体遵循得还不错。
  • 交给 Agent 的任务都是从零开始、相对较小的编码任务,内容纯粹是业务逻辑。

Agent 在 TDD 方面到底有多好?

在开始之前,我需要确保 TDD 指令真的得到了遵循。从以往的经验看,我在这件事上一直不太顺利:Agent 经常先写实现代码、再生成测试,跳过确认"变红"这一步,或者在当前测试之前就过度实现,导致下一个测试没有经历过红色就直接通过了。

我最终使用的提示词与 Sonnet 配合得足够好,可以用于这场比较;不过所有会话都或多或少表现出了一些这类问题。每次 TDD 运行,我都会让一个独立的 Agent 根据会话记录评判工作流被遵循的程度,这样我就不至于无意间把那些并没有真正执行 TDD 的运行也算进去。

结果

我生成了 5 批解决方案,每批包含两个非 TDD 方案和两个 TDD 方案。其中一个批次,我还额外加了两次"只先写测试"的运行,不要求完整的 TDD 纪律(没有增量式的红/绿)。

在小型(1 批)和中型(3 批)任务中,呈现出一种规律:Opus 把两个非 TDD 方案排在第 1、2 位,而两个 TDD 方案排在第 3、4 位。只有一次——在我强化 TDD 提示词、加入更明确的重构和设计审查步骤之后——某个 TDD 方案才拿到了第 1 名。不过在同一批次中,另一个用完全相同提示词跑的 TDD 方案却排在了最后……至于更大的那个任务,TDD 方案居中,而两个非 TDD 方案分别拿到了最好和最差的名次。

(详情见附录)

假设

总体来看,在各个批次中,TDD 和非 TDD 方案都既拿过最佳、也拿过最差,而 TDD 的整体表现略逊一筹。

在得知各方案对应哪种工作流之后,Opus 被要求查看会话轨迹、推测结果成因。它发现,非 TDD 和测试先行的运行,总是先在写任何代码或测试之前就建立完整设计(架构、数据类型、边界情况、契约),而不是跟着需求/测试一样一样地逐步推进。这似乎正是让天平略微偏向更好数据模型、更全面边界情况、更完整功能的关键因素。

TDD 指令实际上在妨碍这种前置设计。在这些运行里,设计由大量局部最小决策堆叠而成,很少被回头审视,因此最终形态往往取决于第一个测试恰好锁定了什么。Agent 没想到要写测试的那些行为,最终也完全没有被实现。

后来我和 Ivett Ördög 聊起这个,她提出了一个理论:"AI Agent 是这样被训练出来的:它们见过大量完整函数以及对这些函数的描述。而真正的逐步 TDD 示例,在训练数据里只占极小一部分。这意味着 LLM 对代码的内部表征,是'从需求直接映射到代码',而不是'如何一步步得到那个表征'的过程。"

TDD 的目标:在 Agent 循环中仍然能实现吗?

下面是我对"在 Agent 循环中使用 TDD"的一些一般性思考,而不仅仅基于这次实验。我会逐个讨论我个人使用 TDD 时最根本的目标,跳过那些"首先得有测试"层面的目标(尤其是单元测试带来的,比如重构安全网、活文档、代码覆盖率),只聚焦于 TDD 工作流特有的一些目标。

测试先行:避免同义反复

测试先行,让我更容易去断言我真正想要的输出,而不是复述实现逻辑本身。这种测试在实现出错时永远不会失败——因为它正是从理应被检查的那套逻辑中推导出来的。当断言与具体实现路径解耦后,测试才能真正捕捉到"行为不符合我预期"的情况。

在 Agent 循环中仍然能实现吗? 在我的实验里,有些 TDD 会话尽管先写了测试,还是踩中了这个问题。有一个特别明显的例子:测试拿实现的输出跟它自身比较——重新运行同一份代码来生成"预期"答案(见这份观察列表中的第 4 项)。先写测试并不能可靠地避免这种情况——它或许能降低发生的概率,而这已经是我们对 LLM 能指望的全部了;但单凭这么小的数据集,我无法对那个概率得出任何结论。

测试先行:可测试性

测试先行能确保代码从设计之初就是可测试的,而不是事后补测试,结果补出来的测试比必要情况更复杂、更脆弱。

在 Agent 循环中仍然能实现吗? 结果没有给出任何清晰明确的信号。况且,我选的任务在规模和性质上,并不需要太多设计复杂度,因此也很难让这一点浮出水面。不过在某种程度上,可测试性其实是"以测试驱动设计"的自然推论(见下文)。

红-绿:测试有效性

先看到测试失败、再看到它通过(红-绿),才能证明它确实能抓住回归。

在 Agent 循环中仍然能实现吗? 当人类从循环中被移除后,这一点究竟还有多大意义?只有当有人去核查它为什么变红时,"看到测试变红"才具有证明力。当 Agent 自己写测试、自己确认失败时,测试变红只能告诉你"Agent 跑了测试并且看到了失败",却无法告诉你"失败的原因是对的"。我在实验中对 TDD 遵循度的评估也印证了这点:Agent 有时仍会跳过或伪造红色步骤,或者提前实现功能,让测试直接通过。回归测试的有效性,可以用变异测试来监控和改进(我在这篇文章里写过)。各方案的变异测试得分也没有任何迹象表明,TDD 运行产出的变异测试成绩会明显好于非 TDD 运行。我并不真的在乎回归质量是怎么做到的,只要我有一套机制能看出它到底好不好。

测试先行,红-绿-重构:驱动更好的设计

先写测试,迫使我们在动手实现之前先明确用法,从而促成更好的接口和更模块化的代码。TDD 循环里的重构步骤,又进一步推动我们一点一点打磨设计。

在 Agent 循环中仍然能实现吗? 至少,这次实验完全没有证明 TDD 运行在设计上更胜一筹。我甚至开始根据 Opus 的评分怀疑,TDD 是不是反而让设计更差了——毕竟非 TDD 方案更常被排到前面,而且 Opus 指出的那些设计缺陷,在我看来也确实成立。不过数据集当然还是太小,不足以得出任何确定性结论。(如果有人时间和 Token 都够,愿意跑一个更大的实验,那会非常有意思!)

人类先写测试时,会被迫在实现之前先考虑用法;在还不知道怎么构建之前,就得先忍受"把行为和预期定下来"的那种摩擦。Agent 感受不到这种摩擦,它可以在规划实现的同时,随手就把测试写出来。如果两者之间少了人类这个检查点,先写测试的意义还剩多少?

小步骤:YAGNI

只写恰好够通过下一个测试的代码,这本身讲的是克制。它本应阻止我们去造没人要的抽象、去处理没人要求的用例。

在 Agent 循环中仍然能实现吗? 这是一种高度以人为中心的好处,当 Agent 独自执行 TDD 时,它就流失了。我们不再有机会待在那样的摩擦里,去认真琢磨正在构建之物的种种繁复之处。理论上,这种摩擦转移到了"我们给 Agent 写规范"的阶段;但在那里,我们没有类似 TDD 的机制能帮我们小步小步地把规范想清楚。

难道 Agent 就不能小步前进,并在发现可能有不需要的东西时来问我们吗?以我的一般经验来看,它们并不擅长这个。实验里也是一样:"最小实现"的指令并不能可靠地拦住它们多做。它们常常用力过猛,实现得比当前测试要求的更多——毕竟完整需求就摆在它们面前。我们通常不会把规范一条条喂进去,那样太低效了。

小步骤:快速、局部的反馈

一步只走一小段,意味着测试失败时,我几乎能确定问题出在哪里——因为自上次绿色状态以来,唯一变动的就是刚刚写下的那一点。

在 Agent 循环中仍然能实现吗? 这次实验设置没能说明:有无 TDD,Agent 卡在调试里的频率是否不同。但按我的一般经验,Agent 通常相当擅长弄清测试为什么变红,哪怕它们并没有小步谨慎地走到这一步。我仍然存疑:它们卡住时,小步 TDD 是否真能带来有意义的缓解?整体算下来成本/收益是否划算?

小步骤:信心和学习

Kent Beck 在《测试驱动开发:示例》一书的序言里,给出他主张 TDD 的最大理由:"管理恐惧"。他说,对难题的合理恐惧会让开发者变得犹豫、不愿沟通、回避反馈。有了 TDD,每通过一个测试,我们就看到实实在在的进展,于是可以放心——因为进展已经被锁定。测试是一种心理机制,帮我们继续走下去。

在 Agent 循环中仍然能实现吗? 这很大程度上是在管理的恐惧、给一个"可以放松"的许可。当 Agent 在循环里做 TDD 时,这一切就无从谈起——它给不了我那种自己一步一步做 TDD 时才能获得的掌控感和信任感。

成本

至少 3 倍的 Token

详细数据见附录。

这很自然:TDD 工作流需要更多轮次和工具调用,Token 消耗自然更高。不过其中相当一部分会命中缓存,所以请注意:Token 量是 3 倍甚至更多,并不直接等于成本也增加了那么多。(遗憾的是,我在实验期间没有记录缓存命中数据。)

提示词维护和测试

TDD 对模型来说似乎并不是一个"顺其自然"的过程,就像是在跟训练数据较劲;要把提示词迭代很多遍,才能让模型在大多数时候照着这套流程走。举个例子:第一批做完后,我意识到 Agent 在红-绿-重构循环里几乎不怎么重构,于是就改了提示词,更加强调这一步——毕竟它对 TDD 至关重要。我后来让 Opus 回看这些会话,看它是否观察到重构有所改善。它确实报告重构步骤增加了——不过它也举出一些例子:Agent 着手重构,却认为设计已经够好而作罢,哪怕 Opus 明显觉得还远远不够(比如所有东西都堆在一个大模块里,明明可以按职责拆开)。

TDD 是一套相对复杂的指令,变量很多,Agent 对它的解读自然也会五花八门。所以我猜,这类提示词在不同模型之间的稳定性,会比更简单的指令更差;换句话说,想让它跨模型、跨版本持续生效,需要花不少功夫。

概览图,总结了 Agent 使用 TDD 的成本(Token、指令),以及 TDD 的各项收益在 Agent 循环中如何兑现。收益部分基本是文章中相关内容的汇总。

我的结论

我认为到了今天,已有越来越多证据表明:在"我们希望模型怎么做"这件事上要求得过细,并不是一种可持续的做法。相反,我们应该想尽办法去监控结果、提供反馈。这种反馈要尽可能自动化;同时,我们得仔细想清楚:自己应当在哪些环节介入,充当"什么算好、什么算对"的仲裁者。

我清楚自己的小评估远不足以代表对 TDD 有效性的全面观察,但它绝对没有给我任何新迹象,让我觉得这般折腾是值得的。尤其是当我们还能用其他办法拿到 TDD 的大部分收益时。

我个人已经不再让编码 Agent 先写测试了,更别说做 TDD(说实话,这事我从来没干过)。除非我看到评估数据或其他有力论证,否则我不会改变主意。我转而把精力放在"自己在 Agent 循环之外使用 TDD"所获得的收益上,并探索用别的方式达成这些收益。

如何获得好的回归测试?

……以便 Agent 和我在现有功能被破坏时得到信号

我仍然在意可靠的回归测试:尽管 Agent 完全可能用错误的方式把红色测试"修绿",但至少红色测试给了它一个反馈信号,让它回头核对那些可能被破坏的既有需求。我用变异测试来监控、改进回归质量,而不是精心设计一套 TDD 指令然后祈祷有好结果。

如何将定期重构融入流程?

……以便代码库保持易于修改

重构依然至关重要,但传统 TDD 那种小步走的方式,放在 Agent 循环里似乎既不高效率,也不够有效。举几个可以触发重构的做法:给 Agent 静态代码分析的访问权;定期做结构和模块性审查;建立团队惯例,保持对代码库的良好理解、尽早发现偏离;盯住每次变更涉及的文件数量走势,以及单次变更消耗的 Token 数。

如何获得信心?

……好让我不怕往生产环境推送

最难的问题依旧是:怎么找回 TDD 曾经给过我们的那种信心?怎么管理恐惧?怎么锁定进展?我没有明确答案,但可以提一个看起来不错的积木:我最近试了试 Ivett Ördög 一直在倡导的 Approved Scenarios 方法。用我自己的话讲(别让她为这个说法负责),这算是一种半手动的测试方式,背后是每个应用各自定制的测试运行器。这个运行器会把功能测试场景摆在我面前,让我很容易想清楚,并允许我在彻底确认之后,把那些期望(场景/测试夹具)在该运行器里"冻结"。将来一旦这些被冻结的期望被打破,我就得重新批准一次。我的同事 Matteo Vaccari 对他用这套方法的心得做了一个很棒的介绍。

无论未来究竟是靠什么,让我们重新对软件建立起信任和信心——我认为,我们所熟知的那种 TDD,其角色都会比 GenAI 时代之前小得多。


附录:根据 Opus 评估的结果

你可以在这个仓库中找到完整结果。

  • NT = 无 TDD 指令
  • T = TDD 指令
  • TF = 测试先行指令

所有批次的 Token 使用情况

按任务大小细分:

任务 NT 平均 Token T 平均 Token T / NT 倍数
小型 119,815 (n=2) 1,018,245 (n=2) 8.50x
中型 736,486 (n=2) 2,181,105 (n=6) 2.96x
大型 253,621 (n=2) 1,239,408 (n=2) 4.89x

只有中型任务额外加入了测试先行指令。

注意: 这些数字只是会话成本的粗略近似,并不衡量方案里写了多少代码或经过多少思考。记录 Token 用量的脚本基于 pi-coding-agent SDK 的 getSessionStats(),它把会话中每个助手轮次的 input + output + cacheRead + cacheWrite 用量相加。这是对整个对话的累计求和:每一轮都要重新读取已经累积的上下文,每次重新读取(通常大部分由缓存命中)又会在该轮的 cacheRead 里再算一次。所以"总 Token"更多反映的是会话经历了多少轮,并按当时上下文已经长到多大来加权。它把便宜的缓存读取 Token 和昂贵的新 Token 按同样权重计算,因此很可能高估了 TDD 的真实开销。鉴于样本量很小,请把这些倍数当作方向性参考:TDD 确实稳定地贵出数倍,但具体贵几倍则浮动很大。

中型任务,第 1 轮

任务: 构建一个 4 阶段 Python 管道(解析 → 聚合 → 格式化 → 验证),将原始的 ROW_ID:CATEGORY:VALUE:PERIOD 字符串转换为纯文本报告。

数据:

ID TDD 测试数量 覆盖率 变异测试得分 总 Token 轮次 工具调用
NT1 75 100% 84.2% 769,814 31 37
NT2 107 100% 89.6% 703,159 21 24
T1 30 100% 81.0% 1,519,762 71 28
T2 34 99% 77.3% 2,580,897 103 60

总体评判:

ID TDD 排名 评判
NT1 1 每阶段一个模块、dataclass、Decimal;没有正确性 bug,错误处理最强,也是唯一检查重复 ROW_ID 的方案;验证逻辑虽自引用但无害
NT2 2 每阶段一个模块、dataclass、float/round;工程化最好、测试套件也最大,但验证器会拒绝自身生成的有效小数输出(误拒 bug);TOTAL 行从未被验证
T1 3 单一模块、dict、float;核心阶段正确,但验证形成循环(重新运行格式化器);接受 nan/inf,忽略重复 ROW_ID
T2 4 单一模块、dict、float;存在一个被测试固化的 TOTAL 行 bug(把人数加进了美元金额);验证检查 #3 完全缺失

(这一轮的排名只依据综合评判、测试数量、覆盖率和变异测试得分——Opus 的设计/代码/测试分项评分自下一轮起才引入。)

中型任务,第 2 轮

任务: 与 01-medium 相同的 4 阶段报告管道,以更严格的 TDD 遵循度重新运行,并新增了测试先行变体(NT1/NT2 沿用 01-medium 的同一代码库)。

ID 方法 测试数量 覆盖率 总 Token 轮次 工具调用
NT1 无 TDD 107 100% 703,159 21 20
TF2 测试先行 90 92% 619,531 27 26
NT2 无 TDD 75 100% 769,814 31 30
T2 TDD 29 98% 2,099,280 96 95
TF1 测试先行 62 99% 268,323 17 16
T1 TDD 25 100% 2,017,739 90 89
ID 方法 设计 代码 测试 平均 实现/测试 LOC
NT1 无 TDD 8 8 8 8.0 497 / 881
TF2 测试先行 8 8 7 8.0 484 / 850
NT2 无 TDD 8 8 7 7.5 330 / 430
T2 TDD 7 7 6 6.5 207 / 304
TF1 测试先行 6 6 6 6.0 348 / 360
T1 TDD 6 6 6 6.0 142 / 228
ID 方法 排名 评判
NT1 无 TDD 1 测试套件最深入,验证最干净(复用了格式化器的布局);但 HEADCOUNT 被混入美元总额,没有测试覆盖到这一点
TF2 测试先行 2 全程使用 Decimal,解析/验证很强;但检查 3 是不可达的死代码,验证模块也略显杂乱
NT2 无 TDD 3 设计干净,用 Decimal,解析正确;但测试偏薄,有一个无操作测试,验证逻辑自引用
T2 TDD 4 快乐路径干净,格式化正确;但遇到格式错误的输入会直接崩溃,而不是返回结构化的解析错误
TF1 测试先行 5 单文件方案里解析器最强;但 TOTAL 行损坏(把人数当成美元),交付物里还带着死脚手架
T1 TDD 6 最紧凑(142 LOC);遇到格式错误的输入会崩溃,验证最同义反复,测试套件最薄

中型任务,第 3 轮(改进的 TDD 指令)

任务: 与 01-medium 相同的 4 阶段报告管道,使用改进的 TDD 提示词(强调前置设计和重构)重新运行;NT1/NT2 再次复用 01-medium 的代码库。

ID TDD 测试数量 覆盖率 变异测试得分 总 Token 轮次 工具调用
T1 51 100% 90.2% 3,447,283 117 116
NT1 107 100% 89.6% 703,159 21 20
NT2 75 100% 84.2% 769,814 31 30
T2 43 100% 81.1% 1,421,671 61 60
ID TDD 设计 代码 测试 平均
T1 8 8 7 7.67
NT1 8 8 6 7.33
NT2 8 7 6 7.0
T2 7 7 6 6.67
排名 ID TDD 主要弱点
1 T1 TOTAL 行只有 HEADCOUNT,却被打印成 $;验证只是子串检查,不是算术检查
2 NT1 带小数的 HEADCOUNT 会让有效输入误报 ValidationError;测试依赖 monkeypatching
3 NT2 NaN/Infinity 会让管道崩溃而不是抛出 ParseError;验证逻辑重新运行格式化器
4 T2 验证同义反复(拿聚合结果和它自身比较);宽度检查只是句注释

小型任务

任务: 构建一个 Python 模块,验证 DAY-TIME-ROOM-CHECKSUM 格式的医疗预约时段代码,返回一个结构化结果,指出哪个规则失败以及原因。

ID TDD 测试数量 覆盖率 变异测试得分 总 Token 轮次 工具调用
NT1 61 100% 89.6% 122,108 10 15
NT2 58 100% 92.3% 117,522 10 20
T1 21 100% 93.6% 894,451 55 37
T2 20 100% 93.2% 1,142,039 68 26
ID TDD 设计 代码 测试 平均
NT1 8 9 9 8.67
NT2 8 8 8 8.0
T1 7 8 7 7.33
T2 6 7 7 6.67
ID TDD 排名 评判
NT1 1 整体最佳——dataclass 结果、无 bug、61 个能断言具体原因的测试
NT2 2 非常接近——dataclass 结果,但存在 Unicode 数字规范偏差
T1 3 正确且干净,但用 dict 返回结果,测试更少,还有死代码
T2 4 设计最弱(自由文本错误),还有一个实打实的崩溃 bug

较大的任务

任务: 构建一个基于内存的 Python 忠诚度积分引擎,具备分级积分赚取率(青铜/白银/黄金)、用于等级重算的滚动 365 天消费跟踪,以及积分兑换功能。

ID TDD 测试数量 覆盖率 变异测试得分 总 Token 轮次 工具调用
NT2 69 100% 86.9% 322,148 14 13
T2 22 99% 85.6% 1,225,517 63 62
T1 21 99% 85.2% 1,253,300 67 66
NT1 74 99% 89.4% 185,094 11 9
ID TDD 设计 代码 测试 正确性 平均
NT2 8 9 8 8 8.25
T2 7 7 8 8 7.5
T1 7 7 7 9 7.5
NT1 8 7 6 6 6.75
ID TDD 排名 评判
NT2 1 唯一带真实输入验证的方案;边界测试精准;只有些轻微的乱序购买边界问题
T2 2 类型化数据模型干净,核心规则全对;但缺少错误处理,存在重复购买 ID 的 bug,还有多余的状态字段
T1 3 功能上最正确(反复探测未发现 bug);但用的是无类型嵌套 dict,有残留结构,测试也最少
NT1 4 设计得分最高、有 74 个测试——但存在两个高严重度 bug:批次扣减顺序错误;未来日期的积分被算作可消费

致谢

感谢 Ivett Ördög、Matteo Vaccari、Dan Mutton、Lukasz Plotnicki 和 Emily Bache 抽出时间审阅本文,也感谢他们的反馈与宝贵讨论,帮助我改进了这篇文章。

本文借助 GenAI 完成资料调研、把想法组织成结构,并对语言进行润色。

  • 原文链接: martinfowler.com/article...
  • 鸿途知科网 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
版权声明

本文仅代表作者观点,不代表区块链技术网立场。
本文系作者授权本站发表,未经许可,不得转载。

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

热门