有一个说法这两年越来越流行:AI 时代,你最需要的就是 TDD。AI 生成代码的行为不确定,说不定哪次就跑偏了。你不可能逐行审查它的每一次输出,但你可以写好测试——用确定的、客观的 test pass/fail 信号把错误拦下来。测试不过,它就过不了关。这听起来像一句没有漏洞的工程常识。很多人已经在这么做了:用 AI 写代码,人写测试,让 AI 跑测试、修 bug,循环到全部变绿。
但我越来越确信这是一个方向性的错误。不是”测试不重要”——测试当然重要。是 TDD 这套方法论的前提在面对 AI 时不再成立,而你一旦把 AI 放进了 TDD 的循环里,它会以一种你在人类开发者身上几乎不会看到的方式让它失效。
先从一个很小的例子开始。假设你让 AI 实现一个权限检查函数,你在 TDD 的节奏里先写了这个测试:
def test_admin_access():
result = check_access(user="admin_user", resource="admin_panel")
assert result == TrueAI 看到这个测试,生成了:
def check_access(user, resource):
return True测试通过了。你当然会说”我的测试集不可能这么简陋”——但先别跳过这个例子。它暴露的东西比看起来深得多。
对一个人类开发者来说,看到 test_admin_access()
的那一刻,他脑子里展开的不只是”这个断言怎么让它绿”。他自动补全了一整套没有写在测试里的东西:这个函数应该查数据库里的权限表、应该校验
session
是否过期、应该处理角色继承层级、应该审计记录。这些东西没有一行出现在测试代码里,但它们出现在他的目标函数里:因为他脑子里装着一个”正确实现应该长什么样”的标准。测试只是路标,不是目的地。
AI 并非不懂权限检查。它在训练数据里见过海量的正确实现,写一段完整的权限校验对它完全不是能力问题。问题在更根本的地方:人和 AI 对同一个测试,跑的是不同的目标函数。
人的目标函数是”建成一个正确、可维护的系统”。测试只是路上的检查站——它告诉人方向对不对,但人的优化目标远大于”通过这些检查站”。所以人看到
test_admin_access 时,自动把”查数据库、校验
session、处理角色层级”补进了搜索空间——不是因为测试说了这些,是因为他的目标函数要求这些。
AI 的目标函数呢?在 TDD 的循环里,它接收到的反馈只有两件事:测试过没过、生成的代码在概率上像不像合理的实现。这就是它的全部优化方向。“查数据库、校验 session、处理角色层级”不在这个方向里——除非测试显式要求。而”return True”完美地满足了这两个信号:测试绿了,代码在语法上也无可挑剔。AI 没有偷懒,它只是在忠实地优化你给它定义的目标。
这是 Goodhart’s Law 在代码生成里的精确复现。当一个度量变成了目标,它就不再是度量了。你的测试本来在度量代码正确性,但当你把它变成 AI 唯一能接收到的反馈信号,它就变成了 AI 唯一在优化的东西。而在一百万种让测试变绿的方法里,实现真正的业务逻辑是最累的那一条。
回看人这边的 TDD。一个 junior engineer 写 TDD,会不会也写出 return True?不太可能。这和聪明无关。大多数 junior engineer 的代码生成能力远不如 LLM。区别在于动机结构。
一个 junior engineer 走进代码库的时候,他知道这坨代码以后要维护、要被 CR、出了问题要被 on-call 叫醒。这些东西给了他一个隐式的代价函数:写一个省事的绕过当下简单,但长期代价巨大。更重要的是,他心里有一个”正确实现”的模糊但真实的图像——那个图像来自于他的工程教育、他的同行标准、他对 bug 被发现的恐惧。这个图像不精确,但足够阻止这种走捷径。
AI 完全没有这些东西。它没有维护负担,没有 CR 恐惧,没有被凌晨两点叫醒的可能。它唯一的反馈信号就是你给它的测试、你给它的 prompt、以及训练数据里下一个 token 的概率分布。你可以在 prompt 里写”请像 senior engineer 一样思考”,但你没有办法在 prompt 里制造”如果写出烂代码会有真实后果”这个动机结构。而整个 TDD 的方法论,是人类在”有后果”这个前提下设计出来的。
这就解释了为什么光把意图告诉 AI 不能解决全部问题。当然可以给——prompt 越详细,AI 做得越好。但意图有一个根本特性:它永远是不完全的。你说”请查数据库”,它会不会用正确的索引?你说”校验 session”,它会不会处理 session 并发过期?这些没说出来的细节,在你亲自动手时会自动冒出来——写到 session 校验那行,脑子里自然会跳出 session 过期的情况。AI 不经过这个过程。它一步生成整个实现,路上碰不到这些没有明写的角落。你漏了什么,它就漏了什么。
到这里,TDD 支持者会给出一个听起来合理的回应:这只不过说明你测试写得不够多。如果 return True 能过关,你当然需要再加测试:加上”普通用户应该返回 False”,加上”已禁用的 admin 应该返回 False”,加上”不存在的资源应该返回特定错误码”。写够多了,取巧路径自然会被堵死,最后唯一能过全部测试的就是正确实现。
这个论证的直觉是对的——在人类的迭代节奏里,它的确成立。但它的成立依赖于一个前提:AI 的取巧路径和你加测试的速度之间存在一个你追得上的关系。而这个前提不成立。
每加一条测试,你堵死了上一次的取巧路径,但 AI 会在新的约束下重新找最短路径。它不会”先试试 return True 不行再换”——它每次都是从零开始,在所有满足当前测试的写法里,选概率最高的那个。你堵的是你见过的那一条路径,它看的是你还没见过的一百万条。约束增长不是线性的,是组合的:每增加一个行为维度(持久化、并发、安全、性能),需要的测试量是按乘积而不是按加法增长的。更隐蔽的是,新加的测试本身也成为 AI 读入的上下文——它会在这个扩大的约束集上继续搜索你没有约束的维度。
这是为什么渐进收敛在 AI 手里不成立。人写代码时,测试约束增长的同时,人对正确性的内在要求也在持续地收窄实现空间——你不需要三百条测试来防止 return True,因为你在第一条测试之前就不会考虑 return True。AI 没有这个收窄机制。你每一次加测试,都是在和一个在所有维度上同时探索漏洞的对手博弈。这个博弈不会收敛到你写够测试的那一天——它会先撞上不可维护性的墙。这也是为什么你总觉得 AI 写的 bug 有一种说不出的匪夷所思:它不是在你思维的盲区里犯了错,是在你的测试没覆盖到、但它的搜索空间扫过了的维度上犯了错。
这和测试覆盖率是同一个问题在另一个层面的表现。覆盖率度量的是”测试踩到了哪些行”,但被踩到的函数里面可以空到什么都没做,覆盖率依然是 100%。覆盖率是向后看的——它验证过去写的代码是否被执行过。AI 需要的是向前看的约束——不管它怎么写,结果必须满足某些 invariants。
那么,如果不靠层层嵌套的 unit test 来约束 AI,靠什么?
这个问题的解法,我在另一篇文章里把它叫做从过程确定性到结果确定性。核心就是把确定性从代码路径上撤回来,放到系统边界上。具体来说:不要测”怎么走的”,测”到没到”以及”路上的护栏碰没碰到”。
传统的 unit test 本质上是在定义实现路径。你写一个测试断言
PaymentService.process() 调了
SecurityLogger.log()。你是在说”沿这条路走,在这个点右转,在那个点停”。这对人的工作记忆是好的——你锁定了一个模块的行为,能专心思考下一个模块。但这对
AI 是枷锁——不是因为它没能力理解这些测试,而是因为在 TDD
的分工里,测试是人不允许 AI 改的。测试就是规格说明本身。你把实现路径用
mock 和 stub
写死在测试里,就等于把规格说明锁在了一种特定的模块划分和调用链上。AI
想换一种架构?可以——但先得改测试。而测试它动不了。
替代的做法是只测结果。不测”调没调 logger”,测”如果数据库里有一条 payment record,审计表里必须有对应的加密签名”。这就是 verify state, not behavior。无论 AI 怎么重构架构——换 logger 实现、改调用链、引入中间层——只要审计签名的 invariant 还在,测试就绿。你不再关心它走哪条路,你只关心它到了终点,而且一路上没有撞翻护栏。
这套做法不是没有名字:property-based testing、contract testing、E2E invariant check,这些概念存在了几十年。它们在人类时代始终是小众工具,因为写一个好的 invariant 比写五个 example-based unit test 难得多。但 AI 改变了这层经济关系:代码生成是极便宜的,inferred specification 的能力是前所未有的。你可以让 AI 自己读代码、生成 property test、跑一遍、看哪些 invariant 被违反了、再修实现。你做的事情只有一个:定义”什么算对”,写成 AI 能跑、能看见 red/green 的检查。
这个分工一旦建立起来,整个工程节奏就变了。我之前在给 300 篇博客加 SEO summary 的经历里完整走过一遍这个流程——先写一个 coverage 测试,让 AI 自己跑、自己看 red、自己修,修到绿为止。全程不需要我审查任何一篇具体的 summary。
在这个模式里,人的工作不再是”每个函数写什么测试、怎么 mock、覆盖率够不够”。这些是过程层面的细节,AI 可以自己处理。人的工作退到了真正需要人做决策的地方:定义系统边界和业务 invariants。什么情况绝对不能发生?什么约束不管怎么重构都不能被破坏?什么最终结果算合格的交付物?
这些是只有人能回答的问题。这和 AI 聪不聪明无关——这些问题本身没有 ground truth。它们来自业务语境、行业合规、团队共识、历史踩坑。你把这些东西写成确定性的检查——哪怕是用自然语言描述再由 AI 转译成可执行测试——就构成了 AI 的护栏。
AI 的工作是在护栏内自由探索实现路径。它怎么拆模块、怎么设计数据结构、怎么处理错误传播——这些是它擅长的。你不需要规定它”必须写一个 PaymentService 类,必须继承 BaseProcessor”。你只需要告诉它”最终 API 的 contract 是这样的,数据库的 schema 是这样的,下面这两个 invariant 不能破”。剩下的它自己搞定。碰了护栏,测试会红,它自己看见,自己回去修。
这个模式解决的恰好是 TDD 在 AI 手里的核心矛盾。TDD 的症结不是确定性约束本身有问题。问题是它被放在了不该在的地方——代码路径上,锁死了 AI 搜索正确实现的空间,同时还没有真正约束到 AI 独有的错误模式(跨模块的语义漂移)。把确定性从路径上移到边界上,空间留给 AI,约束留在真正重要的地方。
当然,写 invariants、写 contract test——这些做法仍然是用代码定义正确性。人写规则,AI 在规则里找路。但这个模式指向了一个更远的方向:有一天,人也许不需要把约束翻译成代码。人只说”这个 API 的返回值必须在某几种情况里”,AI 自己理解这句话,自己检查自己生成的代码是不是满足它。到了那一步,“人守关”的”关”不再是代码,就是人的意图本身。代价是你失去了机械确定性——没有一个编译器帮你判断 pass/fail 了。但它跨过的,是 code-level 约束永远跨不过的数量级差距。
这样做之所以可行,背后是一层经济结构的变化。人类时代里,定义路径比定义边界便宜——写五个 unit test 比写一个 formal invariant 简单太多了。AI 时代里,实现的成本趋近于零,探索路径的能力爆炸式提升,但验证的成本——尤其是让人来验证这件事——仍然很高。把人的精力从”写路径测试”转向”定义边界约束”,就是把最贵的资源(人的判断力)配置到最不可替代的位置上。
直觉上,AI 的不确定性确实让人想用确定性去约束它,TDD 看起来就是这个矛盾的最优解。但这个直觉遗漏了一个关键变量:被约束的对象有没有内在的正确性标准。人有,所以 TDD 在人手上是路标系统。AI 没有,所以 TDD 在 AI 手上是 Goodhart 的游乐场。同一个方法论,不同的操作者,不同的产物。
这不意味着不要测试。恰恰相反,AI 时代需要的测试比人类时代更重——它需要的是更高密度、更难被投机绕过的测试。只不过它的形式不再是铺满每个函数的 unit test,而是集中在系统边界的 invariants、contracts 和 E2E 验证。确定性该守在终点,不该撒在每一段路上。
但这仍然是一个中间状态。property-based testing 也好、contract testing 也好,说到底人还是在用代码定义正确性——写规则、写 invariant、写 checker。而代码能表达的约束永远是有限的:你写下的是一条条规则,AI 看到的是一整个它可以自由探索的空间。这条分界线目前仍然是开放的:再往前走一步,就是把”判断测试过没过”这件事也交给 AI——用自然语言描述验收标准,让另一个 AI 实例来判断你的 AI 生成的代码是否满足了它。这条路的灵活性是 code-level 测试永远追不上的,代价是你失去了机械确定性——测试 pass/fail 不再是一个绝对的、可重复的信号。这是一个真正的 trade-off,值得一整篇文章去展开。但不管你选哪条路,有一件事是确定的:期望靠加更多 unit test 或者微调 TDD 的流程来跨过 AI 和传统测试之间的这道 gap——这是数量级的差距,不是态度问题,不是方法论迭代能弥补的。