본문으로 건너뛰기
AI 时代的 TDD:先让模型撞上红灯 的文章封面图

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

AI 보조

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

ℹ️이 페이지는 아직 번역되지 않았습니다. 중국어 원문을 표시합니다.

先说结论

手绘 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

댓글

목차

AI 时代的 TDD:先让模型撞上红灯 | Yu의 사이버 데스크