2781 字
约 9 分钟
15
S24-A11 · AI 写的测试:覆盖了什么、没覆盖什么

一个模块的单测覆盖率从 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. 自测

  1. 生成测试的三类产出分别是什么?如何仅凭断言内容判断一份测试属于哪一类?
  2. 为什么"把实现输出当期望值"的测试是负价值?它如何把缺陷固化成契约?
  3. 生成器缺失的三样信息里,哪一样只能靠人补?为什么?
  4. 变异测试为什么比覆盖率更能识别"只执行不验证"的测试?抽样时该怎么选目标?
  5. 评审生成测试时,最核心的一条判据是什么?
  6. 生成测试里出现 LocalDateTime.now() 时,为什么正确修法是改设计而不是改测试?
S24-A11 · AI 写的测试:覆盖了什么、没覆盖什么
https://www.clxhxhhr.top/posts/6395/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。