前面四篇解决的是"怎么把测试跑好":让 AI 在你的框架里写脚本(一)、CI 一片红时批量分诊(二)、把分诊接进流水线(三)、脚本坏了让它自愈(四)。
这一篇往上升一层:用例越攒越多,全量跑一遍要几十分钟甚至几小时,可这次提交明明只改了几行------到底该跑哪些?
老办法是两个极端,都很难受:
- 全量跑:稳,但慢。改一行文案等 40 分钟,开发者等不及就绕过 CI 直接合,流水线形同虚设。
- 靠人挑:测试负责人凭经验勾一批,快,但漏没漏全看运气。挑的人请个假,回归就是盲盒。
这两年开始有第三种答案:让机器基于"这次改了什么"和"历史上什么改过会炸",自动圈出一个最小但够用的回归子集。 业界叫它测试影响分析(Test Impact Analysis, TIA)或预测式测试选择(Predictive Test Selection)。这一篇讲清楚它到底怎么圈、能信几成、有哪些现成工具,它说这批用例可以不跑,你凭什么敢不跑。
一、先搞懂:机器圈范围的三种思路,各有各的盲区
别把"AI 圈回归"当成一个魔法。它内部就三条技术路线,思路不同、能抓什么不能抓什么也完全不同。选型和信任度,都建立在你分得清这三者的前提上。
1.1 依赖追踪:这几行代码被哪些测试"碰"到了
最朴素、最确定性的思路:代码和测试之间存在调用/依赖关系,改了一个函数,就把所有"会执行到这个函数"的测试找出来跑。
实现上分两种:
- 静态依赖(看代码结构) :解析 import / 调用图。改了
calculator.add(),凡是from calculator import add的测试都圈进来。快、不需要先跑一遍,但偏保守------它看的是"导入了没有",不是"真的执行了没有",而且看不懂动态调用、字符串反射、配置文件、数据库迁移这些。 - 动态依赖(看真实执行) :跑测试时用覆盖率工具记录"每个测试实际执行了源码的哪些行",改了哪几行,反查命中这些行的测试。更精确,代价是要先全量跑一遍、持续维护一张"代码行 ↔ 测试"映射表。Python 生态的
pytest-testmon就是这条路(底层用 Coverage.py)。
这条路的本质是回答 "这次改动,结构上会碰到谁" 。它对纯代码重构很准,但有一个致命的盲区:它只能找到"直接碰到改动代码"的测试,抓不到跨层的间接影响。 你改了一个公共工具函数的一个边界行为,依赖追踪能找到直接调它的测试;但某个下单流程因为这个边界行为变化而在特定数据下出错------如果那条 E2E 链路在这次跑的时候没真正走到这行,它就圈不到。
更别说根本没被任何测试覆盖到的代码、以及通过配置/SQL/接口契约产生的影响,依赖追踪天然看不见。
1.2 历史失败预测:什么样的改动,历史上最容易炸
第二条路换了个问题:不问"会碰到谁",而问 "根据历史数据,这批用例里,哪些最可能在这次改动上失败"。
这是大厂真正大规模在用的方法。Meta(Facebook)把它做成了一个二分类模型:输入是"一次代码改动 + 一个测试",输出是这个测试在这次改动上失败的概率。特征来自三类数据------改动特征(动了哪些文件、加删多少行、改动频率)、测试历史特征(这个测试历史失败率、平均耗时、最近状态)、以及改动和测试之间的关系。模型用过去三个月的真实跑批数据训练(论文原文用的是梯度提升决策树 GBDT),超过概率阈值、或排名最靠前的一批测试被选中。
效果(论文原始数据):
- 能报回 超过 95% 的单个测试失败 ,超过 99.9% 的"有问题的代码变更"(只要这次改动有任意一个被选中的测试失败,这个变更就被拦下,这才是 CI 最看重的召回);
- 选中的测试数不到按构建依赖圈选的三分之一;
- 变更触发测试的整体基础设施成本降低约一半 。(Machalicek et al., Predictive Test Selection, arXiv:1810.05286)
另一组来自工业界落地的公开数据:T-Technologies 在一个含 6500 多个 UI 测试 的代码库上落地同类方案(方法名为 T-TS,把提交表示成"改动文件的词袋"+跨文件特征,同样不用 coverage 映射),线上结果是只跑约 15% 的用例,执行时间缩短到约 1/5.9,CI/CD 流水线提速约 5.6 倍,失败检出率仍保持在 95% 以上 。这组数字的一手出处是工业论文(Targeted Test Selection, arXiv:2509.10279),Ranger 博客对它有转述(Ranger)。注意这是单一代码库、特定技术栈的线上数字,说明方法在大型 UI 套件上也能成立,但不代表你上去就有 5.9 倍。
这条路的本质是用概率换效率:它从不说"只有这些测试相关",只说"这些最可能炸,先跑这些"。它的命门也很清楚------
- 要历史数据。新项目、刚重构完、测试用例大改之后,模型没东西可学,前几个月只能老老实实全量。
- 会被 flaky 污染。拿不稳定测试的结果去训练,模型会学歪,所以必须先治 flaky(这正是第二篇的主题),训练时对重复试验有专门处理。
- 对"从没失败过、但这次真会出事"的新路径,它只能靠相似性猜,猜不到就是漏。
商业产品 Launchable 走的基本就是这条路线:吃你 CI 的历史测试报告 + 本次改动,用模型给测试排序、圈一个子集。
1.3 LLM 语义理解:读 diff 和需求,像人一样判断该测哪
第三条路是 2024 年后才有的:直接把 PR 的 diff、关联的需求单、代码结构喂给大模型,让它像资深测试一样"读懂这次改动想干什么",再推荐要回归的业务场景。
它的吸引力很明显------前两种都需要埋点、积累数据,LLM 几乎零冷启动,还能理解"这次改的是下单的优惠计算,那支付、退款、优惠券叠加这几条业务线都得看"这种跨模块的业务语义关联,这恰恰是依赖图和历史统计的弱项。
但这里必须泼一盆冷水,2026 年一项覆盖 8 个主流大模型、2 万多个程序变体的实证研究发现:LLM 对代码改动的判断高度依赖表面文本线索,而不是真正的语义影响。 (说白了:它看的是 diff 的"卖相"------改了多少行、改没改名、像不像大改动------而不是真去判断改完后程序跑起来的行为变没变。)最典型的------面对"语义保持的改动"(SPC,比如改名、抽函数、调整格式,功能一点没变),大模型反而更不稳定:生成的测试大量推倒重来,通过率从基线往下掉。也就是说,一次纯粹的重构(本不该影响任何业务),LLM 很可能误判成"影响巨大",让你白跑一大堆;反过来,一次改动很小但动了关键逻辑,它也可能因为 diff 看着平淡而漏掉。 (Evaluating LLM-Based Test Generation Under Software Evolution, arXiv:2603.23443)
同领域还有更刺耳的数字:在精心构造的代码补全幻觉基准上,连最强的"裁判模型",正确接受对的补全 + 正确拒绝幻觉补全的双正确率也只有 56%--90%,而"import 了一个不存在的模块"这类幻觉能骗倒超过 40% 的判断(Delulu 基准, arXiv:2605.07024)。模型会把"看起来合理"误当成"真的相关"。
所以 LLM 在圈回归这件事上的正确定位是"补充",不是"判官":
- 它擅长把 diff 翻译成人话、提示业务上可能波及的场景、给新人解释为什么要跑这些;
- 但 "某批用例这次可以跳过"这个决定,不能交给一个会被重构文本带偏、会一本正经编造关联的概率模型单独拍板。 跳过的权力必须握在确定性分析(1.1)和安全网(第三章)手里。
1.4 三者怎么配合:一句话记住
| 路线 | 回答的问题 | 强项 | 盲区 | 数据成本 |
|---|---|---|---|---|
| 依赖追踪(静态/动态) | 改动结构上碰到谁 | 确定、可解释、零冷启动(静态) | 抓不到跨层间接影响、配置/SQL、未覆盖代码 | 动态需先全量埋点 |
| 历史失败预测(ML) | 哪些用例这次最可能失败 | 大厂验证、能砍掉一大半、会自我进化 | 要历史数据、怕 flaky、猜不到全新失效路径 | 需数月跑批数据 |
| LLM 语义理解 | 这次业务上可能影响啥 | 零冷启动、懂跨模块业务语义、会解释 | 被表面改动带偏、会编关联、不可独自决定跳过 | 几乎为零 |
成熟做法是漏斗叠加,而不是三选一 :先用确定性依赖追踪圈"结构上必跑"的;再用历史失败模型把"虽然没直接碰到、但历史上这种改动容易炸"的加进来;最后让 LLM 补充"业务语义上建议关注"的场景,同时任何一条说"跳过"都被安全网兜底。LLM 只能让集合变大(宁可多跑),不能让集合变小(无权砍掉)。
二、主流工具选型地图(按你的栈对号入座)
先按"你的测试能不能直接看到被测代码"分两大类,这决定了你能用哪类工具:
- 白盒/代码同仓 (单元、接口、集成测试,测试和产品代码在一个仓库、同一种语言):依赖追踪最好做,工具最成熟,优先用免费的依赖追踪工具,规模大了再上 ML 商业产品。
- 黑盒/跨进程/跨系统的端到端 (Selenium/Playwright/Cypress 点界面、打 HTTP,后端可能是另一个仓、另一种语言的微服务):CI 里照样拿得到本次合入的 diff ,所以能做"改动文件 → 业务模块标签"的粗粒度映射 、让 LLM 读 diff 推断影响域(第四章自研脚本的第一路输入就是它);但 testmon/coverage 那种精确到行的执行依赖追踪做不到 ------测试和被测服务不在一个进程,没法把"点下单按钮的用例"和"支付服务第 120 行"精确连起来。因此 E2E 主要靠 diff 粗映射 + 历史失败预测 + 业务标签 三路叠加,要么上商业平台,要么走第四章方案。(例外:被测的是前端代码时,Cypress/Playwright 配合插桩能拿到 E2E 覆盖了哪些前端组件/行,前端这部分不黑,见下表。)
| 你的栈 / 场景 | 免费/自建首选(都是现成工具或命令) | 商业 ML | 依据 |
|---|---|---|---|
| Python + pytest(单元/接口) | pytest-testmon (pytest --testmon,底层吃 coverage.py) |
Launchable | 动态行级依赖 |
| JS/TS 单元(Jest) | Jest 自带 :jest --findRelatedTests <改动文件> 或 jest --changedSince main(Vitest 用 --changed) |
Launchable | 静态模块依赖图 |
| JS/TS 多包仓(monorepo) | Nx :nx affected -t test;用 Bazel/Turborepo 的同理走 affected |
Launchable | 项目级依赖图 |
| Java/JVM、Go | 没有 testmon 级的通用免费成品:要么靠构建系统(Bazel/Gradle)反查受影响模块,要么用 JaCoCo/cover 数据自研,规模大直接上商业 ML | Launchable | 构建依赖 / 自研 |
| Cypress/Playwright(有前端代码) | 用 istanbul/v8 插桩拿到 E2E 覆盖了哪些前端文件,再自己做"改动文件→命中用例"映射(无一键工具) | Mabl/Testim 内置影响分析 | 前端覆盖+映射 |
| 纯黑盒 Selenium/Appium E2E | 第四章自研(diff 粗映射+历史失败+业务标签) | Launchable/Testim/Mabl | 历史预测为主 |
| 用例数 < 200、全量 < 5 分钟 | 不需要这类工具,直接全量 | ------ | 别过度工程 |
一个容易混的点:coverage.py / JaCoCo / istanbul 只产出覆盖报告(哪行被跑过),不负责选测试 ;真正做选择的,是 testmon 这种"吃 coverage 数据反查"的工具、Jest/Nx 这种"自己建依赖图"的框架能力,或 Launchable 这种"再叠加历史失败模型"的商业产品。另外:这套东西是给"全量已经成为瓶颈"的团队用的。如果你的全套回归 5 分钟内跑得完,圈选省下的时间还抵不上你折腾工具和承担漏测风险的成本------先把用例并行化、把慢用例优化掉,比上圈选更值。
下面几个工具,每个一句话告诉你怎么上手。
2.1 Python + pytest:先用免费的 pytest-testmon
⚡ 一句话上手:
pip install pytest-testmon,先完整跑一次pytest --testmon建立依赖库(生成.testmondata);之后改完代码再跑pytest --testmon,它只执行受改动影响、以及上次失败的测试,无需连任何云端、不上传代码。
它底层用 Coverage.py 记录"每个测试实际执行了源码哪些行",改完代码比对依赖库,只跑受影响的,并且默认总会重跑上次失败的测试 (testmon 官网)。本地、免费、数据不出机器,Python 团队的第一选择。
几个必须知道的边界:
- 第一次必须全量,没建过依赖库它没法选;
- 它基于"实际执行到的代码",所以没被任何测试覆盖的代码改了,它感知不到;配置、数据、环境变量这类非代码改动也一样;
- 它是代码层工具,对纯黑盒 UI 测试意义不大;
- 许可证是 AGPL,要嵌进你们自己的商业产品分发时先看一眼协议;
- 升级大版本后建议删掉旧的
.testmondata重建。
一句话:testmon 适合在开发者本地和 PR 上快速缩小代码级测试范围,但它不能当发版前的唯一防线------原因见第三章的安全网。
2.2 多语言 / 大仓 / 想要 ML:Launchable
⚡ 一句话上手: 按官方文档安装 Launchable CLI(支持 pip / brew 等)、
launchable verify完成认证后,每次 CI 里三步走------launchable record build登记构建、把"完整测试清单"通过管道喂给launchable subset --build <名> --target <百分比> <runner>拿到子集清单,让测试框架只跑这个子集,跑完再用launchable record tests把 JUnit 报告回传;攒几周数据后模型开始生效。
Launchable 是这方向最具代表性的商业产品,核心就是 Meta 论文那套历史失败预测:吃你历次 CI 的测试结果和本次改动,给测试按失败概率排序、圈子集,还能顺带做测试并行分片(Launchable 官方文档)。它语言和框架覆盖广(pytest、JUnit、Cypress、Playwright 等大多通过 JUnit 报告接入),还支持"零输入子集"(Zero Input Subsetting),对黑盒测试更友好。
代价和边界要清楚:
- SaaS,要把测试结果(部分场景含改动信息)上传到它的云端,代码本身一般不传,但报告里可能有路径/用例名,合规敏感的团队先评估;
- 冷启动期------数据不够时它就是个按历史耗时排序/并行工具,积累数周到数月才有预测力;
- 付费,按使用量计费,小团队算清楚 ROI。
2.3 商业低代码平台:自带影响分析
Mabl、Testim(Tricentis)、Functionize 这一类(第四篇讲自愈时提过)大多把"测试影响分析 / 智能执行"作为内置能力:平台天然握有每次跑批数据和用例与页面的映射,改动后自动推荐/优先执行相关用例。
⚡ 一句话上手: 用这类平台录制/托管用例后,在它的"Test Impact / Insights / 智能执行"设置里开启相关选项,CI 或平台跑批时即自动按风险排序,不用自己搭。
适合已经买了或打算买商业平台、以黑盒 E2E 为主的团队;不建议为了"圈回归"单独去买------第四篇算过这笔账,这类平台多是企业定价。
2.4 黑盒 UI、又不想花钱:第四章的自研方案
如果你的主力是 Selenium/Playwright 端到端、代码依赖用不上、也不想把数据传 SaaS,现实里最划算的是一个轻量、可解释、零模型依赖的圈选器:不靠神奇 AI,靠三条朴素规则的并集------
- 本次 diff 命中了用例自己声明的"业务模块/页面"标签;
- 该用例最近 N 次跑批失败过(失败的优先重跑);
- LLM 读 diff 建议补充关注的场景(只做加法、给出理由、人能看);
再叠加第三章的安全网。第四章给完整可跑脚本,Python + pytest 演示,思路换成任何语言都成立。
2.5 选型就问三件事
- 我的测试看得到被测代码吗? 看得到 → 依赖追踪(testmon 类)性价比最高;纯黑盒 → 历史预测/业务标签,别指望代码依赖。
- 我有几个月的历史跑批数据、愿意上云吗? 都满足且规模够大 → Launchable 类 ML;没有 → 先依赖追踪 + 第四章自研,边用边攒数据。
- 省下来的时间,值不值得我承担"漏跑"的风险? 这就是下一章的主题------工具决定"跑哪些更快",但敢不敢跳过、怎么保证跳了不出事,靠的是一套与工具无关的规矩。
三、不管用什么工具,敢"跳过"的三条规矩
3.1 划一张"绝不跳过"的清单
下面这些每次都跑,任何模型、任何优化目标都无权跳过,一句话:AI 只能让集合变大(多跑),"跳过"必须由这张确定性红线把关:
- 冒烟核心链路:登录、首页可达、下单、支付、权限校验,挂了就是 P0;
- 本次改动所在的测试 、新增/变更的测试:后者还没有历史数据可供判断;
- 最近失败过和 flaky 的用例(见第二篇);
- 命中高风险区的改动:公共框架/工具库、认证权限、支付核心、依赖清单、CI、数据库迁移------影响是全局、间接的,静态分析算不准,宁可不优化也不漏。
3.2 PR 圈子集快反馈,夜间/发版全量兜底
任何圈选系统都不能成为合并前唯一一道测试关,标准是两层:
- PR / 增量(求快):每个提交只跑圈中的子集,几分钟反馈,允许"可能漏";
- 夜间 / 发版前全量(求全) :不阻塞开发者的时段跑完整测试集,把白天漏掉的抓回来。Meta 论文称这层为"生产安全网"------被跳过的测试不是不跑,只是推迟到全量再跑。
配套两个习惯:先灰度 ------头几周只在 PR 贴"建议跑这 X 个",CI 仍跑全量,对比推荐与真实失败后再让它接管跳过;新代码默认多跑,没历史的用例冷却期内一律纳入。
3.3 只盯逃逸率,超标就回退
怎么知道系统有没有偷偷漏?看逃逸率:
逃逸率=全量失败的用例总数全量失败、但 PR 子集没跑的用例数
运营三点:
- 目标逃逸率持续 < 5%(核心系统更严,按故障成本定);
- 按周看趋势:一旦抬头、或漏到生产出 P0,立即回退(关掉跳过 / 调保守目标 / 补标签和历史),查清再放开;
- 持续治 flaky,否则它既浪费圈选名额又污染历史模型。
四、轻量自研:一个看得懂、可审计的圈选器(Python 演示)
如果你的主力是黑盒 E2E、又不想上传数据到 SaaS,下面给一个零模型也能独立工作 的圈选器,一个文件、只用 Python 标准库(含详细注释约 400 行,核心判断并不多)。它把第三章的三条规矩直接固化进逻辑里。用 Python + pytest 演示,但它读的是 git diff、文件标签和 JUnit 报告,思路平移到任何语言都成立。
4.1 先看它怎么圈:三路取并集,LLM 只能做加法
先说清楚落地形态:整套自研只需要你新增一个脚本,不用装任何第三方库。 脚本固定放在测试工程的根目录 下的 scripts/(即scripts/select_regression.py),所有命令也都在这个根目录敲,脚本默认在此处找 tests/、读 git、读写历史文件。selected.txt、selected_audit.json、regression_history.json 不用你建,第一次跑会自动生成。
这里要分两种仓库形态(黑盒团队尤其注意第二种):
- 形态 A · 测试与业务同仓 (Python 后端、单体项目常见,
tests/就在业务仓里):脚本放业务仓根目录,它直接在本仓git diff就能拿到业务改动,开箱即用。 - 形态 B · 测试是独立工程 (UI 自动化单独一个仓,业务代码在另一个仓,Selenium/Playwright 团队常见):脚本放测试工程根目录 ,但它在测试仓里跑
git diff只能看到测试脚本自己的改动,看不到业务仓改了啥 。这时必须让 CI 在业务仓 先产出改动清单(git diff --name-only origin/main...HEAD > changed.txt),再把清单传给脚本:select --changed-from changed.txt(或设环境变量CHANGED_FILES)。脚本里"路径→标签"的映射按业务仓路径写。脚本已内置这两种模式,且清单文件一旦缺失会直接保守全量。
整体就四步,后面几小节逐一展开:
- 放脚本 + 改配置 (4.2):把脚本放进
scripts/,按你们项目改文件顶部四块------冒烟必跑清单、高风险路径正则、"路径→业务标签"映射表、标签白名单。 - 给用例打标签 (4.3):在测试文件顶部写
# reg-tags: order, payment。 - 跑起来圈子集 (4.3):根目录执行
python scripts/select_regression.py select --base origin/main(独立测试仓加--changed-from changed.txt)生成selected.txt,再pytest $(cat selected.txt)只跑圈中的;CI 里每次跑完追加一步record report.xml积累历史。 - 先只推荐不跳过、夜间全量兜底、按逃逸率决定何时真接管(4.4 + 第三章)。
它内部的判断逻辑是这样:
scss
本次 git 改动
│
├─ 命中高风险区(公共框架/认证/支付核心/CI/迁移)?──是──▶ 全量
│
├─ 改动路径 ──映射──▶ 业务模块标签 ──┐
├─ 最近5次跑批失败过的用例 ──────────┼── 并集 ──▶ 子集
├─ 冒烟命门(ALWAYS_RUN)────────────┤ (写审计,可追溯)
└─ LLM 读 diff 建议的标签(白名单过滤)┘
│
└─ 取完是空集 / 取不到 diff?──是──▶ 全量兜底
几个刻意的设计:
- 标签是人维护的业务语义,不依赖被测代码覆盖率 ------黑盒 E2E 也能用。用例文件顶部写一行
# reg-tags: order, payment,再维护一张"源码路径 → 业务标签"的粗映射表。 - LLM 的输出过白名单 :模型只能从已知业务标签里"多选几个",返回任何列表外的标签直接丢弃。这样即使它幻觉,也既不能凭空造用例、更不能跳过用例,顶多让你多跑几个模块。
- 任何不确定都 fail-safe 成全量:取不到 git diff、一个都没圈出来、LLM 超时------全部退回全量。慢一点,绝不裸奔。
- 每次圈选都写
selected_audit.json:哪些用例因为标签命中、哪些因为历史失败、哪些是冒烟必跑,一目了然。这就是它的"凭什么"。
4.2 完整脚本 scripts/select_regression.py
下面这份代码在一个含 7 个用例的最小 git 仓库里实测过:同仓场景覆盖改单模块出子集、历史失败精确拉入、公共/高风险改动与无基准 ref 两种全量兜底、逃逸率计算;独立测试仓场景覆盖
--changed-from外部清单圈选、清单缺失回退全量。可直接照抄,只改文件顶部那几个配置。
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
select_regression.py ------ 轻量回归范围圈选器(黑盒 E2E 友好,不依赖被测代码覆盖率)
思路(三路取并集,LLM 只能做加法,永远不能让集合变小):
1) 标签命中 :用例声明覆盖的业务模块/页面标签;本次改动的文件映射到模块标签,命中则圈入。
2) 历史失败 :最近 N 次跑批里失败过的用例,无理由重跑(flaky/反复出问题的优先)。
3) LLM 补盲 :可选。把 diff 摘要发给模型,让它"建议追加"关注的模块标签;
模型返回的标签必须落在【已知标签白名单】内才算数(防编造),只做并集。
硬护栏(任何来源都无权跳过):
- ALWAYS_RUN:冒烟核心链路(登录/下单/支付/权限),每次必跑。
- 命中"高风险改动"(公共框架、依赖、CI、权限、支付核心、没有可解析的 diff)→ 直接全量。
- 圈不出任何用例(如首次运行无映射无历史)→ 全量,宁可慢不可裸奔。
输出:选中的 pytest node id 写入文件(默认 selected.txt,一行一个),供:
pytest $(cat selected.txt)
同时把"为什么圈这些"写入审计文件 selected_audit.json。
只依赖标准库;LLM 补盲走 OpenAI 兼容接口,缺 key / 失败时自动降级,不影响主流程。
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import re
import subprocess
import sys
# ---------- 1. 可配置的护栏(按你们项目改) ----------
# 无论如何每次都跑的 node id 前缀(冒烟核心链路,只放真正的命门,别贪多,
# 否则这个集合越堆越大,圈选就退化成全量了)
ALWAYS_RUN_PREFIXES = [
"tests/test_smoke.py", # 登录、首页可达
"tests/test_checkout.py::test_submit_order", # 下单主链路
]
# 改动路径里只要命中这些关键词(正则),一律全量------它们的影响面机器算不准
HIGH_RISK_PATTERNS = [
r"(^|/)conftest\.py$", r"(^|/)pytest\.ini$", r"(^|/)requirements.*\.txt$",
r"(^|/)pyproject\.toml$", r"(^|/)\.github/", r"(^|/)ci/",
r"(^|/)common/", r"(^|/)framework/", r"(^|/)core/", r"(^|/)utils?/",
r"(^|/)(auth|login|permission)/", # 认证/权限模块
r"services/(payment|billing)/core", r"(^|/)pay/core", # 支付核心内核
r"migration", r"schema",
]
# 源码改动路径 → 业务模块标签 的粗粒度映射(正则 → 标签)。
# 黑盒项目里这张表通常由架构/测试负责人维护,几十条就够覆盖主干。
# 每条都要锚定路径边界(目录起始 ^|/ 或完整目录名),否则裸词会误命中
# 文件名子串------比如裸写 account 会命中 services/order/account_log.py,改 order 误圈 user。
PATH_TAG_RULES = [
(r"(?:^|/)services/order/|(?:^|/)order/", "order"),
(r"(?:^|/)services/(?:pay|payment|billing)/|(?:^|/)payment/|(?:^|/)pay/", "payment"),
(r"(?:^|/)services/user/|(?:^|/)user/|(?:^|/)account/", "user"),
(r"(?:^|/)services/cart/|(?:^|/)cart/", "cart"),
(r"(?:^|/)services/coupon/|(?:^|/)coupon/|(?:^|/)promotion/", "coupon"),
(r"(?:^|/)services/refund/|(?:^|/)refund/", "refund"),
(r"(?:^|/)ui/|(?:^|/)frontend/|(?:^|/)src/components/|(?:^|/)pages/", "ui"),
(r"(?:^|/)api/gateway|(?:^|/)gateway/", "gateway"),
]
HISTORY_FILE = "regression_history.json" # 历次跑批结果(由 record 子命令累积)
FAIL_LOOKBACK = 5 # 最近 N 次跑批内失败过 → 重跑
# 已知业务标签白名单:LLM 返回的标签必须在这里面,否则丢弃(防幻觉编造)
KNOWN_TAGS = {"order", "payment", "user", "cart", "coupon", "refund", "ui", "gateway"}
# ---------- 2. 收集仓库里全部用例的 node id 与标签 ----------
_TAG_RE = re.compile(r"#\s*reg-tags:\s*(.+)")
def _junit_node_id(classname: str, name: str):
"""把 JUnit 的 (classname, name) 归一化成 pytest node id。
pytest 生成的 classname 通常已是包路径(如 tests.test_cart),
若再拼一次 tests/ 就会变成 tests/tests/test_cart.py,和 collect 出来的
node id 对不上,导致"历史失败拉回"整条失效。这里做幂等归一。
"""
if not classname:
return name
path = re.sub(r"^(tests/)+", "tests/", classname.replace(".", "/"))
if not path.endswith(".py"):
path += ".py"
if not path.startswith("tests/"):
path = "tests/" + path
return f"{path}::{name}"
def collect_tests(root: str = "tests"):
"""返回 [(node_id, set(标签))]。标签从用例文件里的 `# reg-tags: a, b` 读取。
标签支持两种位置:
- 文件顶部的 reg-tags:对整个文件生效;
- 函数 docstring 上方的 reg-tags(这里简化为同一文件内最近的标记,
生产实现可用 AST 精确定位到函数,见文末说明)。
本演示按"文件级标签"实现,简单、够用、零依赖。
"""
items = []
for path in sorted(glob.glob(os.path.join(root, "**", "test_*.py"), recursive=True)):
tags = set()
node_ids = []
with open(path, encoding="utf-8") as f:
for line in f:
m = _TAG_RE.search(line)
if m:
tags |= {t.strip() for t in m.group(1).split(",") if t.strip()}
# 用 pytest --collect-only 拿到精确 node id;拿不到就退化为文件路径
node_ids = _pytest_node_ids(path)
for nid in node_ids:
items.append((nid, tags))
return items
def _pytest_node_ids(path: str):
try:
out = subprocess.run(
[sys.executable, "-m", "pytest", path, "--collect-only", "-q"],
capture_output=True, text=True, timeout=60,
)
ids = []
for line in out.stdout.splitlines():
line = line.strip()
if "::" in line and not line.startswith(("::", "coffin")):
ids.append(line.split("[")[0] if line.endswith("]") else line)
if ids:
return sorted(set(ids))
except Exception:
pass
return [path.replace("\\", "/")]
# ---------- 3. 本次改动 ----------
def changed_files(base_ref: str, changed_from: str | None = None):
"""拿到本次改动的文件列表(路径按业务仓口径)。
两种部署形态:
- 同仓(测试与业务代码在一个 git 仓):不传 changed_from,直接在本仓 git diff。
- 独立测试仓(黑盒 E2E,业务代码在另一个仓):CI 在业务仓生成改动清单文件,
通过 --changed-from 或环境变量 CHANGED_FILES 传进来(一行一个路径);
这是唯一能让独立测试仓感知业务改动的方式,本仓 git diff 只能看到测试脚本。
取不到(无 git / 无基准 / 清单文件缺失)一律返回 None,由上层保守全量。
"""
changed_from = changed_from or os.environ.get("CHANGED_FILES")
if changed_from:
if not os.path.exists(changed_from):
return None
return [l.strip() for l in open(changed_from, encoding="utf-8") if l.strip()]
try:
out = subprocess.run(
["git", "diff", "--name-only", f"{base_ref}...HEAD"],
capture_output=True, text=True, timeout=30,
)
if out.returncode != 0:
return None
files = [l.strip() for l in out.stdout.splitlines() if l.strip()]
return files
except Exception:
return None
def is_high_risk(files):
return any(any(re.search(p, f) for p in HIGH_RISK_PATTERNS) for f in files)
def tags_from_files(files):
tags = set()
for f in files:
for pat, tag in PATH_TAG_RULES:
if re.search(pat, f):
tags.add(tag)
return tags
# ---------- 4. 历史失败 ----------
def recently_failed(history_path: str, lookback: int, all_ids):
"""最近 lookback 次跑批里失败过的 node id(按文件前缀兜底匹配)。"""
if not os.path.exists(history_path):
return set()
try:
runs = json.load(open(history_path, encoding="utf-8"))
except Exception:
return set()
failed = set()
for run in runs[-lookback:]:
failed |= set(run.get("failed", []))
# 精确 node id 命中优先;只有历史记录本身是【文件级】(不含 ::)时,
# 才把该文件下所有用例都算失败。方向不能反:若对函数级失败 id 也做
# startswith 文件前缀兜底,会把同文件其它没失败的用例一起误判为失败。
failed_exact = {f for f in failed if "::" in f}
failed_files = {f for f in failed if "::" not in f}
selected = set()
for nid in all_ids:
if nid in failed_exact:
selected.add(nid)
elif nid.split("::")[0] in failed_files:
selected.add(nid)
return selected
# ---------- 5. LLM 补盲(OpenAI 兼容;失败自动降级,绝不影响主流程) ----------
def llm_suggest_tags(files, diff_stat: str):
"""让模型基于改动【建议追加关注的模块标签】。
关键约束:只允许它返回白名单内标签;任何额外文本、新标签都丢弃。
这样即使模型编造,也无法制造或跳过用例------它最多从已知模块里多选几个。
"""
api_key = os.environ.get("LLM_API_KEY")
base_url = os.environ.get("LLM_BASE_URL", "https://api.deepseek.com")
model = os.environ.get("LLM_MODEL", "deepseek-chat")
if not api_key:
return set(), "未配置 LLM_API_KEY,跳过 LLM 补盲"
flist = "\n".join(files[:80])
prompt = (
"下面是本次代码改动涉及的文件列表和统计。请判断回归测试应额外关注哪些业务模块。\n"
f"只允许从这些标签里选择(逗号分隔):{', '.join(sorted(KNOWN_TAGS))}。\n"
"宁可多选,不可编造列表外标签。只输出标签,不要解释。\n\n"
f"改动文件:\n{flist}\n\n变更统计:\n{diff_stat}"
)
try:
import urllib.request
req = urllib.request.Request(
base_url.rstrip("/") + "/chat/completions",
data=json.dumps({
"model": model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0,
}).encode("utf-8"),
headers={"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=30) as resp:
content = json.loads(resp.read())["choices"][0]["message"]["content"]
tags = {t.strip().lower() for t in re.split(r"[,,]", content) if t.strip()}
valid = tags & KNOWN_TAGS # 白名单过滤:编造的标签直接丢弃
dropped = tags - KNOWN_TAGS
return valid, (f"LLM 建议标签 {sorted(valid)}" +
(f";丢弃白名单外标签 {sorted(dropped)}" if dropped else ""))
except Exception as e:
return set(), f"LLM 补盲失败,已降级(不影响圈选):{type(e).__name__}"
# ---------- 6. 主流程 ----------
def select(base_ref: str, out_path: str, audit_path: str, use_llm: bool,
changed_from: str | None = None):
tests = collect_tests("tests")
all_ids = [nid for nid, _ in tests]
audit = {"total_collected": len(all_ids), "reasons": {}}
def full(reason):
audit["mode"] = "FULL"; audit["reason"] = reason
_write(all_ids, out_path, audit, audit_path)
print(f"[FULL] {reason} → 全量 {len(all_ids)} 个用例")
return
files = changed_files(base_ref, changed_from)
if files is None:
full("取不到改动清单(无基准 ref / 非 git 环境 / --changed-from 文件缺失),保守全量"); return
audit["changed_files"] = files
if not files:
full("本次相对基准没有改动"); return
if is_high_risk(files):
hit = sorted({f for f in files
for p in HIGH_RISK_PATTERNS if re.search(p, f)})
full(f"命中高风险改动 {hit[:5]},影响面无法静态算准"); return
changed_tags = tags_from_files(files)
audit["tags_from_diff"] = sorted(changed_tags)
failed = recently_failed(HISTORY_FILE, FAIL_LOOKBACK, all_ids)
audit["recently_failed"] = sorted(failed)
llm_tags, llm_note = set(), ""
if use_llm:
llm_tags, llm_note = llm_suggest_tags(files, "")
audit["llm"] = llm_note
changed_tags |= llm_tags # LLM 只做并集(加法)
selected, why = set(), {}
for nid, tags in tests:
rs = []
if changed_tags & tags:
rs.append(f"标签命中 {sorted(changed_tags & tags)}")
if nid in failed:
rs.append("最近失败过")
if any(nid.startswith(p) for p in ALWAYS_RUN_PREFIXES):
rs.append("冒烟必跑")
if rs:
selected.add(nid); why[nid] = rs
if not selected:
full("按规则一个都没圈出来(可能是首次运行/标签缺失),保守全量"); return
selected = sorted(selected)
audit["mode"] = "SUBSET"
audit["selected"] = len(selected)
audit["ratio"] = f"{len(selected)}/{len(all_ids)}"
_write(selected, out_path, audit, audit_path)
print(f"[SUBSET] 圈选 {len(selected)}/{len(all_ids)} 个用例 "
f"({len(selected)*100//max(len(all_ids),1)}%)→ {out_path}")
if llm_note:
print(" " + llm_note)
def _write(ids, out_path, audit, audit_path):
with open(out_path, "w", encoding="utf-8") as f:
f.write("\n".join(ids) + ("\n" if ids else ""))
with open(audit_path, "w", encoding="utf-8") as f:
json.dump(audit, f, ensure_ascii=False, indent=2)
# ---------- 7. 历史记录 & 逃逸率(供 CI 调用) ----------
def record(junit_path: str):
"""从 pytest JUnit XML 解析本轮结果,追加进历史文件。"""
import xml.etree.ElementTree as ET
runs = []
if os.path.exists(HISTORY_FILE):
try:
runs = json.load(open(HISTORY_FILE, encoding="utf-8"))
except Exception:
runs = []
failed, total = [], 0
tree = ET.parse(junit_path)
for tc in tree.iter("testcase"):
nid = _junit_node_id(tc.get("classname") or "", tc.get("name"))
total += 1
if tc.find("failure") is not None or tc.find("error") is not None:
failed.append(nid)
runs.append({"total": total, "failed": failed})
runs = runs[-50:]
json.dump(runs, open(HISTORY_FILE, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print(f"已记录本轮:{total} 个用例,{len(failed)} 个失败 → {HISTORY_FILE}")
def escape_rate(night_report: str):
"""逃逸率:夜间/发版全量里失败、但白天 PR 子集没圈到的比例。
这是这套机制唯一关键的北极星指标,详见正文第三章。
"""
import xml.etree.ElementTree as ET
selected = set(l.strip() for l in open("selected.txt", encoding="utf-8") if l.strip())
tree = ET.parse(night_report)
leaked, caught = [], []
for tc in tree.iter("testcase"):
if tc.find("failure") is None and tc.find("error") is None:
continue
nid = _junit_node_id(tc.get("classname") or "", tc.get("name"))
(caught if any(nid.startswith(s) or s.startswith(nid.split('::')[0])
for s in selected) else leaked).append(nid)
total_fail = len(leaked) + len(caught)
rate = len(leaked) / total_fail if total_fail else 0.0
print(f"夜间全量失败 {total_fail} 个:子集已覆盖 {len(caught)},逃逸 {len(leaked)}")
print(f"逃逸率 = {rate:.1%} (目标:持续 < 5%,任何一周抬头就回退全量查原因)")
def main():
ap = argparse.ArgumentParser(description="轻量回归范围圈选器")
sub = ap.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("select", help="圈定子集")
s.add_argument("--base", default="origin/main")
s.add_argument("--out", default="selected.txt")
s.add_argument("--audit", default="selected_audit.json")
s.add_argument("--no-llm", action="store_true")
s.add_argument("--changed-from", default=None,
help="独立测试仓用:业务仓改动清单文件路径(一行一个);"
"也可用环境变量 CHANGED_FILES。不传则在本仓 git diff。")
r = sub.add_parser("record", help="记录本轮 JUnit 结果到历史")
r.add_argument("junit")
e = sub.add_parser("escape", help="根据夜间全量报告算逃逸率")
e.add_argument("night_report")
args = ap.parse_args()
if args.cmd == "select":
select(args.base, args.out, args.audit, use_llm=not args.no_llm,
changed_from=args.changed_from)
elif args.cmd == "record":
record(args.junit)
elif args.cmd == "escape":
escape_rate(args.night_report)
if __name__ == "__main__":
main()
4.3 给用例打标签、接进 CI
给每个测试文件顶部加一行业务标签:
python
# tests/test_checkout.py
# reg-tags: order, payment
def test_submit_order(): ...
CI 里串起来(PR 阶段圈子集、记录结果;夜间全量后算逃逸率):
yaml
# 形态A·同仓:PR 增量阶段直接本仓 diff
- name: 圈定回归范围
run: python scripts/select_regression.py select --base origin/${{ github.base_ref }}
- name: 只跑圈中的子集
run: pytest --junitxml=report.xml $(cat selected.txt) || true # 见下注
- name: 记录本轮结果供后续预测
if: always()
run: python scripts/select_regression.py record report.xml
# 形态B·独立测试仓:先用业务仓的改动清单,再圈选
# - run: git diff --name-only origin/${{ github.base_ref }}...HEAD > changed.txt # 在业务仓 job 里产出
# - run: python scripts/select_regression.py select --base origin/main --changed-from changed.txt
# 夜间全量 job(另起,不阻塞 PR)
- run: pytest --junitxml=night.xml
- run: python scripts/select_regression.py escape night.xml
注:示例里
|| true只是为了保证后面的record一定执行;正式写法应在 pytest 失败时也走完 record、再由最后一步透传失败退出码,别让红用例被吞。record解析时<failure>和<error>都算失败------这点和第三篇是一致的,fixture 报错不能漏。
4.4 本地先用一段时间,再让它真接管
第一阶段只让它推荐不跳过 :CI 照常全量,额外上传 selected_audit.json,人工对比几周"它圈的"和"实际失败的"重合度。等你对逃逸率有了底,再把 pytest 的入参从全量换成 $(cat selected.txt)。脚本里的映射表和高风险清单,也在这个阶段按真实逃逸案例逐步补全。
五、跑顺之后:放权多少,用指标说话
你可以把"敢跳过多少"也做成一个随数据收紧或放开的闸门:
| 阶段 | 圈选器的角色 | 跳过权限 | 前置条件 |
|---|---|---|---|
| L1 只看只说 | 在 PR 贴"建议跑这些",CI 仍全量 | 无(0% 跳过) | 圈选器跑起来、审计可查 |
| L2 有界跳过 | PR 只跑子集,夜间全量兜底;高风险改动强制全量 | 跳过非红线用例 | 灰度观察数周、逃逸率稳定达标 |
| L3 更激进优化 | 引入 ML(Launchable 类)按失败概率动态裁剪 | 动态比例 | 数月干净历史、逃逸率长期 < 阈值、有自动回退 |
注意 L3 的"激进"依然带着镣铐:红线清单不跳过、全量兜底不取消、逃逸率一旦抬头自动退回 L2。放权程度不是配置项拍脑袋定的,是被逃逸率这个客观指标顶上去的,也能被它一键拉回来。
日常只需要盯三个数:
- 逃逸率(北极星):全量失败中子集漏掉的比例,长期 < 5%,周维度看趋势。
- PR 反馈时长 / 跳过比例:这套东西到底省了多少时间,值不值。
- 全量兜底的真实拦截数:如果夜间全量连续很多周一个漏网的都抓不到,说明你的圈选偏保守、还能再放开一点;反之频繁抓漏,就该收。
写在最后:执行段五篇,拼成一条完整的链
回看执行自动化这条线,都在给同一个问题------怎么把"机器跑得快"和"结果靠得住"同时拿到------逐层上保险:
- (一)AI 写脚本:解决从零到一,让 AI 在你选定的框架里把用例写出来;
- (二)红灯分诊:一片红时,让 AI 先把"产品 bug / 脚本问题 / 环境 flaky"分清楚,人只处理真问题;
- (三)接进 CI:三级放权,让分诊嵌进流水线,红灯一亮自动动作,但可逆性决定权限;
- (四)脚本自愈:元素改版后让 AI 重定位,可它说修好了你不能直接信------a red test stays red,a green test must earn its green;
- (五)圈定回归范围(本篇):决定每次跑多少,AI 说"这次不用跑",靠红线、全量兜底和逃逸率来决定你敢不敢。
一句话总结:让 AI 做加法、做加速、做它擅长的重复判断;把减法、把"拍板放行"的权力,留在确定性规则和人手里。