覆盖率骗了你:用 mutation score 量一量测试集到底有没有杀伤力

举报
霍格沃兹测试学社 发表于 2026/09/15 17:21:56 2026/09/15
【摘要】 本文揭示AI生成单元测试的致命陷阱:高覆盖率(95%)≠ 高质量测试。AI常“照抄实现写断言”,导致测试恒真、无法发现逻辑错误(如满减与折扣顺序颠倒)。真正衡量测试能力的是**变异测试得分(mutation score)**——通过故意注入代码缺陷,检验测试能否捕获。覆盖率只答“是否执行”,mutation score才答“能否揪错”。AI时代,该用它为测试集“体检”。

那批单元测试是 AI 生成的,一共 60 条,覆盖率报告很漂亮:行覆盖 95%,全绿。上线前一天,我们还专门截了那张覆盖率图发进群里。

上线第二天,业务方报了一个缺陷:满减叠加优惠券的时候,折扣算反了——本该先减满减、再打折,代码却先打了折、再减满减,用户少付了钱。奇怪的是,这条路径的那几行代码,明明是被测试「覆盖」到的,跑的时候一片绿。

复盘时我们扒开那 60 条用例,发现一个让人后背发凉的规律:AI 是「跟着实现写断言」的。实现里 price * 0.9 算出来是多少,它就把那个数填进 assertEqual。覆盖率当然高——每一行都被执行到了;但它根本没有能力发现实现里的逻辑是错的,因为它的期望值就是从错误实现里抄来的。

覆盖率量的是「你的代码被测过没有」,不是「你的测试能不能抓住 bug」——这两件事,AI 时代差得越来越远。

一、覆盖率高 ≠ 检出率高

先把结论摆前面:行覆盖率高,只能说明测试跑过了这些行,不能说明测试在跑过的时候有能力让错误的代码变红。

一条 assertEqual(apply_discount(300, 10, True), 260.0),如果那个 260.0 是照着实现算出来抄进去的,那么无论实现里的折扣逻辑是对是错,这条断言都会通过——它测的是「实现等于它自己」,恒真。覆盖率统计里,这几行被标记成「已覆盖」,绿得理直气壮。

这就是 AI 生成用例最典型的假绿:不是没写测试,而是写了一堆没有独立判断力的测试。要戳破它,你得换一个问法——不问「测过没有」,改问「如果代码错了,这些测试抓得到吗」。

二、为什么 AI 写的断言特别容易假绿

这不是 AI 笨,是它的工作方式决定的。你让它「给这个函数写单元测试」,它手上最可靠的信息就是这个函数本身。于是它做的事本质上是一次翻译:把实现读一遍,再把实现的行为写成断言。这种测试有一个致命的循环——期望值来自被测代码,被测代码错了,期望值跟着一起错,断言照样成立。

人工写用例时也会偷懒,但脑子里通常还带着「需求说应该怎样」这份独立信息;AI 拿到的往往只有代码、没有需求,于是「应该怎样」被「实际怎样」悄悄替换掉了。覆盖率工具看不出这次替换,因为它只统计行有没有被执行,从不统计断言有没有独立来源。

识别假绿有个便宜的土办法:挑几条用例,把被测函数里的一个运算符手动改错,再跑一遍。如果测试还是全绿,这批断言就是抄来的。这个土办法的自动化版本,就是变异测试。

三、变异测试:故意把代码改错,看测试抓不抓得到

变异测试的思路特别朴素:既然你不确定测试有没有杀伤力,那就人为往被测代码里注入一些小缺陷,再跑测试集,看它能不能发现。

每一个被注入的小改动叫一个 mutant(变异体),常见的注入就是把 > 改成 >=、把 + 改成 -、把 and 改成 or、删掉某一行、把常量改一个值。注入之后跑整组测试:

  • 有测试变红了,说明这个 mutant 被「杀死」(killed)——测试发现了代码被改错,好;
  • 全部还是绿的,说明这个 mutant「存活」(survived)——代码都被改错了,测试居然没反应,这正是你线上会漏 bug 的地方;
  • 还有一种叫「等价变异体」(equivalent),改了写法但行为不变(比如 x*2 改成 x+x),这种本来就杀不死,不该算进分母。

下面这张表把常见算子和杀它的难度列出来,方便你判断哪些存活 mutant 是真问题:

变异算子 示例 易杀难度
关系运算符替换 >>=<<= 中:需要边界值断言才杀得死
算术运算符替换 +-*/ 低:只要期望值不是抄的,基本能杀
逻辑运算符替换 andor 中高:需要覆盖条件组合的用例
常量修改 0.91.0 低:金额类断言容易抓到
删除语句 删掉一行赋值 高:若无对应断言,极易存活

一句提醒:边界值相关的算子(>>=)最考验测试质量,它们存活往往意味着你的用例只测了「典型值」,没测「临界值」——而线上出的事,多半就出在临界值上。

四、mutation score 才是检出率

把上面的结果量化成一个数,就是 mutation score:

mutation score = 被杀死的 mutant 数 ÷(总 mutant 数 − 等价变异体数)

它量的才是你真正关心的东西——测试集的检出率。覆盖 95% 但 mutation score 只有 30%,意思是:代码几乎每行都被跑过,可一旦这些行被改错,你的测试十次里有七次抓不到。这种测试集在 CI 里绿得再好看,也挡不住「折扣算反」这类缺陷上线。

把两个指标放一起,差别就一目了然:

维度 行覆盖率 mutation score
衡量的是什么 代码有多少行被执行过 代码被改错时测试能抓住多少
能被什么骗 期望值抄实现的「假绿」测试 极难作弊,改错了就必须变红
AI 生成用例常见表现 很高(跟着实现跑一遍) 偏低(断言没有独立判断力)
怎么提升 补更多走到的路径 补边界值、补能杀死存活 mutant 的断言
适合用来 快速看有没有大片没测 判断测试集到底有没有杀伤力

五、Python 里怎么落地:mutmut 与 cosmic-ray

Python 生态最常用的是 mutmut 和 cosmic-ray。mutmut 上手最快,配置也简单;cosmic-ray 更适合分布式跑、算子可配。下面以一个折扣模块为例,给一套能直接跑的东西。

先看被测模块和一组典型的「AI 风格」测试——覆盖率高,但断言是照着实现抄的:

# discount.py —— 订单折扣计算模块(被测代码)
VIP_THRESHOLD = 200.0   # 满 200 才享会员九折


def apply_discount(price: float, coupon: float, vip: bool) -> float:
    """先看是否达到满减门槛,达标才打会员折,再减优惠券。价格不能低于 0。"""
    if vip and price >= VIP_THRESHOLD:
        price = price * 0.9          # 会员九折
    price = price - coupon           # 再减优惠券
    if price < 0:
        price = 0
    return round(price, 2)


# test_discount_ai_style.py —— AI 生成:覆盖率高,但期望值抄实现,杀伤力低
import unittest
from discount import apply_discount


class TestDiscountWeak(unittest.TestCase):
    def test_vip_over_threshold(self):
        # 期望值 260.0 是照着 300*0.9-10 从实现里抄进来的,不是独立算出来的
        self.assertEqual(apply_discount(300, 10, True), 260.0)

    def test_vip_below_threshold(self):
        # 100 没到门槛,不打折:100-10=90
        self.assertEqual(apply_discount(100, 10, True), 90.0)

    def test_non_vip(self):
        self.assertEqual(apply_discount(100, 10, False), 90.0)

    def test_zero_floor(self):
        self.assertEqual(apply_discount(5, 100, False), 0)


if __name__ == "__main__":
    unittest.main()

跑 mutmut,把 mutation score 打出来。命令和配置如下:

# 安装
pip install mutmut

# setup.cfg 里限定只对核心模块跑,控制 mutant 爆炸
# [mutmut]
# paths_to_mutate=discount.py
# tests_dir=./

# 1) 先确认测试本身是绿的
python -m unittest -v

# 2) 跑变异测试(会往 discount.py 逐个注入 mutant 再跑测试)
mutmut run --paths-to-mutate discount.py --tests-dir ./

# 3) 看结果汇总:killed / survived / timeout 各多少
mutmut results

# 4) 逐个查看存活 mutant 的具体改动
mutmut show 1

跑完你会发现:那几条抄实现的测试,杀掉了算术类 mutant(*/)和常量 mutant(0.91.0)——期望值虽然是从实现里抄的,但抄的是当前这一版实现,代码一改就对不上号。真正存活的是关系运算符 mutant:把满减门槛的 price >= VIP_THRESHOLD 改成 price > VIP_THRESHOLD,四条测试全绿,因为没有一条正好打在 200 这个门槛值上。mutation score 就卡在这种地方。

如果仓库更大、想分布式跑或者自定义算子,就换 cosmic-ray,它的流程是「先建基线、再执行、最后出报告」:

pip install cosmic-ray

# 1) 初始化会话与基线配置(toml 里写清 mutate 哪个模块、用什么命令跑测试)
cosmic-ray init session.toml baseline.toml

# 2) 先跑基线:确认没注入任何 mutant 时,测试本身就是全绿的
cosmic-ray baseline baseline.toml

# 3) 注入并逐个执行(可分布式,把任务摊到多台机器上)
cosmic-ray exec session.toml

# 4) 出报告:mutation score 与存活 mutant 清单
cr-report session.toml

不管用哪个工具,控制 mutant 爆炸只有三条经验。一是范围锁死在核心业务模块,算钱、算权限、算库存的地方优先跑,工具类和自动生成的代码不跑。二是先让测试跑快,变异测试的总成本约等于「mutant 数 × 单次测试耗时」,一个本身就慢的测试集会让整件事不可行,先把慢用例挪走或打桩再开跑。三是分批读结果,别指望一次跑完就消化几千条,按存活 mutant 所在的函数排序,从算钱的那几个开始补。

六、针对存活 mutant,补一条真能杀死它的测试

mutmut show 会告诉你第几号 mutant 存活、改了哪一行。假设存活的是把满减门槛的 >= 改成了 >——它之所以能活下来,是因为你从没有一条测试正好打在门槛值 200 上。补一条边界断言就能杀死它:

# test_discount_kill_survivor.py —— 针对存活 mutant 补的门槛边界测试
import unittest
from discount import apply_discount


class TestDiscountBoundary(unittest.TestCase):
    def test_vip_exactly_at_threshold(self):
        # 正好 200,落在 >= 与 > 的临界点上:达标该打折,200*0.9-10 = 170.0
        # mutant 把 >= 改成 > 之后,这里会算出 190.0,断言当场把它杀死
        self.assertEqual(apply_discount(200, 10, True), 170.0)

    def test_vip_just_below_threshold(self):
        # 199 差一点没到门槛,不该打折:199-10 = 189.0
        self.assertEqual(apply_discount(199, 10, True), 189.0)


if __name__ == "__main__":
    unittest.main()

补完再 mutmut run,那个存活 mutant 被杀死,mutation score 往上跳一格。这就是变异测试最爽的地方:它不只告诉你「测试不够好」,还精确指给你看是哪一行改动没被抓到、该在哪个边界上补断言。

几点为什么这么写。第一,setup.cfg 里一定要用 paths_to_mutate 把范围锁死在核心业务模块,别对全仓库跑——mutant 会爆炸,几千个变异体跑一晚上都出不来,而你对工具类代码的检出率其实没那么在意。第二,示例里故意保留了「期望值抄实现」的写法,因为只有让 mutation score 掉下来,你才能亲眼看到覆盖率骗人的样子;真实补测时,期望值必须自己独立算,不能从实现里复制。第三,解读存活 mutant 前先排掉等价变异体——就拿本模块说,把 if price < 0 改成 if price <= 0,在「低于 0 就置 0」这段逻辑里行为一模一样(price 等于 0 时赋成 0 还是 0),它本来就杀不死,不是测试的锅,别为它硬凑断言。踩过的坑是 timeout:有的 mutant 会让代码死循环,mutmut 记成 timeout,这类要单独看,别混进 survived 一起算。

七、别把它当日常,把它当体检

变异测试贵——每个 mutant 都要重跑一遍测试集,慢且耗资源,不适合挂在每次提交的 CI 上。合理的用法是当定期体检:对核心业务模块(算钱、算权限、算库存的地方)按周或按迭代跑一次,盯住 mutation score 的趋势。AI 大量生成用例的团队尤其该这么做,因为覆盖率这个老指标已经被「跟着实现写断言」的假绿彻底带偏了,你需要一个骗不了的数来兜底。

绿灯不代表测到了,覆盖率也不代表抓得住——只有当代码被改错时你的测试会脸红,它才算真的在替你把关。

覆盖率证明你跑过了代码,mutation score 才证明你有能力抓住代码里的错——AI 时代,后者才是测试集的真本事。

你们那套 AI 生成的用例,敢不敢往被测代码里注入一个缺陷,看它能杀死几个?

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。