一个模块的单测覆盖率从 46% 涨到 91%,用的是助手批量生成测试。三周后同一个模块出了一个线上缺陷:入参为负数时金额算成了正数。缺陷路径在覆盖率报告里是绿色的——那一行确实被"执行"过,只是没有任何断言关心它的结果。
这是 AI 生成测试的典型问题:它擅长让代码被执行,不擅长判断什么是错的。生成器看到一段实现,能推出"输入什么会进入这个分支",但它推不出"这个分支的结果在业务上应该是什么"。
1. 生成测试的三类产出
同一条提示词,产出的测试质量差异极大,可以分成三类。
| 类型 | 内容 | 能拦住什么 | 占比(典型) |
|---|---|---|---|
| 执行型 | 构造入参、调用方法、不崩就算过 | 空指针、类型错误、异常逃逸 | 最多 |
| 断言型 | 对返回值做具体断言 | 逻辑回归、边界错误 | 中等 |
| 规格型 | 断言业务规则与不变量 | 需求理解错误、边界语义 | 很少 |
三类的成本也不同:第一类几乎零成本,第三类需要人先想清楚规格。所以生成测试的价值上限由人给定的规格决定,而不是由模型能力决定。同一条提示词在"有规格表"和"没规格表"两种输入下,产出质量差一个档位。
第一类几乎没有价值,但它最容易被生成——因为它不需要知道"正确的答案是什么"。一个 assertDoesNotThrow(() -> svc.calc(-1)) 就是典型:它证明了方法不抛异常,除此之外什么都没证明。
判断一份生成测试属于哪一类,只要看它的断言部分:断言是"不抛异常"或"非空",就属于第一类;断言是具体数值或状态,属于第二类;断言是"金额不能为负""同一订单不能重复扣款"这类业务规则,才属于第三类。
2. 生成器天然缺失的三样信息
| 缺失项 | 为什么缺 | 后果 |
|---|---|---|
| 业务语义 | 实现里没有写"为什么" | 断言写成"等于当前实现的输出" |
| 边界清单 | 实现只写了正常路径 | 负数、空、超长、并发都不测 |
| 失败预期 | 异常分支常被省略 | 错误路径永远是绿的 |
第一项造成的是最坏的一种测试:把当前实现的输出当成正确性标准。实现里有 bug,测试就把 bug 固化下来,此后任何修复都会让测试变红。这类测试不但没有价值,还有负价值——它把缺陷变成了契约。
// 反例:把实现输出当标准,bug 被固化为"期望行为"
@Test void calcAmount() {
assertEquals(-150.0, svc.calcAmount(-100)); // 负数入参算出负数,但本应报错
}
// 修复后(负数入参抛 IllegalArgumentException)这条测试会失败
第一项有个更隐蔽的变体:生成器会把断言写成"跟另一个方法的结果比较",比如 assertEquals(svc.calc(a), svc2.calc(a))。这种"对比测试"在两边都有同一个 bug 时永远通过,它验证的是"两个实现一致",而不是"结果正确"。
第二项可以用清单强制补齐,第三项只能靠人补——因为"应该失败"这个预期,只有知道业务契约的人才写得出来。
3. 一份可用的生成测试长什么样
有效的做法不是禁止生成,而是约束生成的形状。
| 约束 | 具体做法 | 效果 |
|---|---|---|
| 先写规格再生成 | 人工给出输入输出对照表 | 断言有真实基准 |
| 强制边界清单 | 空、负、极值、重复、并发五项逐项回答 | 覆盖边缘 |
| 错误路径单独要求 | 每个抛异常的分支必须有一条测试 | 失败路径不再全绿 |
| 禁止断言实现细节 | 不断言私有方法、不断言日志文本 | 减少脆性 |
| 断言必须含业务词 | 断言消息里写业务规则 | 评审可判断 |
边界清单可以固化成一份可勾选的表,缺项在评审时就能看出:
// 生成测试的边界清单:五项逐项回答,不适用要写原因
boundary:
empty_or_null: "calcAmount(null) → IllegalArgumentException"
negative: "calcAmount(-1) → IllegalArgumentException"
extreme: "calcAmount(Long.MAX_VALUE) → 溢出保护或明确拒绝"
repeated: "同一订单重复结算只扣一次(幂等)"
concurrent: "不适用的原因:纯函数,无共享状态"
第五项"不适用"必须写原因,否则它会变成习惯性跳过。
第一项是根本:没有规格的生成,只能复述实现。把"输入 100 应得 110(含 10% 税)"这样的对照表先写出来,再让助手补测试骨架,产出的断言才有意义。
// 推荐:先给定规格表,再生成测试
// 规格:amount<0 → IllegalArgumentException;amount=0 → 0;amount>0 → 按阶梯税率
@Test void specTable() {
assertThrows(IllegalArgumentException.class, () -> svc.calcAmount(-1)); // 边界:负
assertEquals(0.0, svc.calcAmount(0)); // 边界:零
assertEquals(110.0, svc.calcAmount(100)); // 正常 + 税率
assertEquals(1100.0, svc.calcAmount(1000)); // 阶梯换档
}
4. 覆盖率为什么会骗人
覆盖率衡量的是"被执行",不是"被验证"。
| 指标 | 衡量什么 | 生成测试下的表现 |
|---|---|---|
| 行覆盖率 | 该行是否被执行 | 容易冲到 90% 以上 |
| 分支覆盖率 | 分支是否都走到 | 提升明显 |
| 变异存活率 | 改动代码后测试是否变红 | 明显偏高,最能反映问题 |
| 断言密度 | 每个测试的断言数 | 偏低 |
断言密度还有一个简单的判定口径:一条测试如果删掉全部断言后仍然能通过编译且不报错,说明它的价值全在断言上——这是好事;如果删掉断言后测试仍然"有意义",那它本来就没有在验证任何东西。
第三项是检验生成测试最有效的手段:故意改动一处逻辑(把 + 改成 -、把 >= 改成 >),看测试会不会红。不会红,说明这块测试只是"跑过",没有"检查"。这类变异测试不需要全量跑,抽样几个关键方法即可。
// 变异测试抽样:验证测试是否真的能拦住改动
// 以 Java 为例,pitest 按目标类抽样,只跑核心模块
mvn -q org.pitest:pitest-maven:mutationCoverage \
-DtargetClasses='com.example.pricing.*' \
-DmutationThreshold=60 \
-DoutputFormats=HTML
// 存活变异集中在哪个类,那个类的测试就是"只执行不验证"
5. 评审该看什么
生成测试进 PR 后,评审的注意力要放对位置。
| 该看 | 不该看 |
|---|---|
| 断言是否对应业务规则 | 测试方法命名风格 |
| 是否覆盖四项边界 | 用例数量多少 |
| 是否有"不抛异常"式空断言 | 是否用了参数化 |
| 是否断言了实现细节 | 是否 100% 覆盖 |
| 出错时是否能定位 | 测试代码行数 |
第一列第一条是核心判据:这条断言失败时,能告诉人哪里错了。如果一条测试失败后只能得出"某处不对",它对排错的贡献接近零。
第三列第一条同样重要:assertNotNull、assertDoesNotThrow、assertTrue(result != null) 这三类断言在生成测试里最泛滥,而它们几乎不携带信息。
6. 排错:五种"测试很多但没拦住"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 覆盖率涨了缺陷率也涨了 | 执行型测试居多 | 抽样做变异测试 |
| 修 bug 后一堆测试变红 | 断言固化了旧行为 | 断言改按规格,不按实现 |
| 测试偶尔失败 | 依赖时间、随机、外部服务 | 注入 Clock / 固定种子 / 假实现 |
| 重构后大量测试失效 | 断言了实现细节 | 断言移到公开行为 |
| 缺陷在异常路径上 | 异常分支无测试 | 每个 throw 配一条用例 |
// 让时间成为参数,测试才可能稳定
// 改造前:内部取当前时间,测试只能"大概在范围内"
public boolean expired(Order o) { return o.getPaidAt().plusDays(7).isBefore(LocalDateTime.now()); }
// 改造后:时间由调用方给,测试可精确断言边界
public boolean expired(Order o, LocalDateTime now) { return o.getPaidAt().plusDays(7).isBefore(now); }
// 生产代码在入口处传 now,测试传固定值
第三项在生成测试里尤其常见:生成器不知道系统里有 Clock 抽象,会直接写 LocalDateTime.now()。修法不是改测试,而是让被测算法的签名接受时间参数——测试的可测性问题是设计问题。
7. 边界:什么情况生成测试收益低
| 场景 | 为什么 | 做法 |
|---|---|---|
| 纯胶水代码(controller、mapper) | 逻辑少,测了也拦不住什么 | 用集成测试覆盖 |
| 算法与业务规则密集 | 断言需要规格 | 人工写规格,助手补骨架 |
| 一次性脚本 | 生命周期短 | 不做单测 |
| 已有稳定的契约测试 | 边界由契约层保证 | 减少重复 |
还有一条组织上的建议:不要用生成测试去追覆盖率目标。覆盖率一旦成为考核项,生成的执行型测试会迅速把指标刷满,而缺陷率不会有任何改善——这类指标一旦被优化,就失去了它原本的测量价值。
判断口径一句话:这段代码的"正确"能不能用一段话写清楚。能写清楚,就让助手按那段话生成测试;写不清楚,先生成规格,再谈测试。
8. 自测
- 生成测试的三类产出分别是什么?如何仅凭断言内容判断一份测试属于哪一类?
- 为什么"把实现输出当期望值"的测试是负价值?它如何把缺陷固化成契约?
- 生成器缺失的三样信息里,哪一样只能靠人补?为什么?
- 变异测试为什么比覆盖率更能识别"只执行不验证"的测试?抽样时该怎么选目标?
- 评审生成测试时,最核心的一条判据是什么?
- 生成测试里出现
LocalDateTime.now()时,为什么正确修法是改设计而不是改测试?