Aller au contenu principal
AI 时代的 TDD:先让模型撞上红灯 的文章封面图

AI 时代的 TDD:先让模型撞上红灯

Assisté par IA

AI 编程里为什么还需要 TDD。重点不是形式上的“先写测试”,而是先制造一个可验证的失败,再让实现从红变绿。

ℹ️Cette page n'a pas encore été traduite. Le contenu original en chinois est affiché.

先说结论

手绘 AI 代码机器被红色测试灯拦住,旁边有戴青绿色护目镜的金黄色作者头像角色
AI 可以很快交付代码,但 TDD 要先让它撞上一个可验证的红灯。

AI 写代码以后,TDD 不是过时了,而是换了一个位置。

过去我们说 TDD,常常是在说程序员的自律:先写测试,再写实现,小步重构。到了 AI 编程里,它更像一套刹车系统。因为模型最擅长的事,正好也是最危险的事:它能很快写出一大段看起来完整的代码。

你让它实现一个功能,它可能十几秒就给你:

  • 一个实现文件
  • 一组测试
  • 一段解释
  • 一句“已完成”

问题是,“看起来完整”不是工程意义上的完成。工程意义上的完成,至少要回答:

这个行为有没有被一个明确的失败测试定义过?
这个失败有没有因为实现而变绿?
变绿以后,我们有没有在不改行为的前提下整理代码?

这就是 AI 时代重新谈 TDD 的原因。

它不是为了让流程显得高级,而是为了把“相信模型”换成“相信反馈”。

一、常见误解:TDD 不是“先写测试”

手绘测试纸条转向红色失败信号,作者头像角色指向红灯
TDD 的重点不是测试文件出现得早,而是失败反馈出现得足够早。

很多人讨厌 TDD,是因为他们理解成了一个仪式:

先写测试。
再写代码。
最后跑一下。

这当然无聊,而且很容易变成形式主义。

真正有用的 TDD,不是“测试文件出现得比较早”,而是“失败出现得足够早”。

关键不是测试,而是红

TDD 的第一步叫 RED,不叫 TEST。

RED 的意思是:先写一个测试,让系统明确失败。这个失败必须满足三件事:

  1. 它确实失败。
  2. 它因为目标行为不存在而失败。
  3. 它失败的方式和你的预期一致。

如果没有先看到红,后面的绿就没有意义。

比如你要实现 slugify("Hello World") -> "hello-world"。一个有价值的 RED 不是“我写了个测试文件”,而是:

Test: slugify.test.ts
Command: npm test -- slugify
Failure: slugify is not defined
Reason: 目标函数还不存在,符合预期

这时测试才变成了规格。它告诉你:下一步实现只需要让这一条行为成立。

先绿再补测试,通常是在补故事

AI 很容易走另一条路:先写实现,再补测试。

这在体验上很顺。你看到代码已经跑了,测试也有了,心里会觉得“差不多”。但它有一个致命问题:测试很可能只是对当前实现的追认。

它不是在问“需求应该是什么”,而是在问“当前代码怎么写才容易通过”。

这就是为什么 AI 写测试经常会出现这些味道:

  • 断言太贴合当前实现
  • mock 太多,真实边界没测到
  • 只测 happy path
  • 为了让现有代码通过,把断言写得很宽
  • 没有一个测试能证明旧代码原本是错的

TDD 要反过来:先让需求变成失败,再让代码追上需求。

二、为什么 AI 时代更需要 TDD

AI 编程的核心矛盾,不是“代码写得慢”,而是“反馈来得晚”。

没有 TDD 时,你通常是这样工作:

描述需求 -> AI 写一堆代码 -> 人肉看 diff -> 跑一下 -> 发现问题 -> 回头修

问题会堆到最后。等你发现它错了,可能已经有三类东西混在一起:

  • 需求理解错了
  • 实现路径错了
  • 重构把旧行为改坏了

TDD 的作用,是把这个长链条切短。

它给模型一个可判定目标

“写得优雅一点”不是目标。

“实现用户登录态过期后自动跳回登录页”也还不够具体。

更好的目标是:

当 access token 过期时:
1. 请求返回 401。
2. 客户端清理本地 session。
3. 用户被重定向到 /login。
4. 原始目标地址被保存在 redirect 参数里。

再往前一步,把其中一条变成失败测试:

given expired session
when user opens /settings
then app redirects to /login?redirect=/settings

这时 AI 不再是在猜“登录态过期应该怎么处理”,而是在完成一个明确行为。

它把大任务拆成小闭环

AI 最容易失控的地方,是一口气做完。

一次性让它实现登录、权限、刷新 token、错误提示、路由跳转,最后你会得到一个很大的 diff。它也许能跑,但 review 成本很高。你要同时判断业务、状态、路由、边界、测试和重构。

TDD 的节奏更像这样:

一个行为 -> 一个失败测试 -> 最小实现 -> 变绿 -> 再下一个行为

每次只推进一小段。小到你能看懂,小到 AI 不容易编故事,小到失败时能快速定位。

它限制模型“顺手发挥”

AI 的一个常见问题是热心过度。

你让它修一个边界 bug,它顺手抽了 helper;你让它加一个测试,它顺手改了实现;你让它重构,它顺手改了行为。

TDD 用阶段把这些动作分开:

阶段可以做什么不该做什么
RED写一个失败测试写生产实现
GREEN写最小实现改测试凑绿
REFACTOR整理结构引入新行为

这张表比“请谨慎一点”有用。它让模型知道现在处在哪个阶段,也让人类更容易发现越界。

三、红绿重构:三道门,不是三句口号

手绘红灯、变绿、重构三道门,作者头像角色拿着检查板
红绿重构更像三道门:先证明缺口,再让当前行为通过,最后只整理结构。

“Red, Green, Refactor” 很容易被说成口号。真正用起来,它应该像三道门。每过一道门,都要留下证据。

第一门:RED,证明需求还没被满足

RED 阶段最重要的问题是:

这个测试如果失败,是否能证明我们还缺一个目标行为?

一个坏 RED:

expect(true).toBe(true)

一个也不太好的 RED:

expect(formatTitle("Hello World")).toContain("hello")

它太宽了。很多错误实现也能通过。

更好的 RED:

expect(slugify("Hello World")).toBe("hello-world")

这条测试很小,但它清楚。它指定了输入、输出和行为。

第二门:GREEN,只让当前测试通过

GREEN 阶段不是写最终架构。

它的任务只有一个:用最少代码让当前失败测试通过。

这句话听起来反直觉。很多人会担心“最少实现”会不会太丑。会,有时候会丑。但它的价值在于保持设计压力。

如果第一条测试是:

expect(slugify("Hello World")).toBe("hello-world")

一个可以接受的 GREEN 可能只是:

export function slugify(input: string) {
  return input.toLowerCase().replace(/\s+/g, "-");
}

你不需要立刻支持中文、重音符号、连续标点、emoji、SEO 特例。那些应该由后面的测试推动。

第三门:REFACTOR,只改结构,不改行为

REFACTOR 阶段最容易被 AI 搞混。

它会把“整理代码”理解成“顺便增强一下”。这不行。重构的定义很窄:外部行为不变,内部结构变好。

好的重构像这样:

  • 改一个更准确的变量名
  • 抽出重复表达式
  • 拆掉过深的条件分支
  • 移动函数位置,让模块职责更清楚

坏的重构像这样:

  • 顺手支持了新输入
  • 顺手改了错误提示
  • 顺手换了依赖
  • 顺手改了测试断言

判断标准很简单:

如果这个提交只叫 refactor:,测试前后应该一样绿,用户行为也应该一样。

四、好测试的味道

TDD 不是测试越多越好。AI 也很擅长生成一堆没什么价值的测试。

更重要的是测试的味道。

好测试像规格

好测试读起来应该像一句业务规格:

当用户没有权限时,保存按钮不可点击。
当标题为空时,表单显示错误信息。
当重复提交同一个请求时,只创建一条记录。

它关心的是外部行为,而不是内部怎么做。

坏测试则像实现笔记:

应该调用 validateInput 三次。
应该读取 state.user.flags。
应该触发 handleClick 内部函数。

实现细节被绑住以后,重构就会很痛。你只是改了内部结构,测试却大面积失败。这样的测试不是保护代码,而是在冻结代码。

好测试有边界

一个测试最好只回答一个问题。

如果一个测试同时断言:

  • 格式正确
  • 权限正确
  • 网络请求正确
  • toast 文案正确
  • 数据库状态正确

它失败时你很难知道问题在哪。

AI 特别容易写这种“大而全”的测试,因为它想一次证明很多东西。TDD 要反过来:一个行为,一个失败,一个实现。

好测试会让实现难以作弊

如果测试只覆盖一个过于特殊的输入,AI 可能写出刚好匹配的假实现。

比如:

export function slugify(input: string) {
  return "hello-world";
}

第一条测试能让它过,但第二条测试就会逼出真正逻辑:

expect(slugify("Test Driven Development")).toBe("test-driven-development")

所以 TDD 不是永远只写一个测试,而是每一轮只新增一个行为压力。压力逐步增加,设计逐步长出来。

五、AI 会怎么绕过 TDD

手绘三种 AI 绕过 TDD 的方式:改测试、过拟合、乱 mock,作者头像角色拿着检查清单
当 AI 试图改测试、写过拟合实现或用 mock 掩盖边界时,要把流程拉回证据。

这部分必须讲清楚,因为 AI 不会天然尊重测试。

它的优化目标很简单:完成你刚才说的任务。如果你说“让测试通过”,它可能会做出一些人类不想要的动作。

第一种:改测试凑绿

最典型:

  • toBe("hello-world") 改成当前输出
  • 删除失败断言
  • 给测试加 skip
  • 把严格断言改成宽松断言

这不是 TDD,这是把红灯拆掉。

第二种:写过拟合实现

比如测试只有一个输入:

expect(slugify("Hello World")).toBe("hello-world")

模型可能写出:

if (input === "Hello World") return "hello-world";

这时你不需要骂它。你需要继续加下一条行为,让实现无法继续硬编码。

第三种:用 mock 盖住真实边界

AI 很喜欢 mock。mock 让测试容易写,也让很多真实问题消失。

不是说不能 mock,而是要问:

我现在 mock 掉的,是慢依赖,还是我真正想验证的边界?

如果你要验证支付回调解析,却把解析层 mock 掉了,测试就没有意义。

六、什么时候不该用 TDD

TDD 有价值,但不是所有事情都值得套。

不适合的场景

  • 纯视觉微调
  • 一次性脚本
  • 技术探索 demo
  • 需求本身还没想清楚的原型
  • 测试框架还没搭好的仓库

这些场景先追求探索速度,不要被流程拖住。

适合的场景

  • bug 修复
  • 权限、计费、状态机
  • 数据转换和边界处理
  • 会长期维护的核心模块
  • AI 会反复修改的代码路径

判断标准不是“这个功能大不大”,而是:

如果它错了,代价是否明显?

代价明显,就值得先写测试。

七、一个可执行的心法

如果只给 AI 一句话,我不会说:

请高质量实现这个功能。

我会说:

先写一个失败测试,运行它,确认失败原因符合预期。不要写实现,直到我说 go。

这句话的质量更高,因为它不是要求模型“表现好”,而是要求它进入一个可检查的流程。

再完整一点:

每轮只处理一个行为。
RED:写一个失败测试并运行。
GREEN:写最小实现,不改测试。
REFACTOR:只在绿色状态下整理结构。
每轮报告测试文件、命令、失败原因、通过结果。

这就是 AI 时代 TDD 的核心。

不是迷信测试,不是迷信流程,而是让每一步都有证据。

收尾

AI 编程最需要的不是更多代码,而是更短的反馈。

TDD 的价值就在这里:它把“我觉得应该对”变成“这里有一个失败,后来它变绿了”。这个变化很小,但足够真实。

如果你只记一句话,记这个:

不要让 AI 直接交付代码。先让它交付一个红灯,再让它把红灯变绿。

下一篇 实战指南 会把这套节奏变成可以直接复制的工作流。

推荐资源

TDD, AI agents and coding with Kent Beck
Pragmatic Engineer 访谈 Kent Beck。重点是小步反馈、测试保护和 AI 编程里的工程纪律。YouTube

Augmented Coding:超越氛围

Kent Beck 用 AI agent 写代码时的第一人称复盘,核心是把 vibe coding 变成工程化协作。

Kent BeckTidy First

Red/green TDD

为什么 red/green TDD 会成为 coding agent 能理解、能执行的紧凑工作指令。

Simon Willisonsimonwillison.net

《测试驱动开发》by Kent Beck

定义红绿重构循环的经典书。理解 TDD,最终还是要回到小步反馈。

Kent BeckAmazon

Commentaires

Table des matières

AI 时代的 TDD:先让模型撞上红灯 | Le Bureau Cyber de Yu