跳到主要内容
测试全绿?恰恰该紧张了

测试全绿?恰恰该紧张了

Kevin
Kevin

· 阅读约 9 分钟

这篇不搭 agent,也不搭 workflow,搭一个更底层的东西:一份用来验收 AI 生成代码的检查流程。跑完你手里会多一个五步清单,下次再拿到一段测试全绿、CI 通、lint 干净、AI 自己解释得头头是道的代码,你不会被这整套全绿信号带着走。

开搞之前先确认:

  • 你已经在用 AI 写代码,而且合入前会看一眼 diff,哪怕只是自己的 repo
  • 你愿意接受一个有点反直觉的前提:测试全过不是安全信号,是“该去查假设”的信号

都齐了咱们就开搞。

Step 1:全绿凭什么比挂红更吓人

两周前在 DEV 上刷到一篇聊 AI 代码风险的文章,里面那个案例太典型了:AI 生成的一段状态更新,测试全过,合进去了,后来才发现它打破了一条系统不变量——另一块相关的状态没跟着一起更新。作者原话我记不清了,但核心意思我记得很牢:最危险的 AI 代码,不是一跑就崩的那种,是全部测试通过、但里面藏着错误假设的那种。

这话我得先摊开讲,后面五步全靠它撑着。

明显坏掉的代码其实不可怕。测试挂了、构建失败了、日志里全是报错,你会停下来。就算没拦住合进去了,业务用两天就会锤你。它坏得显眼,生命周期短,整改也快。

错误假设就不会这样。它不亮红灯,直接变成你管道里的绿灯。这里有个要命的死结:测试只验证你想到要检查的行为,AI 写代码也只实现它认为你要的东西。你没想到“状态和权限必须同步更新”,它就不会同步更新;两边都没有测试断言这条规则——于是测试全绿,逻辑自洽,业务上埋着一颗雷。

风险不来自“代码坏了”,来自“坏的方式恰好是你没测试的那个维度”。挂掉的测试是显眼的,全绿的测试是寂静的。从那以后我就不再把绿灯当成“可以合入”的信号,而是“该去查假设”的信号。

Step 2:看个具体的例子

空口说没用,咱们直接上代码。假设你让 AI 写一段用户状态流转,它给你的是这个:

// 用户状态从 pending 转 active
function activateUser(id) {
  const user = db.users.get(id)
  if (user.status !== 'pending') return

  updateUser(id, { status: 'active' })
}

配的测试长这样:

test('pending 用户可以被激活', () => {
  activateUser(123)
  expect(getUser(123).status).toBe('active')
})

跑一遍,绿,稳,能合。

但问题来了:这个系统里,status 和 permissions 是有绑定关系的——用户从 pending 转成 active 的那一刻,权限得重新计算,否则就会拿着旧的、可能是过期甚至越权的权限继续干活。上面这段代码,status 更新了,permissions 连碰都没碰。

单测没抓住它,理由很简单:没人写“status 变的时候 permissions 必须跟着变”这条断言。而写这条断言的人,得先知道这条规则存在。AI 知道吗?不知道。它只会实现你让它实现的东西。你连自己都没想起来的那条规则,它更没有义务替你守护。

⚠️ 这一步很多人会卡在“这不就是个漏写吗”——漏写会被测试抓住,关键是这段代码的测试是绿的。没有测试覆盖的漏写,是会大摇大摆过验收的。它比普通漏写难排查十倍,因为你永远不回去查一个已经通过的东西。所以看到全绿别直接点合入,先问自己一句:测试真的覆盖了我要守的那条规则吗?

对了,真实项目里这种缺陷通常藏得更深。权限不一定是同步更新的,可能是事件驱动的,可能是缓存在 Redis 里的,可能是另一个微服务管着的。status 已经切成 active 了,那套旧权限还在别的系统里继续跑。

Step 3:把审查重心从“总结”挪到“假设”

那 review 的时候看什么?我的答案跟那篇文章一样:别看 AI 的变更总结。

AI 给的那段解释是最不可靠的证据。它会用一套逻辑自洽的语言告诉你为什么改这些、怎么改的、测试怎么过的——通篇挑不出毛病,问题是它答的是它自己的题目,不是你业务的题目。有问题的代码往往能配套一份完美的解说词,这恰恰是最阴险的部分。你 review 的是一份它给自己写的辩护词,不是代码本身。

所以看 diff 的时候,心里过这几句:

  • 这段改动要让什么“必然为真”才能是安全的?——这是它依赖的不变量
  • 哪些前提看起来“显然成立”,但我从头到尾没看到任何代码或测试在守护它?
  • 如果这个改动错了,它会以什么方式暴露?——是立刻挂,还是静默三个月变成线上事故?

第三问最狠。如果你答不出来它的失败方式,说明你对它的假设还不够清楚。一个连失败模式都描述不出来的改动,不该合。

Step 4:五步清单,抄作业版

这是我从那篇文章里提炼出来、再掺上自己 review 血泪的五步,直接抄:

  1. 用一句话写出这段代码解决的问题。 写不出来的改动,AI 也不知道它自己为什么要写这段,大概率是它猜的。
  2. 列出它默认成立、但没有显式断言的假设。 “用户存在”“权限不变”“时间戳是新的”“缓存和数据库一致”——列出来你就知道有多少东西是没人管的。
  3. 找到它依赖的不变量,然后确认有没有对应的检查。 没有检查的不变量,等于没有不变量。
  4. 走一遍 unhappy path。 权限不足、重复提交、半状态、并发穿行,每走一遍都会戳破一个“默认成立”。问 AI“你犯了哪些错”它是不会承认的,但你把具体场景塞给它,它能列出一堆边界条件——因为你不问,它就不会主动说。
  5. 关掉 AI 的对话窗口,你还能不能跟别人解释清楚这段改动? 不能的话,说明这段代码只活在你们俩的对话历史里。三个月后同事接手,他面对的就一行“AI 写的别乱删”,那是灾难。

第 5 步是我最近才加上的,以前完全没有这个意识。我们现在太依赖对话上下文了,改完代码上下文一清空,这段代码就成了孤儿。你 review 的时候如果发现自己“得问问 AI 当时怎么想的”,这改动就没到可合入的状态。

还有一条关于 prompt 的建议也顺手抄了:让 AI 写代码之前,先让它猜你的盲区。别只说“实现这个功能”,改成——

这是我要实现的场景和约束,我目前能想到的规则都写在这里了。我漏了什么假设?哪条规则我没提到,但一旦代码违背就会出问题?

你让它“实现”,它进入执行模式;你让它“找漏”,它才会开始翻边界条件。同一个模型,两种问法,出来的东西不一样。

Step 5:AI 不会为假设负责,但我会

这里有个内部矛盾我先坦白:我之前写过一篇自动巡检 PR 的 agent 教程,就是让 AI 去审 AI 生成的 diff。有人会问这不自相矛盾吗——信不过 AI 审代码,却信 AI 审代码?想深一点其实不矛盾。AI 擅长抓“明显的破坏”:diff 和描述对不上、空值没处理、缺边界检查。但让它审“错误的假设”,它和写代码的那个 AI 共享同一个盲区——它不知道你业务里那条没写进任何代码的规则。所以那条自动巡检的 agent 是兜底的下限,今天这份清单才是兜底的上限。

本来写到这我还想把这套检查做成自定义 lint 规则,以后自动跑。但想了想,不变量本质上就是业务语义,“状态变的时候权限必须跟着变”这句话在代码里根本没有实体,你没法 lint 一个不存在的东西。也许以后能做成某种约束描述文件,让 AI 自己对照着检查——但那个方案我还没想清楚,不硬塞了。

回到责任。AI 不会为假设负责,它没这个能力,也没这个动机。它不会因为合入后出了事故被追责,被追责的是人。代码写得越多,AI 的产出占比越高,这份验收就越值钱。测试全过不代表验证完成,验证完成的标准是你自己把假设过了一遍,拍板愿意为它兜底。

那篇文章最后有个说法我特别认同:测试全过同时编码了错误假设的代码,给你的不是信心,是虚假的信心——它会变成一笔你不知道在哪里的技术债,悄悄长,等你发现的时候已经没人敢动它了。

到这里,这份验收流程就搭完了。跑起来了吗,照这个清单自查:

  • 你上一次合入的 AI 代码,现在能不能用一句话说出它解决的问题?
  • 它默认了什么“显然成立”的事?你列出来了吗?
  • 它依赖的不变量,有没有任何代码或测试在守护?
  • 关掉 AI 的聊天记录,你现在还能不能解释它为什么那么写?

都打勾了,这篇就算交付。接下来可以试着做两件事:一是把这五步沉淀成你自己的 CLAUDE.md 验收规则,让 AI 每次改完自己先过一遍;二是下次拿到全绿代码,先按清单审完再合,看看能拦下多少以前直接合掉的货色。第一件容易,第二件做完你可能会跟我一样改不掉这个习惯——我现在看到绿灯的第一反应不是放心,是想问一句“你到底默认了什么”。