AI 写的测试全绿,但可能什么都没验
第 20 篇讲四层验证时,第一层是"让机器自己验",也就是跑测试。那一节里埋了一个坑,当时只提了一句:
它写的测试,可能只是在验证它自己的实现。
这篇把这一句展开——因为这可能是 AI 协作里最隐蔽、也最危险的一个坑。
文章目录
- AI 写的测试全绿,但可能什么都没验
- 零、先说一个反转:测试的地位变了
- 一、核心问题:它在验证自己
- 根本原因
- 一个真实的例子
- 这不是它偷懒,是它的天然倾向
- 二、四种"看着在测,其实没测"
- ① 只测正常路径
- ② 断言太弱
- ③ 测了实现细节
- ④ 构造一个必然通过的场景
- 三、判断方法:读场景,不读断言
- 三个可以直接用的提问
- 一个狠招:让它自己反向验证
- 四、怎么让它写出有用的测试
- 关键动作:给规则,不给代码
- 一个加速技巧:规则清单先有,代码后写
- 五、测试通过之后
- 六、一个更深的视角:测试是需求的显式化
- 测试的本质:用代码写出来的需求
- 为什么在 Agent 时代这一点变得关键
- 一个副产品:它是检验"我自己想清楚没有"的工具
- 一个可以直接用的检查清单
- 七、最后:测试的价值不在"通过"
零、先说一个反转:测试的地位变了
在 Agent 参与开发之后,测试这件事的位置发生了根本变化。
| 以前 | 现在 | |
|---|---|---|
| 代码是谁写的 | 我 | 它 |
| 我对代码的了解 | 边写边想,心里有数 | 隔了一层,只能看结果 |
| 测试的角色 | 辅助(补充验证) | 唯一能依赖的抓手 |
关键在这一行:
以前你写代码,所以你对"它对不对"心里有数;现在代码是生成的,你唯一的依据就是测试。
这就带来一个直接后果:
测试没写好,等于整条验证链的根断了。
因为第 20 篇讲的四层验证里,第一层是"让机器自己验"——它是成本最低、覆盖最广的一层。后面三层(行为验证、边界异常、机械指标)都是"抽查",只有测试是"每次都跑"。
而正是在这个位置上,AI 写的测试有个系统性的缺陷。
一、核心问题:它在验证自己
根本原因
它写的测试和它写的代码,出自同一个"想法"。
想象一下:它读需求 → 在心里形成一个理解 → 按这个理解写了实现 → 再按同一个理解写了测试。
测试和实现共享同一个前提假设。
如果这个假设本身就错了,那么实现错了,测试也会跟着一起错——而且是"刚好错得一致"。
所以测试全绿。而它绿,是因为两边一致,不是因为它对。
一个真实的例子
第 20 篇里我举过这个例子,这里完整讲一遍。
需求是:库存不足时,下单必须失败。
它写的测试是这样的(简化):
@Testvoid库存不足时下单应该失败(){// 手动把库存改成 0book.setStock(0);bookMapper.updateById(book);// 下单assertThrows(BizException.class,()->orderService.create(orderDTO));}这个测试完全合理,而且永远通过。
但它没验到真正的东西。
因为真实的超卖风险不长这样——真实场景是:
库存 = 1 请求 A 读到 stock = 1 → 通过检查 请求 B 读到 stock = 1 → 通过检查 ← 两个都没超 请求 A 扣减 stock,写入 0 请求 B 扣减 stock,写入 0 ← 卖了两件,但库存只有 1这个测试用"手动把库存设成 0"绕过了真正的难点——那个难点是"两个请求同时读到了同一个 1"。
它是无意的:在它的理解里,“库存不足"就是"库存等于 0”,所以它就这么构造了输入。
测试绿了,bug 还在。
这不是它偷懒,是它的天然倾向
要强调一下:这不是它能力不行,也不是它想糊弄你。
这是它的天然倾向——因为它的目标函数是"让这个任务看起来完成了",而一个和实现自洽的测试,是达成这个目标最省事的路径。
所以在第 21 篇我就说过一条规矩:
不要让同一会话既生成又检查。它会倾向于确认自己的产出。
测试,正是这条规矩最容易被违反的地方——因为你通常会让它"顺便把测试也写了"。
二、四种"看着在测,其实没测"
除了上面那个例子,还有四种常见形态。它们的共同点是:测试通过率 100%,信心增量 0。
① 只测正常路径
@Testvoid发布图书(){assertDoesNotThrow(()->bookService.publish(dto));}只有一条最顺的路,一个错误分支都没有。
而真实系统里,出问题的绝大多数是错误分支:参数为空、超长、重复提交、权限不够、并发。
判断:如果这个测试文件里看不到assertThrows,基本可以判定它没认真测。
② 断言太弱
@Testvoid查询图书列表(){Resultresult=bookService.list(query);assertNotNull(result);// ← 就这一句}assertNotNull是这个测试里最没用的一句话。它只能证明"方法没崩",证明不了**“返回的东西是对的”**。
它应该断言的是:返回了几条、第一条的标题是不是我发布的那个、被删除的书有没有被过滤掉。
判断:断言里如果只有notNull/isTrue这类"弱断言",那这个测试几乎等于没写。
③ 测了实现细节
@Testvoid应该调用两次mapper(){orderService.create(dto);verify(bookMapper,times(2)).updateById(any());}这个测试测的是"它怎么写的",不是"它做对了吗"。
后果是极其脆弱:只要重构一下(把两次更新合成一次批量更新),功能完全正确,测试却红了。
判断:如果"改写法不改行为"会让测试失败,这个测试就在测实现细节。
④ 构造一个必然通过的场景
这就是库存那个例子的本质。
特征:测试的通过不依赖于被测逻辑的正确性——你把实现改坏,它照样过。
这个最危险,因为它看起来最像真的。
三、判断方法:读场景,不读断言
第 20 篇给过一句简短的判断:
读测试的场景,不是读断言。
为什么:断言是"它认为自己应该检查什么",而场景是"它认为这件事真实长什么样"。后者才暴露它的理解对不对。
三个可以直接用的提问
看一个测试,问这三句:
| 问题 | 说明什么 |
|---|---|
| 测试名字里的场景,是我真实会遇到的吗? | 像testAdd这种名字,基本可以跳过 |
| 它构造的输入,是真实数据还是"专门为了通过"? | 手动把值设成极端值,往往是回避难点 |
| 我把实现改坏,这个测试会红吗? | 这一条最狠,见下 |
一个狠招:让它自己反向验证
这是我从"变异测试"里简化出来的招,非常好用。
原理很简单:一个好测试的标志是"它能失败"。
所以直接让它去尝试弄坏自己的实现:
请做一件事: · 把刚才那个功能的核心逻辑故意改坏(比如把库存判断条件反过来写) · 跑一遍测试 · 告诉我:哪些测试变成了红色,哪些还是绿的 还是绿色的那些,说明它们没有覆盖到这段逻辑。它给出的"哪些还是绿的"这份清单,就是测试的漏洞地图。
这个招好用在哪:
- 成本极低(一句话)
- 结果确定(红/绿是事实,不依赖你的判断力)
- 它做这件事的能力和写代码一样强
⚠️ 一个注意点:改完记得让它改回来——或者更好的做法是先 commit 再实验(第 21 篇的规矩)。
四、怎么让它写出有用的测试
到这里,问题清楚了:它按"实现"推导测试,所以测试和实现一起错。
解法就是换一个推导源头:让它从"规则"推导测试,而不是从"代码"推导。
关键动作:给规则,不给代码
不要说:
给 BookService.createOrder 写单元测试(这句话让它去读代码,然后按代码的理解写测试——又回到老路了。)
要说:
这个下单功能的业务规则如下: 1. 库存不足时,下单必须失败,且不能产生订单 2. 库存充足时,下单成功后库存减少相应的数量 3. 同一个用户短时间内重复提交,只能产生一个订单 4. 两个请求同时购买最后一件,只能有一个成功 请为每条规则写一个测试。 每个测试开头用注释说明"这条对应上面的第几条规则"。 写完告诉我:哪条规则你没能写出有效的测试。这个改动的三层价值:
| 改动 | 作用 |
|---|---|
| 给规则不给代码 | 切断"从实现推导"的路径 |
| 编号 + 对应关系 | 你能逐条核对覆盖度(一眼看出漏了哪条) |
| 问"哪条写不出" | 它会主动暴露难点——比如第 4 条并发,它多半会告诉你"这个测不了" |
最后那句是精髓。
它说"这条我测不了",比它给你一个假测试有价值一百倍。
因为它主动告诉你的不确定,是你唯一能拿到的、准确的风险清单。
一个加速技巧:规则清单先有,代码后写
顺序很重要:
1. 先让它把业务规则列出来(这时候还没有代码) 2. 你核对规则(这一步你一定会发现它漏了什么) 3. 再让它按规则写实现和测试为什么这样更好:在第 1 步它还没有"实现"这个包袱,列规则的视角更接近需求本身。而第 2 步你介入的成本极低——改一句话,比改一堆代码便宜太多。
五、测试通过之后
最后要说清一个边界:测试是四层里的第一层,不是全部。
测试全绿只说明一件事:在你写到的那些场景里,逻辑是自洽的。
它不说明:
- 需求本身理解对了(可能规则就写错了)
- 集成起来是对的(单元测试不覆盖层间问题)
- 前端展示是对的
- 数据真的落库了
- 权限是对的
所以第一层过了,第二层(跨 UI / 接口 / 数据的实际行为验证)不能省——那是第 20 篇的核心内容。
一句话分工:
测试负责"逻辑自洽",你负责"需求对不对"和"真的跑起来对不对"。
这两件事不能互相替代——测试再多也不告诉你"这功能是不是用户要的"。
六、一个更深的视角:测试是需求的显式化
上面讲的是"怎么让测试有效"。但如果你往后退一步看,会发现这件事的意义比"测出 bug"大得多。
测试的本质:用代码写出来的需求
想清楚一件事:
一条测试,本质上是一句"需求"的机器可执行版本。
"库存不足时下单必须失败"是一句需求;那个assertThrows就是它的可执行形式。
区别在于:需求可以用自然语言含糊地说,而测试必须精确到"什么输入、什么预期"——含糊不了。
所以写测试的过程,就是一个"把模糊需求逼成精确表述"的过程。
为什么在 Agent 时代这一点变得关键
在人自己写代码的时代,需求 → 代码 → 验证,这条链是在你脑子里走的。中间可以模糊——因为你记得自己的意图,缺的那块你自己会补上。
现在中间那一环换成了它,你脑子里的"默会知识"传不过去了。于是:
以前可以含糊的地方,现在必须明说。
而"写测试"恰恰是逼你说清楚的最有效手段——
因为你说"下单要防止超卖",它可能理解成"检查库存够不够"。
但你说"请写一个测试:两个请求同时买最后一件,只能有一个成功"——
它必须面对那个"同时",而你也必须面对"我到底要它怎么处理并发"。
这句话一说出来,需求就再也没法含糊了。
一个副产品:它是检验"我自己想清楚没有"的工具
这是我觉得最有价值的一点。
测试写不出来,很多时候不是测试的问题,是需求本身没说清。
上面那个"请为每条规则写测试,并告诉我哪条写不出"的要求,其实同时也在测你自己:
| 它说 | 说明 |
|---|---|
| “这条我能测” | 这条规则是清晰的 |
| “这条我测不了” | 要么规则模糊,要么你还没想清楚它该怎么表现 |
| “这条我理解有两种可能” | 需求有歧义,需要你裁决 |
所以"让它写测试"这个动作,实际上是你自查需求质量的一次机会。
⚠️ 这也是为什么我把它放在写实现之前——在第 1 步就想清楚,比在第 10 步返工便宜得多。
一个可以直接用的检查清单
读它交上来的测试时,按这个顺序过一遍:
| # | 检查项 | 不合格的信号 |
|---|---|---|
| 1 | 有没有错误分支 | 整个文件找不到assertThrows/ 异常断言 |
| 2 | 断言够不够具体 | 只有notNull/isTrue这类弱断言 |
| 3 | 场景是不是真实的 | 输入是"为了通过"构造的极端值 |
| 4 | 是不是在测实现细节 | 改写法不改行为,测试就红 |
| 5 | 能不能失败 | 用"反向验证"那招试一次 |
五条里有一条不合格,这个测试就不能作为"验证通过"的依据。
七、最后:测试的价值不在"通过"
回到开头那个反转。
在 Agent 时代,测试的重要性是上升的——因为它是"让机器自己验"的唯一手段,是你从"逐行读代码"里解放出来的前提。
但这也意味着:
测试的质量,直接决定你解放得彻不彻底。
测试写得实,你可以放心不看代码;测试写得虚,你等于什么都没验,却以为验过了。
而后者比"没有测试"更危险——因为没有测试你会警惕,有了一堆全绿的测试你会放心。
所以判断一个测试好不好,标准只有一条:
看它能不能失败。
一个永远通不过失败考验的测试,等于没有测试。
这句话反过来用,就是我给你最实在的建议:
别问"测试通过了吗",问"它有没有可能失败、以及在什么情况下会失败"。
答不上来,那一片绿色就不是保证,是心理安慰。
互动时间:你让 AI 写过测试吗?有没有检查过"这些测试到底测住了什么"?我最有价值的一次发现就是那个库存的例子——测试全绿,但把库存判断删掉它还是全绿。从那以后我改用"反向验证"那一招:让它先把逻辑改坏,看哪些测试还能过。你试过类似的思路吗?评论区聊聊。
系列回顾:《用 Agent 最贵的不是订阅费:一份被算错了很多次的成本账》 · 《这几类活,我不交给 Agent 做:划边界比找场景更重要》
作者另有 18 篇运维实战文章(部署 / 监控 / 排查 / 备份 / 安全),见 个人主页。