ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI代码质量三道关卡:PR-Agent、变异测试与CI防假绿实战

AI代码质量三道关卡:PR-Agent、变异测试与CI防假绿实战 1. 从全绿到炸了一个让所有工程师后背发凉的场景测试全绿覆盖率 92%CI 流水线一路飘绿代码评审顺利通过然后上线三天后线上炸了。这个场景听起来像是段子但它正在越来越多的团队里真实上演。原因也不复杂——AI 代码助手大规模进入日常开发流程之后代码的产量上去了但代码的质量验证体系没有同步升级。以前一个工程师一天写两百行现在借助 AI 代码工具一天产出上千行都不稀奇。问题在于我们的测试、评审、CI 关卡还是按人写代码的节奏设计的。我自己所在的团队就经历过这么一次。一个中等规模的服务模块用 AI 辅助重构之后单元测试覆盖率从 78% 拉到了 92%CI 上所有检查项全绿PR 合并得非常顺畅。结果上线第三天一个边界条件下的空指针把整个下单链路打挂了。事后复盘发现那段出问题的代码测试确实覆盖了但测试断言写的是错的——它验证的是一个错误的行为所以永远绿。这件事之后我们给 AI 生成的代码加了三道关卡。这篇文章就把这三道关卡的设计思路、落地方式、踩过的坑完整地讲一遍。不管你是用 AI 代码编辑器写业务代码的 Java 开发还是在搭 GitLab CI/CD 流水线的工程效能同学或者是正在评估AI coding 会不会让代码质量下降的技术负责人这篇内容应该都能给你一些可以直接抄作业的东西。核心关键词先摆出来AI 代码、PR-Agent、变异测试、mutmut、CI。这几个词基本构成了我们整套方案的主干。2. 为什么覆盖率 92%是一个会骗人的指标2.1 覆盖率衡量的是执行到不是验证对很多人对测试覆盖率有一个根深蒂固的误解覆盖率越高代码越可靠。这个结论在人写代码的时代勉强成立因为人写测试的时候脑子里想的是我要验证这个逻辑对不对。但 AI 写测试的时候它想的是我要让这行代码被执行到。这两者的差别是致命的。举个最简单的例子。假设有一个函数public int divide(int a, int b) { if (b 0) { throw new IllegalArgumentException(divisor cannot be zero); } return a / b; }AI 生成的测试可能是这样的Test public void testDivide() { assertEquals(2, divide(4, 2)); assertThrows(IllegalArgumentException.class, () - divide(1, 0)); }看起来没问题覆盖率 100%。但如果 AI 把实现写成了return b / a测试依然会通过——因为divide(4,2)期望 2而2/4在整数除法下是 0等等这个例子会失败。换个更隐蔽的如果实现里把b 0的判断写成了b 0测试照样全绿因为测试只覆盖了b0这一种情况没有覆盖b-1。这就是覆盖率指标的根本缺陷它只告诉你代码被执行了不告诉你执行的结果是否被正确验证了。AI 特别擅长写出执行路径全覆盖但断言很弱的测试因为它的训练目标里让测试通过的权重远高于让测试有意义。2.2 AI 生成测试的三个典型假绿模式我们在实际项目里总结出 AI 生成测试最容易出现的三种假绿模式几乎每个用 AI 写测试的团队都会中招第一种断言缺失或过弱。AI 经常写出只调用不验证的测试比如service.process(input);后面什么都不跟或者只断言assertNotNull(result)。这种测试覆盖率贡献极高但几乎不验证任何业务逻辑。第二种测试跟着实现走。这是最危险的一种。AI 在生成测试时会参考当前的实现代码。如果实现本身有 bugAI 会生成一个符合这个 bug 行为的测试。于是 bug 被测试固化下来后续任何人想修复这个 bug都会先被测试拦住反而以为是自己改错了。第三种Mock 过度导致测试失真。AI 特别喜欢 mock因为它能让测试快速通过。但 mock 太多之后测试验证的其实是 mock 的行为而不是真实代码的行为。一个典型的信号是测试里 mock 的返回值和真实依赖在同样输入下的返回值不一致。2.3 覆盖率数字背后的心理陷阱还有一个更隐蔽的问题覆盖率数字会给团队带来虚假的安全感。当 CI 上显示 92% 的时候评审者的心理预期会不自觉地降低。都 92% 了应该没啥大问题吧这种心态会让 PR 评审变得敷衍。而 AI 生成的代码量又大评审者本来就容易疲劳看到绿油油的覆盖率更容易直接点通过。我们那次事故的 PR评审记录里只有一句LGTM。不是评审者不负责而是 92% 这个数字太有说服力了。所以第一道关卡的核心思路不是提高覆盖率而是让覆盖率这个指标失去欺骗性。具体怎么做下一节展开。3. 第一道关卡用 PR-Agent 把评审从看覆盖率拉回看逻辑3.1 PR-Agent 解决的是什么问题PR-Agent 是一个开源的 PR 自动评审工具可以接入 GitLab、GitHub 等平台。它的核心能力是在 PR 创建时自动对 diff 内容做分析生成评审意见、改进建议、甚至自动补充测试。我们引入它的初衷很朴素AI 写的代码用 AI 先审一遍。因为人工评审在面对 AI 生成的大段代码时注意力是稀缺资源而 PR-Agent 可以不知疲倦地把每一处可疑的地方标出来。但这里有个关键认知PR-Agent 不是用来替代人工评审的而是用来把人工评审的注意力引导到真正重要的地方。它负责扫出这里断言可能有问题这个异常分支没被测试覆盖这个 mock 的返回值和真实实现不一致这类信号人只需要看这些信号就行。3.2 接入 GitLab CI 的具体配置我们用的是 GitLab CI接入 PR-Agent 的过程不算复杂但有几个坑必须提前说。首先是在项目根目录加一个.gitlab-ci.yml的 jobpr_agent_review: stage: review image: codiumai/pr-agent:latest script: - pr-agent --pr_url$CI_MERGE_REQUEST_PROJECT_URL/-/merge_requests/$CI_MERGE_REQUEST_IID review rules: - if: $CI_PIPELINE_SOURCE merge_request_event variables: GITLAB_PERSONAL_ACCESS_TOKEN: $PR_AGENT_TOKEN OPENAI_API_KEY: $OPENAI_KEY这里第一个坑是token 的权限范围。PR-Agent 需要读取 MR 的 diff、发表评论所以 token 至少要有api和write_repository权限。我们一开始只给了read_api结果 PR-Agent 能分析但发不出评论排查了半天。第二个坑是触发时机。如果放在merge_request_event上每次 push 都会触发一次token 消耗很快。我们的做法是加一个 label 触发机制只有打了ai-review标签的 MR 才跑 PR-Agent日常小改动不跑。第三个坑是prompt 定制。PR-Agent 默认的评审 prompt 偏向通用场景对AI 生成代码这个特定场景不够敏感。我们在配置里加了一段自定义指令明确要求它重点关注断言强度、mock 与真实实现的一致性、异常分支覆盖、边界条件。加上这段之后它抓出的问题质量明显提升。3.3 PR-Agent 评审意见的分级处理经验PR-Agent 跑起来之后最大的问题不是它抓不出问题而是它抓出的问题太多评审者看不过来。一个中等规模的 MR它可能给出二三十条意见其中大部分是建议级别的。我们的处理方式是做分级级别触发条件处理方式阻断级断言缺失、mock 与实现不一致、异常分支未覆盖必须修改后才能合并警告级命名不规范、重复代码、复杂度偏高建议修改可带注释合并提示级风格建议、可选优化不阻断记录到技术债分级之后评审者的注意力集中在阻断级问题上效率高了很多。而且我们规定阻断级问题必须由提交者逐条回复处理结果不能直接点已解决。这个规定看起来繁琐但它是防止假绿的关键——因为很多 AI 生成的测试问题只有提交者自己动手改的时候才会真正理解。3.4 一个真实的 PR-Agent 拦截案例说一个具体的。有一次 AI 生成了一个订单状态流转的测试覆盖率贡献很大CI 全绿。PR-Agent 在评审时标出了一条测试testCancelOrder中 mock 的orderRepository.findById返回了一个statusCANCELLED的订单但真实实现中该方法在订单已取消时会抛出异常mock 行为与真实行为不一致。这条意见直接拦下了一个假绿测试。如果没拦住这个测试会一直绿下去直到某天有人真的去测取消已取消的订单这个场景才会发现测试根本没验证到真实逻辑。这就是 PR-Agent 的价值它不判断业务对错但它能发现测试和实现之间的裂缝。4. 第二道关卡变异测试专治假绿4.1 变异测试的核心思想如果说 PR-Agent 是从代码评审角度发现问题那变异测试就是从测试有效性角度直接给答案。变异测试的原理很直白故意在代码里注入 bug叫变异然后跑测试。如果测试失败了说明测试抓到了这个 bug这个变异被杀死了如果测试还是绿的说明测试没抓到这个变异存活了。存活率越高说明测试越假。一个覆盖率 92% 但变异存活率 60% 的测试套件实际保护能力可能还不如覆盖率 70% 但变异存活率 20% 的套件。这个指标对 AI 生成的测试特别有效因为 AI 最擅长的就是写出执行路径全覆盖但断言很弱的测试而变异测试恰好能精准地把这类测试的弱点暴露出来。4.2 mutmut 的选型理由与替代方案对比我们用的是 Python 生态的 mutmut因为团队里有一个 Python 的数据处理服务。Java 那边用的是 PITpitest。这里重点说 mutmut因为它的配置和踩坑经验更典型。选 mutmut 的理由有三个一是它对 pytest 支持好我们的测试都是 pytest 写的二是它的增量模式只对改动的文件做变异跑得快适合放进 CI三是它的报告格式清晰能直接定位到哪一行代码的哪个变异存活了。对比一下其他方案工具语言优点缺点mutmutPython增量快、报告清晰对复杂 mock 场景支持一般PITJava生态成熟、Maven/Gradle 集成好全量跑慢StrykerJS/TS配置灵活大项目内存占用高cosmic-rayPython支持分布式配置复杂选型的核心原则是优先选能增量跑的。全量变异测试动辄几十分钟没人愿意等最后一定会被从 CI 里踢出去。4.3 mutmut 在 CI 中的落地配置mutmut 的配置主要靠setup.cfg或pyproject.toml。我们的配置大概是这样[tool.mutmut] paths_to_mutate src/ tests_dir tests/ runner pytest -x -q dict_synonyms Struct, NamedStruct关键参数解释一下paths_to_mutate指定要变异的源码目录不要包含测试目录否则会变异测试本身。runner里的-x很重要遇到第一个失败就停能大幅加速。dict_synonyms是给自定义数据结构用的不加的话 mutmut 可能识别不了某些变异点。CI 里的 job 这样写mutation_test: stage: test image: python:3.11 script: - pip install mutmut pytest - mutmut run --paths-to-mutate src/ --tests-dir tests/ || true - mutmut results - python scripts/check_mutation_score.py rules: - if: $CI_PIPELINE_SOURCE merge_request_event changes: - src/**/*.py注意|| true因为 mutmut 在有存活变异时会返回非零退出码但我们不想让它直接失败而是用后面的脚本判断存活率是否超标。4.4 变异存活率阈值怎么定这是最容易被问的问题存活率多少算合格我们的经验是分模块定阈值不要一刀切核心业务逻辑订单、支付、权限存活率必须低于 15%一般业务逻辑低于 30%工具类、DTO、配置类低于 50% 即可为什么核心逻辑要求这么严因为核心逻辑一旦出错损失是直接的。而工具类代码即使有 bug影响面也有限。还有一个经验新代码的变异存活率要求比老代码严。老代码存量问题多一次性拉高标准不现实但新提交的代码尤其是 AI 生成的代码必须按高标准来。我们的做法是在 CI 里只对 diff 涉及的文件做变异测试这样既快又能保证新代码质量。4.5 变异测试跑出来的真实案例说一个 mutmut 抓到的经典案例。AI 生成的一段价格计算代码def calculate_discount(price, user_level): if user_level vip: return price * 0.8 elif user_level svip: return price * 0.6 return priceAI 生成的测试覆盖了三个分支覆盖率 100%。但 mutmut 把0.8变异成0.9、把0.6变异成0.7之后测试依然全绿——因为测试只断言了返回值是浮点数没有断言具体金额。这就是典型的假绿。修复方式很简单把断言改成具体金额即可。但如果没有变异测试这个 bug 会一直潜伏到某天财务对账时才发现。5. 第三道关卡CI 流水线的防假绿改造5.1 为什么前两道关卡还不够PR-Agent 和变异测试各自解决了一部分问题但它们都是事后检查。如果 CI 流水线本身的设计有漏洞AI 生成的代码还是能绕过检查。我们复盘那次事故时发现CI 流水线有几个结构性问题第一测试和实现是同一个 PR 提交的。这意味着 AI 可以同时写实现和写测试然后让测试去适配实现。正确的做法应该是测试和实现分离评审或者至少让测试先于实现被评审。第二CI 只跑通过/失败不跑质量趋势。覆盖率从 90% 掉到 85%CI 不会报警因为阈值设的是 80%。但趋势下降本身就是信号。第三没有反向验证环节。所有检查都是代码是否符合预期没有代码是否会在异常情况下崩溃的验证。5.2 测试与实现分离评审的落地方式我们的做法是在 GitLab CI 里加一个 job检测 PR 中是否同时修改了src/和tests/目录check_test_impl_separation: stage: review script: - | CHANGED_FILES$(git diff --name-only $CI_MERGE_REQUEST_DIFF_BASE_SHA HEAD) HAS_SRC$(echo $CHANGED_FILES | grep -c ^src/ || true) HAS_TEST$(echo $CHANGED_FILES | grep -c ^tests/ || true) if [ $HAS_SRC -gt 0 ] [ $HAS_TEST -gt 0 ]; then echo WARNING: PR 同时修改了实现和测试需要额外评审 exit 1 fi rules: - if: $CI_PIPELINE_SOURCE merge_request_event这个 job 会阻断同时改实现和测试的 PR要求提交者拆成两个 PR或者至少打上needs-extra-review标签由指定的人重点评审测试部分。这个规定一开始被团队吐槽太麻烦但坚持两个月之后大家发现假绿测试的数量明显下降了。因为当测试必须单独提交时提交者会不自觉地更认真地思考这个测试到底在验证什么。5.3 覆盖率趋势监控而不是阈值卡点覆盖率阈值卡点有个致命问题它只防下降不防低质量的高覆盖。一个 AI 生成的测试可以把覆盖率从 80% 拉到 95%但质量可能更差。我们的做法是改成趋势监控每次 MR 记录覆盖率变化如果单次 MR 覆盖率提升超过 10%触发人工复核因为这种大幅提升往往是 AI 批量生成测试的结果。如果覆盖率提升但变异存活率没有同步下降说明新增测试质量不高需要复核。覆盖率趋势图接入团队看板每周 review 一次。这套机制的核心逻辑是覆盖率提升本身不是目标覆盖率提升且变异存活率下降才是。5.4 反向验证让 AI 代码主动崩溃第三道关卡里最有意思的一个设计是我们加了一个反向验证环节。具体做法是在 CI 里跑一个脚本对新增的代码做几类破坏性输入测试——空值、超长字符串、负数、极大值、并发调用。这些输入不需要断言具体结果只需要验证代码不会崩溃。# scripts/fuzz_new_code.py import random import string def generate_edge_inputs(): return [ None, , * 10000, -1, 0, 2**63 - 1, -2**63, [None] * 1000, {key: None}, ]这个脚本会对新增的 public 方法逐个调用捕获所有未处理异常。如果某个方法在边界输入下抛出了未预期的异常CI 直接失败。这个环节抓出过不少问题。AI 生成的代码特别容易忽略边界条件因为它训练数据里的代码大多是正常路径的代码。反向验证相当于强制它面对不正常路径。5.5 CI 流水线的最终形态三道关卡全部落地之后我们的 CI 流水线大概长这样阶段检查项阻断条件reviewPR-Agent 评审阻断级问题未处理review测试实现分离检查同时修改且未打标签test单元测试失败test变异测试新代码存活率 15%test反向验证边界输入崩溃test覆盖率趋势单次提升 10% 触发复核这套流水线跑下来一个 MR 从提交到合并平均需要 15-25 分钟。比之前慢了不少但换来的是上线事故率的大幅下降。我们统计过改造后三个月内因代码逻辑问题导致的上线事故从每月 2-3 起降到了 0-1 起。6. 落地过程中踩过的坑与经验总结6.1 坑一PR-Agent 的 token 消耗失控PR-Agent 每次评审都要调用大模型token 消耗不小。我们一开始没做限制结果一个月下来账单超预算三倍。解决办法有三个一是加 label 触发不是每个 MR 都跑二是限制 diff 大小超过 500 行的 MR 只评审前 500 行并提示diff 过大建议拆分三是缓存评审结果同一个 commit 不重复评审。6.2 坑二变异测试拖慢 CI全量变异测试在中等规模项目上要跑 20 分钟以上团队怨声载道。解决办法是只对 diff 涉及的文件做变异。mutmut 支持指定文件列表我们在 CI 脚本里用git diff --name-only拿到改动文件只对这些文件跑变异。这样单次变异测试通常 2-3 分钟就能跑完。6.3 坑三团队抵触额外关卡三道关卡刚上线时团队抵触情绪很大觉得以前好好的现在搞这么多检查。我们的应对方式是先在一个小团队试点用数据说话。试点团队跑了两个月上线事故率下降然后拿着数据在全团队推广。同时我们承诺如果某个关卡连续一个月没有抓到有效问题就下线它。这个承诺让大家觉得关卡是有用的而不是形式主义。6.4 坑四AI 代码工具本身的差异不同 AI 代码工具生成的代码质量差异很大。我们试过几种发现有的工具生成的测试断言特别弱有的工具生成的代码边界处理比较好。我们的经验是不要指望某一个 AI 工具解决所有问题而是根据工具的特点调整关卡强度。比如某个工具生成的测试断言普遍偏弱那对它的代码就重点跑变异测试某个工具生成的代码 mock 特别多就重点跑 PR-Agent 的一致性检查。6.5 一个反直觉的结论最后分享一个反直觉的结论AI 代码质量下降往往不是因为 AI 写得差而是因为人审得松。AI 生成的代码单看每一行可能都没问题但组合起来就容易出现逻辑自洽但业务错误的情况。这种错误人眼很难发现因为代码本身是合理的。三道关卡的价值就是把这种合理但错误的代码拦下来。所以如果你问我AI coding 会不会让代码质量下降我的答案是取决于你的验证体系有没有跟着升级。验证体系不升级AI 只会让问题来得更快更隐蔽验证体系升级了AI 反而能帮你把代码质量推到一个人工达不到的高度。7. 给不同角色的一点实操建议如果你是一线开发我的建议是不要抗拒这些关卡而是把它们当成AI 代码的质检员。你写代码的速度已经因为 AI 提升了很多花几分钟处理关卡反馈换来的是上线后不用半夜爬起来修 bug。如果你是技术负责人我的建议是不要一上来就三道关卡全上先上 PR-Agent因为它最容易落地、见效最快。等团队适应了再上变异测试。反向验证可以最后加。如果你是工程效能同学我的建议是所有关卡都要支持增量模式只对 diff 做检查。全量检查在 CI 里活不过一个月一定会被团队想办法绕过。这套方案我们跑了半年多中间调整过很多次参数和阈值但三道关卡的核心框架一直没变。它不是什么高深的技术本质上就是把人写代码时代的质量意识用工具的方式重新注入到AI 写代码时代的流程里。工具会变AI 会变但代码要经得起验证这件事不会变。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进