Under the Sun with Paddy

第 84 期 · 2026年10月10日

AI写的测试到底有多水?

本文是《One-Person Quality Pipeline》的实战续篇,也是《VibeCoding中AI测试全绿之后,谁在测谎?》的回应。那篇说变异测试是AI测谎仪,这篇讲我真的用了,结果是这样。

前阵子我写了篇文章说,AI写的测试全绿不算数,得用变异测试给它测谎——往代码里故意注入小错误,看测试能不能发现。发现了算”杀死”,发现不了算”幸存”,杀死率就是测试的真实质量分。

道理很简单,代价也很清楚:每个变异体都得跑一遍测试,成本是测试套件运行时间的 N 倍。所以那篇文章停留在”演示级别”——找一个函数,跑一轮,看几个幸存者,让AI补两个断言,完事。

但我心里一直有个疑问:在一个真实项目、完整代码库上跑一轮全量变异测试,到底是什么体验? 杀死率真的会很低吗?幸存者真的都是测试缺口吗?一个人、没有团队、AI辅助的情况下,能把幸存者消化掉吗?

假期里,我花了三天时间,在手头一个 Python 后端项目上做了一次完整的实战。项目是一个数据处理管线服务,包含采集、加工、输出三个主要阶段,大概一万行左右的业务代码。

实战开始时的状态:103 个 pytest 测试全绿,无 CI,无覆盖率上报,无变异测试。

三天干了三件事:

  1. 搭了两条 GitHub Actions 质量管线(CI 快门 + 变异慢门);
  2. 跑了一次全量变异基线,10197 个变异体,杀死率 47.0%;
  3. 把 4022 个幸存变异体全部分诊消化完毕,最终杀死率 77.6%。

中间加了 248 个测试,零回归。这篇文章记录整个过程——怎么搭的、怎么消化的、抓到了什么、以及最重要的:代价是什么。


一、两条管线:一快一慢,各管一段

先说基础设施。一个人做项目,最大的问题不是”知不知道该做质量检查”,而是”检查挂在哪、谁来跑、跑多久”。本地跑嫌慢,CI 跑又不会配,最后就是零检查。

我的最终方案是两条流水线,分工明确,互不重叠。

1. CI 快门:push 即跑,合并门

第一条是 CI 流水线,push 到 main 或特性分支时自动触发,三个 job 并行跑,全绿才能合并。

Job做什么时间预算为什么这样配
lint静态检查,只开语法错误 + 未定义名称两级~1 分钟真 bug 级起步,不搞教条。格式检查目前是观察期(不阻塞),清偿完格式债务再转硬门
test单测全量 + 覆盖率上报~3 分钟CI 是权威判绿环境;覆盖率只报告、不设阈值——有就不错了,设阈值只会逼 AI 刷数字
密钥扫描全历史密钥扫描~2 分钟单人项目最容易犯的低级错误之一:把 API key 提交进仓库

有几个设计纪律值得写下来:

  • 凭证全假值。 测试里所有外部依赖都被 mock 掉了,所以 CI 环境里的密钥一律填占位字符串——满足配置加载就行,不接触任何真实凭证。
  • 依赖钉版本。 第三方 action 不是 pin 到 tag,是 pin 到具体的 commit 哈希。供应链纪律,升级时手动换。
  • 并发组取消进行中。 同一分支连推两个 commit,前一个 CI 直接取消,只跑最新的。省时间,也省 runner 分钟数。

为什么不搞 pre-commit?这个问题原文那篇《pre-commit hooks are fundamentally broken》已经讨论得很充分了。我的选择更极端:本地零钩子,全放远端。 pre-commit 检查工作树而非暂存区、交互式 rebase 会被打断、全仓检查卡住无关提交——这些问题在单人项目里一样存在,而且一个人时你更懒得修。

反正 commit 之后 3 分钟内 CI 就给结果了,不差这点时间。坏了就修,修了再推。

2. 变异慢门:手动触发,里程碑级测量

第二条是变异测试流水线。这是本文的主角。

先说触发方式:纯手动触发(workflow_dispatch 按钮),零定时任务。

原文建议”mutmut 周更(定时跑)”,实战后我改了主意。里程碑驱动的项目里,”每里程碑跑一次”比 cron 更贴合节奏——开发期代码天天在变,测试也天天在补,每天跑出来的数字都在漂移,没有参考价值。而且 GitHub 的定时 workflow 只在默认分支生效,多一层心智负担。

全量跑一次多久?10197 个变异体,在 GitHub 托管的 4 核 runner 上,大约 25–30 分钟。这个时长很关键:它决定了变异测试不可能作为每次提交的门,但作为里程碑级测量工具完全够用——等半小时,拿一个全量数字,值。

那跑出来的结果怎么看?mutmut 自带的 HTML 报告功能比较弱,我写了个脚本自己生成可视化的报告,然后通过 GitHub Pages 部署,参考了 Codecov:

报告首页是总览(总变异体数、杀死数、幸存数、无测试覆盖数、杀死率),下面是模块汇总表——每个模块的各项数字和杀死率一眼可见。点进每个模块,幸存者按文件和函数展开,每条都包含:变异类型(布尔翻转、运算符替换、字符串字面量改动等)、覆盖它的测试清单、推断的变异算子、源码链接,以及一键复制的复跑命令。

还有一个”复制给 Agent”按钮——把分诊指令和这条变异体的全部信息打包复制,直接贴给 AI 就能开始干活。这是后面分诊环节的入口。

3. 踩过的坑

搭这条管线的过程不是一帆风顺的,列几个真坑,省得后人再踩:

坑一:mutmut 3.8 原生 Windows 直接不能跑。 这比原文说的”需要 WSL”还严重——CLI 模式直接拒绝在 Windows 上运行。但意外发现了一条生路:库级 API(create_mutations)可以静态重建任意变异体的 diff,不需要跑测试。 这个发现成了后面整个分诊流程的核心工具——几千个幸存者的”长什么样”,全靠这个 API 在本地离线查看。

坑二:配置文件必须先于任何 mutmut 命令存在。 mutmut 3.8 在导入期就加载配置,如果配置文件是在 mutmut run 之前一步才生成的,配置不会生效。听起来是小事,但第一次排查花了我一个多小时——所有变异体都跑不出来,日志里什么错都没有。

坑三:continue-on-error 的 job 不能直接 deploy-pages。 变异 job 因为各种原因可能部分失败,但报告还是要部署的。一开始把 deploy 放在同一个 job 里,结果 Pages 部署永远被跳过。最后拆成两个独立 job,变异 job 允许失败继续,部署 job 等待变异 job 完成后无条件执行。

坑四:CLI 的结果命令出过空输出故障。 有一轮跑完,mutmut results 命令什么都不输出,但文件其实都在。最终解决方案是直接读元数据文件——mutmut 把每个变异体的判定结果存在 .meta 文件里,用脚本直读,比 CLI 可靠。


二、分诊:4022 个幸存者怎么吃完

基线跑完,数字出来了:10197 个变异体,killed 3566,survived 4022,no-tests 2609。排除无测试覆盖的,杀死率是 47.0%。

这个数字说实话,比我预想的好。我本来以为 AI 写的测试可能只有 20%–30% 的杀死率。47% 说明快乐路径上的测试密度其实不低,问题集中在合同级断言——数据结构的字段级契约对不对、配置项逐键生不生效、边界条件的语义清不清楚。

接下来是核心问题:4022 个幸存者,怎么吃?

1. 四分类法:不能见一个修一个

第一个直觉反应是”让 AI 把幸存者全修了”。但这是错的。

幸存者里混着几种完全不同的东西,修法完全不一样。如果不分青红皂白直接让 AI 补测试,结果就是 AI 学会了加一堆无意义的断言来刷变异分数,分数上去了,质量没上去——正好违背了用变异测试的初衷。

所以第一步是分类。我用了四分类法:

类别含义处置
A 等价变异不改变任何可观察行为(比如 ORM 字段的默认值对显式 None 兜底)登记豁免,写清楚理由
B 防御变异发生在防御性代码上,正常路径永远触达不了同 A,登记豁免
C 测试缺口变异确实改变了行为,但测试没有断言到这个面补测试,引条款号
D 设计模糊设计书没钉死,两种读法都通,变异后算不算 bug 说不准升级给用户拍板,拍板结果落设计书

判定顺序也有讲究:先问”可观察行为变了吗”——没变就是 A/B;变了再问”设计书管不管”——管但代码没实现或者测试没覆盖,就是 C;不管,就是 D。

这个分类本身不难,难的是量太大。4022 条,一个人一条一条看,看到猴年马月。

2. 多 Agent 协作:统筹 + 执行 + 人在中间

这就是我这次摸索出来的工作模式——三层分工:

用户(拍板所有模糊点)
    ↑
统筹 Agent(任务书、验收、台账、记忆)
    ↑
执行 Agent(每批一个新实例,领任务书干活)

实际工作流是这样的:

  1. 统筹 Agent 写好一批任务书——分多少条、哪些模块、分类标准是什么、验收条件是什么,连同环境事实(项目结构、设计书位置、测试约定)一起打包;
  2. 我开一个新的编码 Agent 窗口,把任务书贴进去——执行 Agent 上线;
  3. 执行 Agent 干几小时,分类、补测试、核验,最后写一份工作汇报;
  4. 我把工作汇报复制回统筹 Agent 的窗口——统筹 Agent 审核验收、更新台账、决定下一批干什么。

没错,人是”最没价值”的任务路由器。 就是复制粘贴——开玩笑地说,这是掌握权力的乐趣。但认真讲,这也是目前非如此不可的架构:Agent 只是你的执行者,方向依然由人控制。工具还没进化到 Agent 能自己开新会话、自己分工协作的程度(希望未来也不会这样),人就是那个天然的调度器。

这个模式有几个关键设计:

执行 Agent 每批一个全新实例。 不重用,干完就”辞退”。好处是每批的上下文都是干净的,不会被上一批的错误理解污染;坏处是每批都得重新”入职”——任务书必须写得足够完整,让一个全新的 Agent 拿到就能干活。

统筹 Agent 只有一个实例,状态全外部化。 统筹 Agent 要记住所有批次的进展、所有模糊点的拍板、所有数字的对账。但它不是靠上下文窗口记的——上下文窗口撑不住。靠的是三个外部文件:

  • 台账:每个里程碑一行速览,数字、状态、关键事件;
  • 任务书:每批一份,含环境事实、分类规则、验收标准,以及”回写区”让执行 Agent 填结果;
  • 持久记忆:跨会话的设计决策和纪律,不让每轮重新谈判。

第一次分诊结束时,统筹 Agent 的上下文用了 69%,但它依然能准确回答任何批次的任何细节——靠的是这三样东西,不是窗口里还留着什么。

3. 核验循环才是真正的质量门

分类完了不算完。如果只是让 Agent 把 4022 条分成 ABCD,那分类质量没法保证——Agent 会偷懒、会误判、会誊写错。

所以每批都有一个核验-改判循环:

  • 对所有分类为 C(测试缺口)的条目:逐条手工施加变异(改源码 → 跑测试 → 确认落红 → 版本回退还原),必须全部杀死。有活的就说明分类错了或者测试没写对;
  • 对所有分类为 A(等价)的条目:同样逐条施加变异,必须全部幸存。有死的就说明不是等价的,是漏网的测试缺口;
  • 不满足就归因、改判、修测试,然后再跑一轮,直到零意外。

跑偏确实每批都发生,但都被这个循环抓住了:

  • 有一批 11 条”过度断言”——把无条款依据的形态断言写成了杀变异的测试,被核验抓出来修正;
  • 有一批 10 条台账誊写笔误——分析时是 A 类,誊写成了 C 类;
  • 有一批 28 条 C→A 改判(实测发现等价)+ 7 条 A→C 改判。

定义约束的是初始判断,循环约束的是最终质量。分诊类任务的验收标准不应该是”分类完了”,而是”分类经得起逐条施加的机械复核”。

11 个执行 Agent 批次 + 1 次统筹亲执,4022 条全部归类完毕,然后又跑了一次全量 dispatch 收官对账。数字对得上,零回归——基线那 3566 个 killed 的变异体,一个都没复活。


三、抓到了什么

说了半天流程,到底抓到了什么?这是大家最关心的问题。

先说结论:经典意义上的 Bug 只有一个,但变异测试抓到的东西比 Bug 更值钱。

按严重度排列:

1. 真行为缺陷 1 个

某条业务路径中,异常重试耗尽后,变量仍持有非法数据,最终照常入库且标记为成功——与代码自身注释和设计书条款”耗尽落失败态、不入库”相悖。

这个 Bug 不是跑变异测试跑出来的——是分诊中读代码对照条款时发现的。后续处置是产品决策:项目负责人(也就是我自己)裁定改为”依旧入库但打违规标记 + 前端提示”,条款已修订入册。

无论最终语义是哪种,这个行为在分诊之前处于”代码、注释、设计书三方各说各话”的状态。 变异测试只是给了我一个不得不坐下来逐条读代码、逐条对照设计书的理由。

2. 测试质量缺陷 1 类

既有 HTTP 客户端测试的伪造响应只对状态码 ≥400 抛异常,而真实客户端库对 ≥300 就抛——恰好掩盖了设计书硬约束”304 判定必须先于状态检查”的可测性。

旧测试自己全绿,但保护是空的。手工施加 304→305 变异时首轮杀不掉,才暴露出来。

3. 设计书写了、代码没实现 4 处

  • 某降级路径的三级兜底链(设计书写了,代码只实现了前两级)
  • 某突发流量上限保护(设计书有条款,源码无落点)
  • 某配置键的禁用规则(设计书写了,无实现面)
  • 空输入集的跳过语义(设计书写 SKIP,实现落 FAILED)

这些都是读代码对照设计书时发现的。变异体本身可能只是某个条件分支的布尔翻转,但当你翻到”这段代码在干什么”的时候,发现设计书写的功能根本没实现。

4. 规格盲区 8 处

设计书没钉、两种读法都通的语义:时间边界的整点判定、会话过期的临界时刻、无对端身份时的默认取值、可选字段显式 null 的语义、URL 尾斜杠归一化、兼容接口的请求头形态、标识字符串的大小写规则、多匹配命中时的选择优先级。

全部由我逐条拍板,落册为设计书的多轮修订。

这是变异测试最让我意外的一个价值:它不仅是测试质量的探测器,也是规格完备性的探测器。 测试杀不死的变异体,往深了挖,很多时候不是测试写得不好,是设计书没写清楚——而设计书没写清楚的地方,代码迟早会出问题。

5. 等价判据的沉淀

1552 条豁免登记里,沉淀了一批可复用的等价判据:

  • ORM 字段默认值对显式 None 的兜底(”值 → None”变异天然等价)
  • HTTP 客户端库统一小写头名的行为
  • feed 解析库的标识字段回填行为
  • ORM 回写时的修复域规则
  • mutmut 对模块导入期执行代码的激活盲区

这些是下一次变异测试的”免验证白名单”,也是项目知识的沉淀。


四、代价:好听的说完了,说点不好听的

上面说的都是收益。但如果只说收益,那是忽悠人。这次实战的真实代价,比我预想的大。

1. 功能增量为零,测试花了三天

三天时间,一行新功能都没加。全在搭管线、跑基线、分诊、补测试。

对于一个还在早期的单人项目,这三天的机会成本不低。如果我用这三天加功能,可能又多了一两个模块。但问题是——不加这些质量基础设施,后面加的功能越多,债务越重,到时候想补都补不了。

话是这么说,心理上还是会焦虑的。每天打开仓库,看到功能列表一点没长,全是测试文件在增加,会忍不住想”我到底在干嘛”。

2. 测试管线是紧箍咒,新增功能的成本在上升

这是更长期的问题。现在测试和变异测试的基线已经建起来了,之后再加新功能,就不是”写完能跑就行”了——你得补测试,补完测试还得想变异测试能不能杀死,杀不死还得补断言。

而且你不能动之前的东西。改一个已有函数的行为,可能导致几十个测试变红、几百个变异体从 killed 变成 survived。你得一个一个修,或者一个一个重新判定。

质量管线保护了你,也限制了你。 它让你不敢随便改东西——这在项目稳定期是好事,在快速迭代期可能是坏事。

3. 灵活性下降,变更不再自由

以前写代码,想到什么改什么,改完能跑就行。现在呢?

改一个配置项的默认值 → 得看有没有测试覆盖这个默认值 → 没有的话测试当然全绿,但变异测试会暴露 → 你要么补测试,要么登记豁免 → 豁免还得写理由,理由还得能经得起下一轮复核。

改一个函数的返回结构 → 下游的断言全得改 → 改完还得跑变异确认没漏 → 漏了再补。

自由度明显下降了。 好处是质量有底,坏处是想快速试错的时候没那么爽了(特别是 Vibecoding 的舒适区——出 demo 快速验证想法)。

4. 依赖于设计完善的 TDD 式产品设计书

这个是隐性成本,而且是前提条件。

为什么我能做四分类?为什么 C 类补测试时能”引条款号”?为什么 D 类能”升级给用户拍板、拍板落设计书”?因为这个项目有两份完整的正式设计书——产品设计书和技术设计书,条款都有编号。

如果没有设计书呢?如果代码就是需求本身呢?那 D 类(设计模糊)会爆炸性增长——你根本不知道变异后算不算 Bug,因为没有”正确答案”可以对照。

换句话说,这套方法的有效性,取决于你的设计书有多完善。 设计书写得越细、越可验证,变异测试的产出越高;设计书写得越模糊、越靠代码自证,变异测试就越像在给自己找事。

这是 TDD 的老问题了——先有规格,再有测试,再有代码。AI 时代这个逻辑没变,只是执行速度变快了。

5. 治理负担:编号、台账、任务书,本身就是工作

说点鸡蛋里挑骨头的。

为了管理这次实战,我搞了一套编号体系:G0/G1 是里程碑,C1 是第一次分诊……还有各种 tag、各种分支、各种文档。

对当事人来说,这套编号体系效率极高——我说”G1 完成了”,我自己立刻知道是哪件事、包含哪些批次、数字是多少。但对后来者(或者说几周之后的我也会全忘光了)呢?这就是一套解码负担。

这意味着这套模式不适合长期、也不适合团队开发。 一个人的时候你可以用自己的黑话体系,两个人就得对齐术语,十个人就得有正式的流程文档。统筹 Agent 的”外部化状态”(台账、任务书、记忆)本质上是在补”没有第二个人”的缺口——如果有团队,这些东西本来是在沟通中自然对齐的。

6. 命名债务:过程命名 vs 对象命名

再挑一根骨头。

分诊产出的一批测试文件按批次号命名,文件名编码的是过程(第几批),不是对象(测什么)。要回答”某个模块的某个契约在哪测的”,你得打开文件读 docstring。

当时的取舍是:批次号与豁免清单的小节、种子文件、任务书交叉引用一一对应,改名会断这层可追溯性。但这个取舍对读者不友好。

命名应当编码对象,历史归属放注释里。 这条应该进纪律,但我到现在还没来得及改——又是一笔债务。


五、结论:修正后的单人质量管线

回到最开始的问题:变异测试在单人项目里到底值不值?

我的答案是:值,但不是作为日常工具,而是作为里程碑级测量工具。 它的价值也不止于”测测试质量”——它同时在测你的规格完备性、测你的设计书有没有写清楚、测你的代码和设计有没有走偏。

这次实战也验证和修正了《One-Person Quality Pipeline》原文的几个结论:

原文观点实战结论
mutmut 周更(定时跑)修正:纯手动触发(dispatch 按钮),零定时任务。里程碑驱动的项目里,”每里程碑跑一次”比 cron 更贴合节奏
Windows 本地跑 mutmut 需 WSL证实,且更严重:mutmut 3.8 直接拒绝原生 Windows。但库级 API 可以静态重建 diff,成为分诊核心工具
自建 runner 做变异场地修正:托管 runner 完全够用。 一万个变异体在 4 核 runner 上 25–30 分钟,public 仓库还免费
pre-commit 只放凭证扫描+格式化,测试放远端证实。本次连 pre-push 都没配,全靠 CI 门
变异测试适用于”要维护几个月以上的项目”证实,但补充前提:适合作为里程碑级测量,不适合作为日常提交门

把这次实战验证有效的东西压缩成一张表,作为更新版的单人质量管线参考:

环节实测后的形态
提交门CI 三 job:真 bug 级 lint + 单测(CI 为权威判绿)+ 全历史密钥扫描
覆盖率覆盖率上报 + badge,仅报告不设阈值;无覆盖清单是独立里程碑的输入
变异测量dispatch 手动触发,里程碑级两时点跑(基线/收官);runner 25–30 分钟;报告部署 Pages
变异消化离线分诊四分类(等价/防御/缺口/模糊)+ 断言强制引条款号 + 逐条施加核验循环 + 豁免清单书面化
模糊点处置D 类一律升级为用户拍板项,拍板落册设计书修订——变异测试成了规格完备性的探测器
生产变更白名单制(任务书列明才可改)+ 先红后绿(施加破坏验证测试落红 → 还原 → 实现 → 绿)
治理台账一行速览 + 任务书回写区 + 执行 Agent 汇报 + 统筹独立复核
Agent 分工基建 Agent 与里程碑统筹分设;统筹的状态全外部化,不依赖上下文窗口

最后说一句感性的。

做这次实战之前,我以为变异测试的意义是”证明 AI 写的测试有多水”。做完之后发现不是——它真正的作用是逼你坐下来,把你以为你懂的代码,一条一条重新读一遍、对照一遍、想清楚一遍。

广告位
广告位
广告位
广告位
广告位
广告位

读者来信(1)

  1. 秋风于渭水 的头像

    最近有个项目,让AI快速审查,最后给我说通过30+项检查,冒泡全绿,结果我一看,AI设计的检查中有20多个 都是属于,只要有报错返回就算通过的,这种检查没任何意义,只能说明代码中这些部分设计了报错抛出机制罢了。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注