工智能测试CI/CD
摘要:单元测试全绿、覆盖率 92%、CI 一路通过,合并三天后线上炸了。复盘发现,测试里有一条断言写的是
assert result is not None——它跑了那行代码,却没验证结果。AI 写代码之后,这样的"假通过"正在变多。
一个全绿的 PR,三天后炸了
上周 review 一个 PR:AI 写的,300 多行,单元测试全绿,覆盖率 92%,CI 一路通过。我点了合并。
三天后,线上炸了。
复盘时找到的原因有点讽刺——那段代码里有个边界判断被写反了,而测试里恰好有一条断言写的是 assert result is not None。它执行了那行代码,让覆盖率变绿了,但对"结果对不对"什么都没做。
这不是个例。有团队实测过一个 Python 模块:行覆盖率 100%,变异得分(mutation score)只有 4%。测试把每一行都跑过一遍,却没有一行真正验证结果。
再看两组数字:
- AI 生成的 PR 合并率约 32.7%,人写的约 84.5%——大量 AI 代码卡在队列里没人敢合
- 约 60% 的 AI 相关故障属于"静默失败":测试过了,评审也过了,在生产某个边缘 case 上安静地崩
这三个事实指向同一件事:AI 没有让"写代码"变难,它让"判断这段代码能不能上线"变难了。
我们后来把验证拆成了三道关卡,分别对应三个问题:人审不动、验证可能是假的、要验的量太大。
第一道关卡:机器先审,人做判断
它解决什么问题
AI 一次改动 P75 超过 400 行,没人能逐行看完;31.3% 的 PR 是零评审直接合并的。
解法不是让人更努力地看,而是让机器先做一轮过滤,人只看机器看不了的部分——跨切面改动、业务正确性、架构一致性。
工具与地址
PR-Agent,Apache 2.0 开源:
- 官方仓库:
https://github.com/The-PR-Agent/pr-agent - Docker 镜像:
pragent/pr-agent(v0.34.2 之后的新命名空间,旧的codiumai/pr-agent已冻结) - 支持 GitHub / GitLab / Bitbucket / Azure DevOps / Gitea
- 模型可换:OpenAI / Claude / Gemini / DeepSeek / OpenRouter / Ollama
网上大量教程还指向
Codium-ai/pr-agent,项目已移交社区维护,照旧教程配置容易拉到过期地址。
15 分钟接入
1. 准备 Key。 任选一家(OpenAI / Anthropic / DeepSeek),进入仓库 Settings → Secrets and variables → Actions,新建 Secret OPENAI_KEY。
2. 新建 .github/workflows/pr-agent.yml:
name: PR Agent
on:
pull_request:
types: [opened, reopened, ready_for_review]
issue_comment:
types: [created]
jobs:
pr_agent_job:
# 不加这行,机器人评论会触发机器人,无限循环
if: ${{ github.event.sender.type != 'Bot' }}
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
contents: write
steps:
- name: PR Agent action step
uses: the-pr-agent/pr-agent@main
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
换模型只需改 env:
# Claude
ANTHROPIC.KEY: ${{ secrets.ANTHROPIC_KEY }}
CONFIG.AI_PROVIDER: "anthropic"
CONFIG.MODEL: "claude-sonnet-4-6"
# DeepSeek(成本更低)
OPENAI.KEY: ${{ secrets.DEEPSEEK_API_KEY }}
OPENAI.API_BASE: "https://api.deepseek.com/v1"
CONFIG.MODEL: "deepseek/deepseek-chat"
3. 提交 PR。 之后每次开 PR,CI 自动跑。
4. 在 PR 评论区下命令,这是它的主要交互方式:
/review # 全面审查:安全、逻辑、风格
/describe # 自动生成 PR 标题、摘要、变更类型、标签
/improve # 逐行改进建议,输出可直接 commit 的 diff
/ask 这段并发逻辑在高并发下会不会有问题?
每条命令一次 LLM 调用,约 30 秒出结果,单个 PR 成本几分钱。
本地先试也可以:
pip install pr-agent
export OPENAI_KEY=your_key_here
pr-agent --pr_url https://github.com/owner/repo/pull/123 review
决定成败的一步:让它别什么都评论
多数团队失败不是模型不行,而是它什么都要评论,最后被开发者整体忽略——连同有价值的意见一起。
仓库根目录新建 .pr_agent.toml:
[config]
model = "gpt-4o"
response_language = "zh-CN" # 中文团队务必打开,输出变中文
temperature = 0.1 # 越低越稳定
[pr_reviewer]
require_score_review = true # 工作量评估(S/M/L/XL)
require_security_review = true # 专项安全检查
require_tests_review = true # 是否配套写了测试
num_code_suggestions = 4 # 限制条数,避免刷屏
inline_code_comments = true # 行内评论,精确到行
extra_instructions = """
本项目重点关注:
1. 并发问题(goroutine 泄露、竞态、锁粒度)
2. 错误处理是否吞掉异常
3. 数据库操作是否有事务边界
4. 对外接口的输入校验
以下情况禁止评论:
- 纯命名与代码风格偏好
- 不影响正确性的重构建议
- 没有明确改法的空泛建议
"""
[ignore]
glob = ["*.lock", "*.min.js", "dist/**", "*.generated.*", "docs/**", "*.md"]
那段"禁止评论"必须写。[ignore] 同样关键——排除 lock 文件与构建产物,是降噪最有效的一招。
三个容易踩的坑
1. 别为跑通 fork PR 换成 pull_request_target。 pull_request 事件对 fork PR 默认不暴露 secrets,这是 GitHub 的安全设计;换成 pull_request_target 等于把 Key 交给任何外部提交者。fork PR 由维护者手动触发 /review。
2. 机器人触发死循环。 issue_comment 用于评论交互,但必须加 if: github.event.sender.type != 'Bot'。
3. 确认只发 diff 不发明文全库。 敏感仓库优先自托管 + 内网模型。该项目曾因凭证暴露问题(#2445)临时禁用过 /help_docs。
第二道关卡:验证"验证"本身
第一道关卡保证有人看代码,这一道保证**"通过"是真的**。
覆盖率为什么会骗人
行覆盖只统计"这行有没有被执行",完全不关心"有没有断言它的结果"。AI 特别容易写出只调用不断言的测试,让数字好看、CI 变绿,然后什么都不保护。
变异测试就是用来戳破这个的:工具自动往代码里注入微小错误(变异体),再跑测试。
- 测试报错 → 变异体被杀死 → 这条测试真的在保护代码
- 测试依然通过 → 变异体存活 → 这里缺一条有效断言
常见注入:操作符替换(> 变 >=、+ 变 -)、返回值改写、删掉判断分支。
工具
| 语言 | 工具 | 安装 |
|---|---|---|
| Python | mutmut | pip install mutmut |
| JS/TS | Stryker | npm i -D @stryker-mutator/core |
| Java | PIT | Maven / Gradle 插件 |
三条命令
# 1) 先出覆盖率,再只变异"被测试覆盖到的行"
# 没被覆盖的行变异了也抓不到,纯浪费时间
pytest --cov=src --cov-report=xml
mutmut run --paths-to-mutate=src/ --use-coverage --no-progress
# 2) 并行执行(套件 10 秒内跑完时,4 进程通常提速 2-3 倍)
mutmut run --paths-to-mutate=src/ --processes 4
# 3) 看结果 / 只看存活 / 出 HTML 报告
mutmut results
mutmut results --survived
mutmut html # html/index.html,红色行就是薄弱点
mutmut 状态存在
.mutmut-cache(SQLite),跨分支持久化,已杀死的变异体不重复跑——这是它能进 CI 的前提。
做成 CI 门禁
.github/workflows/mutation.yml,只对核心模块触发(全仓跑会慢到不可接受):
name: Mutation Gate
on:
pull_request:
paths:
- 'src/core/**'
- 'src/payment/**'
jobs:
mutation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install pytest pytest-cov mutmut
- name: Coverage first
run: pytest --cov=src --cov-report=xml
- name: Run mutation
run: mutmut run --paths-to-mutate=src/core/ --use-coverage --processes 4 --no-progress
- name: Gate
run: |
# 不同 mutmut 版本输出字段略有差异,首次请先本地跑一次确认
mutmut results | tee mutation-report.txt
SCORE=$(grep -oP 'Mutation score\s*:?\s*\K[0-9]+' mutation-report.txt || true)
echo "mutation_score=${SCORE:-unknown}"
if [ -n "$SCORE" ] && [ "$SCORE" -lt 80 ]; then
echo "FAIL: mutation score ${SCORE}% < 80%"
exit 1
fi
- uses: actions/upload-artifact@v4
with:
name: mutation-report
path: mutation-report.txt
阈值不要一刀切,这决定能否持续:
| 模块类型 | 建议门槛 |
|---|---|
| 核心 / 资金 / 安全相关 | ≥ 80% |
| 一般业务模块 | 60-70% |
| 适配层 / 胶水代码 | 不设门禁,观察即可 |
把漏网之鱼喂回给 AI
mutmut html 会标出哪些行没被真正验证。把对应变异体直接贴给 AI:
下面是我的代码,以及它的一个变异体(注入了一个 bug),但我的测试没有发现它:
【原始代码】
<code>
【变异后】
<diff>
请只做一件事:写出一条能杀死这个变异体的 pytest 测试。
要求断言具体到数值或状态,不要用 assert x is not None 这类弱断言。
不要修改业务代码。
比笼统说"帮我补测试"有效得多——给了它一个具体到不能再具体的靶子。
第三道关卡:从源头减少要验的量
前两道是"怎么验",这一道是让需要验的东西变少。
PR 体积上限
最立竿见影的一条。diff 压到人类愿意看的规模,前面的评审才可能发生。
# CI 中加入:改动超 150 行直接失败,要求拆分
LINES=$(git diff --shortstat origin/main...HEAD | awk '{print $4}')
echo "changed lines: $LINES"
if [ "$LINES" -gt 150 ]; then
echo "FAIL: diff too large ($LINES > 150). Split this PR."
exit 1
fi
配套政策:禁止零评审合并。
复杂度与函数长度
AI 容易写出几百行的大函数,人一看就放弃 review。
pip install radon
radon cc src/ -a -s # 圈复杂度,建议 >10(C 级以上)报警
断言密度
# 粗略自检每个测试文件的断言密度
pytest --collect-only -q | wc -l
grep -rc "assert" tests/ | awk -F: '$2 < 3 {print "低断言文件:", $1}'
断言数是形式,变异得分是实质——两者结合才有效。
先写判据,再让 AI 写代码
投入产出比最高的动作。动手之前,先把"什么算合格"落成判据:
写代码前先列出验收标准:
- 功能性:输入输出映射,列出 3 个具体边界值
- 可靠性:异常输入的预期行为,失败是否需要重试或回滚
- 安全性:输入是否需校验,有无敏感信息落日志
- 可维护性:圈复杂度是否超 10,单函数是否超 50 行
- 性能:预期 QPS,是否存在 N+1 查询
列完再写代码,并把每一条写成测试。
AI 最擅长满足你写下来的那部分,最难补的是你没写的那部分。把隐含需求显式化,返工率会明显下降。
落地节奏
一次性上全套的团队基本都失败。建议这样推进:
第 1 天(30-60 分钟)
- 接入 PR-Agent,写好
.pr_agent.toml的"禁止评论"清单 - 找 3-5 个真实 PR 跑
/review,人工统计"有用评论占比"
第 1 周
- 有用率低于 50% 就继续收紧 prompt,先别扩大范围
- CI 加 PR 体积门禁(>150 行 fail)
- 对一个核心模块建立变异得分基线
第 2-3 周
- 核心模块变异门禁上线(阈值 80%)
- 把
mutmut html的红色区域当每周补测试的任务清单 - 每周跟踪两个指标:中位评审耗时、逃逸到生产的 bug 率
判断成败的标准很简单:两周后,团队是主动去看评审意见,还是已经装了忽略插件。
排错表
| 症状 | 原因 | 解法 |
|---|---|---|
| 评审意见被当噪音忽略 | 什么都要评论,误报率高 | 收紧 extra_instructions,写明"禁止评论"清单;先只开安全和缺测试两类 |
| PR 一评论就无限循环 | 机器人触发机器人 | 加 if: github.event.sender.type != 'Bot' |
| 改了配置没生效 | 用了旧的 action 地址 | 换成 the-pr-agent/pr-agent@main |
| 变异测试跑几小时,CI 超时 | 全仓跑且没用 coverage 过滤 | 加 --use-coverage,限定 --paths-to-mutate,只对特定路径触发 |
| 变异得分长期很低补不动 | 测试写成了"只调用不断言" | 用上面那段 prompt 把存活变异体喂回 AI 补断言 |
| fork PR 的自动评审不工作 | 安全设计,secrets 不暴露给 fork | 维护者手动 /review;别换 pull_request_target |
| 团队抵触"AI 审我的代码" | 定位成了挑错 | 定位成第一道过滤,人做最终判断;AI 评论不设成合并硬门槛 |
回到开头那个 PR。它的问题不在于 AI 写得差,而在于我们的验证体系默认了"通过"等于"正确"。
这个默认在 AI 时代失效了。当写代码趋近于免费,决定"这段代码能不能上线"的判断力,就成了整条交付链上最贵的能力。
你们团队踩过"测试全绿、上线就崩"的坑吗?最后是怎么查出来的?评论区聊聊 👇
出处:公众号《AI 代码的验证:一次测试全绿、上线就崩之后的改造》,转载请注明。