前两天在 Martin Fowler 网站看到一篇文章。作者把同一批任务交给 Sonnet 4.6,一组要求严格按 TDD 做,一组随它写,再让 Opus 4.8 盲评。结果有点打脸:TDD 组没写得更好,Token 至少多烧 3 倍。看到这里,我第一反应不是 TDD 过时了,而是我们可能让 Agent 做了一件看起来很工程、其实很奇怪的事:自己出题,自己答题,再自己宣布通过。

TDD 当初解决的是什么?

TDD 针对的典型开发方式,是先写一大段实现,过很久才验证。等测试失败时,中间已经改了几十处,没人知道错在哪。改动越大,反馈越慢;反馈越慢,人越怕改;越怕改,代码越不敢重构。

Red-Green-Refactor 把这个长反馈链切碎。先定义一个很小的行为,看它失败,只写够让它通过的代码,再清理设计。每次只改变一个变量,出了问题很容易定位;代码一直处于可工作状态,人也敢继续往前走。

Kent Beck 说 TDD 是用来管理 fear 的。这个 fear 很具体:怕改坏,怕失控,怕一个问题大到脑子装不下。Test-first 还会逼着人先从调用者看接口,不至于一头扎进实现,写出一个只有自己会用的 API。

TDD 当初解决的核心,是让人用短反馈控制复杂度;测试是这套反馈机制留下的可执行资产。

Agent 没有 fear,也不会因为问题太大而不敢下手。它拿到完整需求时,通常已经在同时规划测试和实现。要求它先把测试写进文件,只是换了落盘顺序,不代表它经历了人写测试时那段思考。

把这套流程原样搬进 Agent Loop,就像要求挖掘机开工前先活动手腕。动作没错,机器没有那块肌肉。

TDD 优化局部,Architecture Design 决定全局

TDD 每一轮只问两个问题:下一个失败用例是什么?让它通过的最小改动是什么?这是一种局部搜索。问题边界已经清楚时,它很好用;复杂任务的边界还没画出来时,它很容易把“下一步正确”误当成“方向正确”。

想象让 Agent 重写一个支付模块。第一个测试是单币种付款,第二个是优惠券,第三个是退款。每一步都可以 Red-Green,代码也一直能跑。等多币种、部分退款、幂等、对账一起进来,才发现最开始的数据模型就错了。前面那些绿灯没有帮忙,反而把错误结构焊得更牢。

复杂任务的第一步不该是写第一个 test,而是做 Architecture Design:模块边界怎么划,状态归谁,数据往哪流,事务在哪里结束,失败怎么恢复,哪些约束不能破。这些都是全局问题,没法从一个个局部用例里自然长出来。

Architecture Design 也不是先写一份几十页的大文档。它只是要求动手前先把系统的形状想清楚,让后面的局部搜索在正确边界里发生。Architecture 决定往哪走,TDD 只负责每一步别踩空。

Birgitta Böckeler 的实验正好印证了这一点。5 个 batch 里,非 TDD 实现在小型和中型任务中多次排在前两名,TDD 实现落在后两名;大型任务里,TDD 也只排在中间。两组的 mutation score 没拉开差距。

Opus 看完执行记录后发现,不受 TDD 限制时,Agent 往往先把数据结构、边界条件和整体设计想一遍;按 TDD 做时,它围着第一个测试不断做局部最小修改,早期设计很快被锁死,后面又没有认真回头 Refactor。

实验很小,任务都是从零实现,质量还是另一个 LLM 评的,证明不了“TDD 一定让 Agent 变差”。但它把真正的问题露了出来:我们需要的不是一个更守规矩的 coding loop,而是先有全局设计,再让 Agent 在边界里跑。

Red 过,不等于测试对

很多人坚持让 Agent 先跑出 Red,理由也很直接:测试先失败过,至少能证明它不是摆设。

可 Red 只能证明测试和代码不一致,证明不了为什么不一致。可能是实现缺了,也可能是 import 错了、fixture 坏了,甚至测试断言了一个根本不存在的需求。如果测试和实现都来自同一个 Agent,它们还会共享同一份误解。

原文里就出现过一个很典型的例子:测试重新调用实现逻辑计算 expected value,再拿两边结果做比较。它当然会绿。更荒唐的是,哪怕先把这段测试写出来、亲眼看它红过,也不会让它更接近真实需求。

所以我更在意 mutation testing。故意改坏实现,看看测试能不能抓到,至少能证明这张网不是画上去的。但 mutation score 再高,也只能证明测试能发现代码变化,不能证明测试里的答案是对的。

测试最重要的不是先写,而是答案从哪里来。

测试要放在 Agent Loop 外面

我不是要扔掉 TDD,而是要把它拆开。

如果测试由人来写,里面装的是人的 judgment,Agent 再去补实现,这很有价值。如果 Agent 先写测试,人确认它测的是正确行为,再让 Agent 继续,也有价值。真正可疑的是第三种:Agent 在自己的 Loop 里写测试、看它失败、补实现、看它通过,全程没有外部判断。

三种做法看起来都叫 TDD,差别不在谁先写,而在测试和实现是不是来自同一个答案源

需求方给出的 example、线上事故留下的 regression case、旧系统跑出来的 golden output、协议 schema、性能 budget,这些东西才接近 ground truth。它们不一定长得像 unit test,但都在告诉 Agent 什么结果不能交。

这是 What Caps How 在测试里的版本。测试应该是可执行的 What。What 也让 Agent 自己定义,接下来无论 How 跑得多勤快,都只是在完成一次自证。

边界画完,我不关心 Agent 怎么写

Architecture Design 定方向,regression test、architecture test、static analysis、mutation testing 和 benchmark 守边界。边界画完以后,Agent 先写测试还是先写实现,我不关心。

测试杀不掉 mutation,就补测试;模块边界开始漂移,就加 architecture rule;真实样本漏了,就把它放进 regression corpus。

Refactor 也不必绑在每次 Green 后面。复杂度、依赖方向、一次改动碰了多少文件、性能有没有退化,都可以直接触发 review。对着坏结果修,比在 prompt 里提醒它“现在该认真 Refactor 了”靠谱。

这和我在《从 Prompt 到 Harness》里的判断一样:告诉 Agent 应该怎么做,只能改变它做对的概率;能把错误挡住的,才叫 Harness。

TDD 没过时。人写代码时,它还是很好的思考工具;人写测试、Agent 写实现时,它也是很好的协作方式。只是面对复杂任务,顺序应该变成 Architecture Design、外部验证、Agent 实现,而不是一上来就钻进 Red-Green-Refactor。

该停掉的,是 Agent 在 Loop 里那场无人观看的 Red-Green-Refactor。

下次 Agent 告诉你“已严格遵循 TDD”,先别急着放心。问一句:Architecture 是谁设计的,测试里的答案又是谁给的?