AI for Testing 提效实战 · 执行自动化(五):这次该跑哪些用例,让 AI 圈回归范围

前面四篇解决的是"怎么把测试跑好":让 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,靠三条朴素规则的并集------

  1. 本次 diff 命中了用例自己声明的"业务模块/页面"标签;
  2. 该用例最近 N 次跑批失败过(失败的优先重跑);
  3. LLM 读 diff 建议补充关注的场景(只做加法、给出理由、人能看);

再叠加第三章的安全网。第四章给完整可跑脚本,Python + pytest 演示,思路换成任何语言都成立。

2.5 选型就问三件事

  1. 我的测试看得到被测代码吗? 看得到 → 依赖追踪(testmon 类)性价比最高;纯黑盒 → 历史预测/业务标签,别指望代码依赖。
  2. 我有几个月的历史跑批数据、愿意上云吗? 都满足且规模够大 → Launchable 类 ML;没有 → 先依赖追踪 + 第四章自研,边用边攒数据。
  3. 省下来的时间,值不值得我承担"漏跑"的风险? 这就是下一章的主题------工具决定"跑哪些更快",但敢不敢跳过、怎么保证跳了不出事,靠的是一套与工具无关的规矩。

三、不管用什么工具,敢"跳过"的三条规矩

3.1 划一张"绝不跳过"的清单

下面这些每次都跑,任何模型、任何优化目标都无权跳过,一句话:AI 只能让集合变大(多跑),"跳过"必须由这张确定性红线把关:

  • 冒烟核心链路:登录、首页可达、下单、支付、权限校验,挂了就是 P0;
  • 本次改动所在的测试 、新增/变更的测试:后者还没有历史数据可供判断;
  • 最近失败过和 flaky 的用例(见第二篇);
  • 命中高风险区的改动:公共框架/工具库、认证权限、支付核心、依赖清单、CI、数据库迁移------影响是全局、间接的,静态分析算不准,宁可不优化也不漏。

3.2 PR 圈子集快反馈,夜间/发版全量兜底

任何圈选系统都不能成为合并前唯一一道测试关,标准是两层:

  • PR / 增量(求快):每个提交只跑圈中的子集,几分钟反馈,允许"可能漏";
  • 夜间 / 发版前全量(求全) :不阻塞开发者的时段跑完整测试集,把白天漏掉的抓回来。Meta 论文称这层为"生产安全网"------被跳过的测试不是不跑,只是推迟到全量再跑。

配套两个习惯:先灰度 ------头几周只在 PR 贴"建议跑这 X 个",CI 仍跑全量,对比推荐与真实失败后再让它接管跳过;新代码默认多跑,没历史的用例冷却期内一律纳入。

3.3 只盯逃逸率,超标就回退

怎么知道系统有没有偷偷漏?看逃逸率:

逃逸率=全量失败、但 PR 子集没跑的用例数全量失败的用例总数 \text{逃逸率} = \frac{\text{全量失败、但 PR 子集没跑的用例数}}{\text{全量失败的用例总数}} 逃逸率=全量失败的用例总数全量失败、但 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)。脚本里"路径→标签"的映射按业务仓路径写。脚本已内置这两种模式,且清单文件一旦缺失会直接保守全量。

整体就四步,后面几小节逐一展开:

  1. 放脚本 + 改配置 (4.2):把脚本放进 scripts/,按你们项目改文件顶部四块------冒烟必跑清单、高风险路径正则、"路径→业务标签"映射表、标签白名单。
  2. 给用例打标签 (4.3):在测试文件顶部写 # reg-tags: order, payment。
  3. 跑起来圈子集 (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.4 + 第三章)。

它内部的判断逻辑是这样:

scss 复制代码
本次 git 改动
   │
   ├─ 命中高风险区(公共框架/认证/支付核心/CI/迁移)?──是──▶ 全量
   │
   ├─ 改动路径 ──映射──▶ 业务模块标签 ──┐
   ├─ 最近5次跑批失败过的用例 ──────────┼── 并集 ──▶ 子集
   ├─ 冒烟命门(ALWAYS_RUN)────────────┤        (写审计,可追溯)
   └─ LLM 读 diff 建议的标签(白名单过滤)┘
   │
   └─ 取完是空集 / 取不到 diff?──是──▶ 全量兜底

几个刻意的设计:

  1. 标签是人维护的业务语义,不依赖被测代码覆盖率 ------黑盒 E2E 也能用。用例文件顶部写一行 # reg-tags: order, payment,再维护一张"源码路径 → 业务标签"的粗映射表。
  2. LLM 的输出过白名单 :模型只能从已知业务标签里"多选几个",返回任何列表外的标签直接丢弃。这样即使它幻觉,也既不能凭空造用例、更不能跳过用例,顶多让你多跑几个模块。
  3. 任何不确定都 fail-safe 成全量:取不到 git diff、一个都没圈出来、LLM 超时------全部退回全量。慢一点,绝不裸奔。
  4. 每次圈选都写 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。放权程度不是配置项拍脑袋定的,是被逃逸率这个客观指标顶上去的,也能被它一键拉回来。

日常只需要盯三个数:

  1. 逃逸率(北极星):全量失败中子集漏掉的比例,长期 < 5%,周维度看趋势。
  2. PR 反馈时长 / 跳过比例:这套东西到底省了多少时间,值不值。
  3. 全量兜底的真实拦截数:如果夜间全量连续很多周一个漏网的都抓不到,说明你的圈选偏保守、还能再放开一点;反之频繁抓漏,就该收。

写在最后:执行段五篇,拼成一条完整的链

回看执行自动化这条线,都在给同一个问题------怎么把"机器跑得快"和"结果靠得住"同时拿到------逐层上保险:

  1. (一)AI 写脚本:解决从零到一,让 AI 在你选定的框架里把用例写出来;
  2. (二)红灯分诊:一片红时,让 AI 先把"产品 bug / 脚本问题 / 环境 flaky"分清楚,人只处理真问题;
  3. (三)接进 CI:三级放权,让分诊嵌进流水线,红灯一亮自动动作,但可逆性决定权限;
  4. (四)脚本自愈:元素改版后让 AI 重定位,可它说修好了你不能直接信------a red test stays red,a green test must earn its green;
  5. (五)圈定回归范围(本篇):决定每次跑多少,AI 说"这次不用跑",靠红线、全量兜底和逃逸率来决定你敢不敢。

一句话总结:让 AI 做加法、做加速、做它擅长的重复判断;把减法、把"拍板放行"的权力,留在确定性规则和人手里。

相关推荐
PingCAP2 小时前
从一句需求到一套可追溯机制:我们这样推进电子病历改造
人工智能·agent·工作流引擎
PingCAP2 小时前
平凯星辰刘松:稳定交付严肃任务正成为企业级智能体的重要考验
人工智能·agent·工作流引擎
米小虾3 小时前
模型即 Agent?那 Harness 还有用吗
人工智能·agent
米小虾3 小时前
Harness 五要素:从"能跑"到"可靠"
人工智能·agent
孟健3 小时前
LLM API 安全防线:从 OpenCode 盗刷事件到 LiteLLM 多模型容灾实操
人工智能·后端·架构
程序员cxuan3 小时前
我用 WorkBuddy + ima 把公众号文章做成了知识库。
人工智能·后端·程序员
Code_Artist3 小时前
AI 终于学会写正确的汉字了?GPT Image 2.0 到底做对了什么?
人工智能·chatgpt·openai
程序员cxuan4 小时前
玩了一天 REA,我号快没了
人工智能·后端·程序员