Mutation-Guided Unit Test Generation with a Large Language Model
Guancheng Wang, Qinghua Xu, Lionel Briand, Kui Liu
cs.SE
2025-06-03
Lero与华为提出MutGen:把PITest存活突变写进Llama-3.3的prompt,在204个Java方法上突变分达到89.5%和89.1%,分别比EvoSuite高约20和30个百分点,失败测例约一半能修。
行覆盖、分支覆盖打满分,测例照样可以几乎杀不死故障。HumanEval-Java 里编号 id81 的方法,Llama 生成的测例行覆盖和分支覆盖都是 100%,突变分只有 4%。
根因很具体。PITest 的 ConditionalsBoundary 算子把 day < 1 改成 day <= 1,原函数认 day=1 合法,突变体认它非法。普通「冲高覆盖率」的 prompt 会造 assertFalse(validDate("04-00-2025")) 这种两边都挂的输入,很少造 assertTrue(validDate("04-01-2025")) 这种贴着边界、能把突变体掀翻的输入。注释也会帮倒忙:HumanEval 风格注释以「You have to write a function」开头,模型容易去复述实现,而不是写测试。
搜索式工具 EvoSuite 优化的就是覆盖率。近年 LLM 测例生成大多还在报覆盖率。Foster 等人在 WhatsApp、Instagram 的 Kotlin 回归测试里开始看突变分,但他们从已有测例出发,突变还是 LLM 自己造的,等价突变很难处理。Dakhel 等人在 Python 上一次只塞一个突变,不追求收敛到最高突变分,也不分析杀不死的那些为什么活着。
MutGen 把优化目标换成突变分。底座是本地 Ollama 部署的 Llama-3.3 70B,温度 0.0。分预处理和生成两段,生成完再按存活突变迭代。
预处理做两件事。第一,删掉源码注释,让模型写一段方法目的和输入格式的摘要,再放进 prompt,避免注释把模型带跑。第二,用 PITest 的 DEFAULTS 算子组出突变报告,抽出行号、状态(killed / survived / uncovered)和算子,连同源码一起喂给模型。DEFAULTS 是文献里常用的一组,官方说法是不容易被误杀、也尽量少造等价突变。
生成阶段的 prompt 里有三块:摘要、去注释源码、突变反馈。模型被明确要求针对每个存活突变造更多样的输入。测例跑挂之后再走一轮修复 prompt,按错误信息对号入座,覆盖六类问题:断言函数调错、assertEquals 期望值不对、assertTrue / assertFalse 用反、类型歧义需要强转、测试方法重名、其余编译或运行时错误。
然后迭代。每轮拿上一轮还活着、还没覆盖到的突变再生成,失败的再修,能跑通的并进测试套件。预实验随机抽 10+10 个对象跑 7 轮,突变分在第 4 轮趋于平稳,正式实验就把上限设成 4 轮。跑例 validDate 上,两轮就能到 100% 突变分;同一对象上 vanilla prompt 四轮还停在 53%。
评测卡在方法级:一个 Java 类里只有一个目标方法,不做跨模块依赖解析。HumanEval-Java 160 个对象里,丢掉 MutGen 和 EvoSuite 都打满 100% 突变分的,剩 104 个。Leetcode-Java 从 GitHub 上 Medium / Hard 题解里筛能编译的,同样丢掉两边都满分的,再各随机抽 50 个,共 100 个。方法平均行数 HumanEval 一侧约 41 行,圈复杂度 4.90 对 7.88。时间预算取 MutGen 在两个数据集上较大的平均耗时,EvoSuite 两侧都给 150 秒。覆盖率用 JaCoCo,突变分用 PITest。每组跑三次取平均。
两个数据集上 PITest 分别造出 1144 和 1900 个突变,每对象平均 11 和 19 个。
| 方法 | HumanEval 突变分 | LeetCode 突变分 | HumanEval 行/分支覆盖 | LeetCode 行/分支覆盖 |
| EvoSuite | 69.5% | 58.9% | 95.6% / 93.4% | 99.0% / 98.9% |
| EvoSuitemut | 67.4% | 58.1% | 95.4% / 93.4% | 98.1% / 98.7% |
| Genvanilla | 77.9% | 69.9% | 96.2% / 92.8% | 96.3% / 92.7% |
| MutGen | 89.5% | 89.1% | 98.3% / 95.8% | 98.4% / 94.8% |
相对 EvoSuite,突变分提高约 20 和 30 个百分点(相对增幅 28.8% 和 51.3%)。A12 效应量 0.759 和 0.899,对 Genvanilla 是 0.650 和 0.734。Wilcoxon 符号秩检验在 α=0.05 下突变分显著,覆盖率不显著,因为大家已经都接近满分。覆盖率差距都在 3 个百分点以内;LeetCode 上 EvoSuite 覆盖率最高,符合它就是冲覆盖率的设计。
把 EvoSuite 的适应度函数改成强突变(EvoSuitemut)没有帮上忙,两侧 67.4% 和 58.1%,略低于默认 EvoSuite。论文猜测这一支十年没维护,算子和 PITest 对不齐,150 秒预算也可能不够。
失败测例 HumanEval 1254 条修了 52.08%,剩 601 条;LeetCode 2742 条修了 47.29%,剩 1445 条。随机抽 50 条修不动的,74% 是预言写错(断言函数或期望输出不对),26% 是非法输入触发运行时异常。三次重复的突变分波动 HumanEval 为 89.9% / 90.2% / 88.5%(极差 1.7%),LeetCode 为 88.2% / 88.0% / 91.1%(极差 2.1%)。
四轮之后总体杀死率 93.3% 和 94.7%。NegateConditionals、Math 都在 93% 以上。难的是 VoidMethodCalls(HumanEval 上 69.2%,26 个突变里 7 个存活)和 TrueReturns(LeetCode 上 57.7%)。去掉 void 调用时副作用往往不在返回值里,模型看不出来行为变了;TrueReturns 大量是未覆盖。未覆盖突变和存活突变不是一类:前者主要是死代码,以及给了突变反馈也到不了被改语句。算子出现频率和杀死率没有稳定单调关系,Spearman 相关系数两侧分别是 -0.214 和 0.36。
消融三个变体:MutGen-S 用原注释替换摘要,MutGen-F 去掉修复,MutGen-MF 去掉突变反馈。论文根据第四轮的图判断,摘要对效果影响最大,修复次之(LeetCode 上失败测例更多,修复更关键),突变反馈再把模型推向更难杀的突变。图里没有列出各变体的精确百分点。
耗时 HumanEval 125.9 秒、899 token,LeetCode 149.4 秒、1629 token。LeetCode 突变更多,prompt 更长,token 跟着涨。
覆盖率报表可以很好看,杀伤力仍然很差。id81 那种 100% 覆盖、4% 突变分的例子说明,「LLM 测例已经能打满覆盖」不能当成测例可用的证据。
把突变报告当 prompt 材料,比改 EvoSuite 适应度函数有效得多。方法本身不绑模型,工程上能落地的前提是:方法级 Java、能跑 PITest、接受大约一半失败测例需要再修、并且修完仍有预言错误。项目级依赖、Defects4J 这类真实缺陷集,论文明确没做。
对已经在用 LLM 写单测的人,可迁移的是这三步:注释换成摘要、把存活突变的 diff 塞进 prompt、失败断言按错误信息分类修。迭代四轮是这篇的经验值,换模型、换语言都得重测。
评测对象是独立方法,平均大约 40 行,没有跨模块依赖。换到 Defects4J 这类真实项目,光把依赖解析做对就是另一篇论文。
等价突变 PITest 减了但消不干净,100% 突变分这条线达不到,所有方法都被同等拖累,比较仍成立。默认假定被测代码是对的,只修会让 Maven 构建失败的断言,会产出错误预言和假阳性。
Llama-3.3 的训练语料很可能见过 HumanEval 和 LeetCode 公开题解。论文认为具体突变不太可能被背下来,并且相对 vanilla prompt 的增益说明不完全是记忆;这个辩护只能算部分成立,因为 vanilla 基线也在同一模型上跑。只测了一个模型,温度固定 0.0,换 GPT 或 DeepSeek 会不会改变算子之间的难易排序,没有数据。
EvoSuitemut 作为「专门优化突变分」的对照偏弱:算子过时、时间预算按 MutGen 对齐。消融结论主要靠图,正文没给 MutGen-S / F / MF 的精确突变分,「摘要贡献最大」这个排序无法独立复核。