大模型应用实战

LLM 自动化测试系列(03):单元测试生成——TestGen-LLM 与 Qodo Cover

让 LLM '随便写点测试',产出的往往是编译不过、断言写死 true、或者根本不提升覆盖率的垃圾测试。Meta 的 TestGen-LLM 论文给出的解法不是让模型变聪明,而是用一套机械化的过滤器把不合格的产出全部筛掉。这篇拆解这套过滤器的具体设计,以及它的开源实现 Qodo Cover 怎么把这套思路落地成可跑的 CLI 工具。

·约 10 分钟阅读·AI Engineering

"帮我写点测试"最容易翻车的地方

把一个函数丢给 LLM,让它"生成单元测试提升覆盖率",最常见的结果不是测试写得不够好,而是测试写得看起来没问题,实际什么都没测

def test_calculate_discount():
    result = calculate_discount(100, 0.1)
    assert result is not None  # 这种断言,永远不会失败
 
def test_process_order():
    # 编译不过:Mock 的返回值类型和实际接口不匹配
    mock_client = Mock(return_value="success")
    process_order(mock_client)

这类测试有覆盖率数字(甚至能让覆盖率报告看起来"变好了"),但对代码质量没有任何保护作用——assert result is not None 永远不会失败,等于什么都没断言。更麻烦的是另一类问题:LLM 生成的测试有时候连编译/运行都过不了,直接被扔进 CI 只会制造新的红叉。

Meta 在 2024 年发的论文《Automated Unit Test Improvement using Large Language Models at Meta》要解决的正是这个问题,工具叫 TestGen-LLM。它的核心思路不是"训练一个更聪明的模型来写测试",而是反过来:假设 LLM 的产出大概率是垫圾,搭一套机械化的过滤器,把不合格的东西全部筛掉,只留下能证明自己有效的那一小部分。


TestGen-LLM 的四层过滤器

论文把这套方法称为 Assured Offline LLM-Based Software Engineering(Assured LLMSE):LLM 只是流水线里的一个生成步骤,它的产出不会被直接信任,而是要经过一系列可验证的检查,只有全部通过才会被推荐给人。这个"assured"(有保障的)的关键词,就是区别于"直接把模型输出当结果用"的地方。

具体是四层过滤,逐级淘汰:

LLM 生成候选测试

① Build Filter(编译过滤)
   候选代码必须在现有工程基础设施下能完整编译通过
   编译不过 → 直接丢弃

② Pass Filter(首次执行过滤)
   编译通过的测试必须首次运行就通过
   ——这一步不去判断"失败是不是发现了真 bug",
     因为目前没有自动化手段区分"真 bug"和"断言写错了",
     所以统一采取保守策略:首次不过就丢弃

③ Flakiness Filter(稳定性过滤)
   连续运行 5 次,必须每次都通过才算稳定
   只要有一次失败,就判定为 flaky,丢弃

④ Coverage Filter(覆盖率过滤)
   必须证明自己确实提升了代码覆盖率
   不提升覆盖率的测试,即使前三层都过了,也丢弃

只有走完全部四层的测试,才会被推荐给工程师 review

四层过滤器的设计哲学是层层收窄,宁可漏掉有价值的测试,也不能让垫圾测试流出去。特别值得注意的是第三层——连续跑 5 次才能过滤 flaky 测试,这个做法后续也被 Qodo Cover 的路线图直接借鉴(下文会提到)。

一个容易被误解的地方需要澄清:TestGen-LLM 的过滤器里没有用到变异测试(mutation testing)。 论文里提到变异测试可能是理论上覆盖率评估效果最好的标准,但明确把它归为未来工作,原因是"在 Meta 需要的规模下,这类计算量巨大的技术难以部署"——换句话说,变异测试的思路是对的,但工程上跑不动,所以这一版用的是更轻量的"覆盖率是否提升"作为过滤标准,而不是"每一行代码的变异体有没有被杀死"这种更精确但更贵的指标。


真实数字:这套方法在 Meta 内部跑出了什么效果

论文给出了两组数据,分别对应"实验室评测"和"真实工程师使用"两个场景,值得放在一起看,因为差距本身就很有信息量。

实验室评测:Instagram 的 Reels/Stories 组件

在 86 个 Kotlin 组件(31 个 Stories、55 个 Reels)上测试:

75%  的测试类:至少生成了一个能正确编译的新测试用例
57%  的测试类:编译通过 + 稳定通过(走完前三层过滤)
25%  的测试类:编译通过 + 稳定通过 + 确实提升了覆盖率(走完全部四层)

这组数字说明一个很实际的问题:"LLM 能不能生成测试"和"LLM 能不能生成有价值的测试"之间有巨大的衰减。 四分之三的组件能拿到一个"看起来能编译"的测试,但只有四分之一真正提升了覆盖率——中间三层过滤器砍掉的,恰恰是"看起来有用但实际没用"的那部分产出。

真实场景:三次 Test-a-thon(测试冲刺活动)

比实验室数据更有说服力的是三次内部 test-a-thon 的真实使用数据:

                    生成的 diff    工程师接受     接受率
─────────────────────────────────────────────────────
Instagram(第一次)    17            16           94%
Instagram(第二次)    42            36           86%
Facebook              280           144           51%(review 过的里接近 70%)
─────────────────────────────────────────────────────
合计                  1979 个测试类中,196 个被成功改进(约 10%)
                      73% 的改进建议被工程师接受落地

两组数字放在一起对比会发现:实验室里"能通过全部过滤器"的比例大约是 25%,但真实场景里"工程师愿意接受"的比例是 73%。这不矛盾——过滤器筛掉的是机械层面不合格的产出(编译不过、不稳定、不提升覆盖率),而"工程师接受"衡量的是在通过了机械过滤之后,这个测试是否真的值得合并进代码库。也就是说,四层过滤器解决的是"不要让垫圾流出去",不是"生成的东西一定有价值"——后者仍然需要人来判断。


开源实现:Qodo Cover 怎么把这套思路落地成 CLI 工具

TestGen-LLM 本身是 Meta 的内部工具,没有开源。Qodo Cover(前身是 Codium 的 Cover-Agent)是这套思路第一个公开的开源实现,可以直接跑在自己的项目上。

它的架构把 TestGen-LLM 论文里的"过滤器"概念,落地成了四个具体组件:

Prompt Builder(提示构建器)
   从代码库里收集必要信息(源文件、已有测试、覆盖率报告),拼装成给 LLM 的 prompt

AI Caller(模型调用器)
   基于 prompt 调用 LLM 生成候选测试(通过 LiteLLM,可换不同模型厂商)

Test Runner(测试执行器)
   实际跑一遍项目的测试命令,执行新生成的测试

Coverage Parser(覆盖率解析器)
   解析覆盖率报告(Cobertura/JaCoCo 等格式),验证覆盖率是否真的提升了

   通过 → 保留测试,继续下一轮迭代
   不通过 → 丢弃,重新生成候选

这本质上是 TestGen-LLM 的 Pass Filter + Coverage Filter 的具体实现(Build Filter 隐含在 Test Runner 执行失败里,Flakiness Filter 是路线图里计划加入、明确提到要"参照 TestGen-LLM 的做法跑 5 次"的功能)。

使用方式是一个 CLI 循环:指定源文件、测试文件、覆盖率报告路径、目标覆盖率百分比(--desired-coverage)和最大迭代次数(--max-iterations),工具会反复生成候选测试、跑测试、检查覆盖率,直到达到目标覆盖率或用完迭代次数。这个循环可以直接接入 CI 流程,也可以本地跑"一键提升这个模块的覆盖率"。

需要指出的是,Qodo Cover 这个开源项目在 2025 年中之后已经不再维护(README 里明确标注"no longer maintained",建议想继续开发的人自行 fork)。这不影响它作为"TestGen-LLM 思路落地参考"的价值,但如果要在生产环境依赖它,需要考虑维护性风险——这也是本系列后面几篇会反复遇到的问题:开源验证性项目和生产可用工具之间,往往还有一段距离。


这套方法的真实边界

把 Meta 的实验室数据和 test-a-thon 数据放在一起看,可以推出这套方法真实好用的场景边界:

好用的场景:逻辑相对独立、依赖关系清晰的模块——LLM 容易根据函数签名和现有测试风格推断出合理的输入输出关系,四层过滤器能有效筛掉大部分垫圾产出。

不好用的场景

  • 依赖大量外部状态或复杂 Mock 的代码——LLM 生成的 Mock 经常跟真实接口的行为不一致,容易被 Build Filter 或 Pass Filter 筛掉,存活率低
  • 需要深层业务语境才能写出有意义断言的代码——测试可能"能跑通"但断言写得没有意义(比如开头例子里的 assert result is not None),而覆盖率过滤器只看代码是否被执行到,不会判断断言本身有没有意义,这是四层过滤器本身没有解决的盲区
  • 对确定性和可复现性要求极高的场景——flakiness filter 只跑 5 次,统计上仍然可能漏判本质上不稳定的测试

最后一点值得强调:四层过滤器保证的是"这个测试机械上是合格的",不保证"这个测试的断言在业务语义上是正确的"。 覆盖率提升是一个可以自动化验证的代理指标(proxy metric),但"这个测试真的测到了我关心的东西"仍然需要人工 review——这正是为什么 Meta 内部数据里,"通过全部过滤器"和"工程师接受"是两个不同的、都需要单独考察的数字。


总结

  1. LLM 直接生成的测试大概率是"垫圾"——编译不过、断言写死、或者根本不提升覆盖率,TestGen-LLM 的解法不是让模型更聪明,而是用四层机械化过滤器(编译、首次执行、稳定性、覆盖率)逐级淘汰不合格产出
  2. 变异测试不是 TestGen-LLM 当前用的方法,论文明确把它列为受限于计算成本、尚未落地的未来方向,当前用的是更轻量的覆盖率提升作为过滤标准
  3. 实验室数据(25% 走完全部过滤器)和真实场景数据(73% 被工程师接受)衡量的是两件不同的事——前者是机械合格率,后者是人工价值判断,四层过滤器只解决前者
  4. 开源实现 Qodo Cover 把这套思路落地成了 CLI/CI 可用的迭代循环,但项目本身已停止维护,落地时需要评估维护性风险

欢迎访问 PrimeSkills —— 一个精心策划的 AI Agent 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。

更多实用知识和有趣产品,欢迎访问我的个人主页