前面四篇解决的是"怎么把测试跑好":让 AI 在你的框架里写脚本(一)、CI 一片红时批量分诊(二)、把分诊接进流水线(三)、脚本坏了让它自愈(四)。
这一篇往上升一层:用例越攒越多,全量跑一遍要几十分钟甚至几小时,可这次提交明明只改了几行——到底该跑哪些?
老办法是两个极端,都很难受:
这两年开始有第三种答案:让机器基于"这次改了什么"和"历史上什么改过会炸",自动圈出一个最小但够用的回归子集。 业界叫它测试影响分析(Test Impact Analysis, TIA)或预测式测试选择(Predictive Test Selection)。这一篇讲清楚它到底怎么圈、能信几成、有哪些现成工具,它说这批用例可以不跑,你凭什么敢不跑。
别把"AI 圈回归"当成一个魔法。它内部就三条技术路线,思路不同、能抓什么不能抓什么也完全不同。选型和信任度,都建立在你分得清这三者的前提上。
最朴素、最确定性的思路:代码和测试之间存在调用/依赖关系,改了一个函数,就把所有"会执行到这个函数"的测试找出来跑。
实现上分两种:
calculator.add(),凡是 from calculator import add 的测试都圈进来。快、不需要先跑一遍,但偏保守——它看的是"导入了没有",不是"真的执行了没有",而且看不懂动态调用、字符串反射、配置文件、数据库迁移这些。pytest-testmon 就是这条路(底层用 Coverage.py)。这条路的本质是回答 "这次改动,结构上会碰到谁"。它对纯代码重构很准,但有一个致命的盲区:它只能找到"直接碰到改动代码"的测试,抓不到跨层的间接影响。 你改了一个公共工具函数的一个边界行为,依赖追踪能找到直接调它的测试;但某个下单流程因为这个边界行为变化而在特定数据下出错——如果那条 E2E 链路在这次跑的时候没真正走到这行,它就圈不到。
更别说根本没被任何测试覆盖到的代码、以及通过配置/SQL/接口契约产生的影响,依赖追踪天然看不见。
第二条路换了个问题:不问"会碰到谁",而问 "根据历史数据,这批用例里,哪些最可能在这次改动上失败"。
这是大厂真正大规模在用的方法。Meta(Facebook)把它做成了一个二分类模型:输入是"一次代码改动 + 一个测试",输出是这个测试在这次改动上失败的概率。特征来自三类数据——改动特征(动了哪些文件、加删多少行、改动频率)、测试历史特征(这个测试历史失败率、平均耗时、最近状态)、以及改动和测试之间的关系。模型用过去三个月的真实跑批数据训练(论文原文用的是梯度提升决策树 GBDT),超过概率阈值、或排名最靠前的一批测试被选中。
效果(论文原始数据):
另一组来自工业界落地的公开数据: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 倍。
这条路的本质是用概率换效率:它从不说"只有这些测试相关",只说"这些最可能炸,先跑这些"。它的命门也很清楚——
商业产品 Launchable 走的基本就是这条路线:吃你 CI 的历史测试报告 + 本次改动,用模型给测试排序、圈一个子集。
第三条路是 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 在圈回归这件事上的正确定位是"补充",不是"判官":
| 路线 | 回答的问题 | 强项 | 盲区 | 数据成本 |
|---|---|---|---|---|
| 依赖追踪(静态/动态) | 改动结构上碰到谁 | 确定、可解释、零冷启动(静态) | 抓不到跨层间接影响、配置/SQL、未覆盖代码 | 动态需先全量埋点 |
| 历史失败预测(ML) | 哪些用例这次最可能失败 | 大厂验证、能砍掉一大半、会自我进化 | 要历史数据、怕 flaky、猜不到全新失效路径 | 需数月跑批数据 |
| LLM 语义理解 | 这次业务上可能影响啥 | 零冷启动、懂跨模块业务语义、会解释 | 被表面改动带偏、会编关联、不可独自决定跳过 | 几乎为零 |
成熟做法是漏斗叠加,而不是三选一:先用确定性依赖追踪圈"结构上必跑"的;再用历史失败模型把"虽然没直接碰到、但历史上这种改动容易炸"的加进来;最后让 LLM 补充"业务语义上建议关注"的场景,同时任何一条说"跳过"都被安全网兜底。LLM 只能让集合变大(宁可多跑),不能让集合变小(无权砍掉)。
先按"你的测试能不能直接看到被测代码"分两大类,这决定了你能用哪类工具:
| 你的栈 / 场景 | 免费/自建首选(都是现成工具或命令) | 商业 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 分钟内跑得完,圈选省下的时间还抵不上你折腾工具和承担漏测风险的成本——先把用例并行化、把慢用例优化掉,比上圈选更值。
下面几个工具,每个一句话告诉你怎么上手。
⚡ 一句话上手: pip install pytest-testmon,先完整跑一次 pytest --testmon 建立依赖库(生成 .testmondata);之后改完代码再跑 pytest --testmon,它只执行受改动影响、以及上次失败的测试,无需连任何云端、不上传代码。
它底层用 Coverage.py 记录"每个测试实际执行了源码哪些行",改完代码比对依赖库,只跑受影响的,并且默认总会重跑上次失败的测试(testmon 官网)。本地、免费、数据不出机器,Python 团队的第一选择。
几个必须知道的边界:
.testmondata 重建。一句话:testmon 适合在开发者本地和 PR 上快速缩小代码级测试范围,但它不能当发版前的唯一防线——原因见第三章的安全网。
⚡ 一句话上手: 按官方文档安装 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),对黑盒测试更友好。
代价和边界要清楚:
Mabl、Testim(Tricentis)、Functionize 这一类(第四篇讲自愈时提过)大多把"测试影响分析 / 智能执行"作为内置能力:平台天然握有每次跑批数据和用例与页面的映射,改动后自动推荐/优先执行相关用例。
⚡ 一句话上手: 用这类平台录制/托管用例后,在它的"Test Impact / Insights / 智能执行"设置里开启相关选项,CI 或平台跑批时即自动按风险排序,不用自己搭。
适合已经买了或打算买商业平台、以黑盒 E2E 为主的团队;不建议为了"圈回归"单独去买——第四篇算过这笔账,这类平台多是企业定价。
如果你的主力是 Selenium/Playwright 端到端、代码依赖用不上、也不想把数据传 SaaS,现实里最划算的是一个轻量、可解释、零模型依赖的圈选器:不靠神奇 AI,靠三条朴素规则的并集——
再叠加第三章的安全网。第四章给完整可跑脚本,Python + pytest 演示,思路换成任何语言都成立。
下面这些每次都跑,任何模型、任何优化目标都无权跳过,一句话:AI 只能让集合变大(多跑),"跳过"必须由这张确定性红线把关:
任何圈选系统都不能成为合并前唯一一道测试关,标准是两层:
配套两个习惯:先灰度——头几周只在 PR 贴"建议跑这 X 个",CI 仍跑全量,对比推荐与真实失败后再让它接管跳过;新代码默认多跑,没历史的用例冷却期内一律纳入。
怎么知道系统有没有偷偷漏?看逃逸率:
$$\text{逃逸率} = \frac{\text{全量失败、但 PR 子集没跑的用例数}}{\text{全量失败的用例总数}}$$
运营三点:
如果你的主力是黑盒 E2E、又不想上传数据到 SaaS,下面给一个零模型也能独立工作的圈选器,一个文件、只用 Python 标准库(含详细注释约 400 行,核心判断并不多)。它把第三章的三条规矩直接固化进逻辑里。用 Python + pytest 演示,但它读的是 git diff、文件标签和 JUnit 报告,思路平移到任何语言都成立。
先说清楚落地形态:整套自研只需要你新增一个脚本,不用装任何第三方库。 脚本固定放在测试工程的根目录下的 scripts/(即scripts/select_regression.py),所有命令也都在这个根目录敲,脚本默认在此处找 tests/、读 git、读写历史文件。selected.txt、selected_audit.json、regression_history.json 不用你建,第一次跑会自动生成。
这里要分两种仓库形态(黑盒团队尤其注意第二种):
tests/ 就在业务仓里):脚本放业务仓根目录,它直接在本仓 git diff 就能拿到业务改动,开箱即用。git diff 只能看到测试脚本自己的改动,看不到业务仓改了啥。这时必须让 CI 在业务仓先产出改动清单(git diff --name-only origin/main...HEAD > changed.txt),再把清单传给脚本:select --changed-from changed.txt(或设环境变量 CHANGED_FILES)。脚本里"路径→标签"的映射按业务仓路径写。脚本已内置这两种模式,且清单文件一旦缺失会直接保守全量。整体就四步,后面几小节逐一展开:
scripts/,按你们项目改文件顶部四块——冒烟必跑清单、高风险路径正则、"路径→业务标签"映射表、标签白名单。# reg-tags: order, payment。python scripts/select_regression.py select --base origin/main(独立测试仓加 --changed-from changed.txt)生成 selected.txt,再 pytest $(cat selected.txt) 只跑圈中的;CI 里每次跑完追加一步 record report.xml 积累历史。它内部的判断逻辑是这样:
本次 git 改动
│
├─ 命中高风险区(公共框架/认证/支付核心/CI/迁移)?──是──▶ 全量
│
├─ 改动路径 ──映射──▶ 业务模块标签 ──┐
├─ 最近5次跑批失败过的用例 ──────────┼── 并集 ──▶ 子集
├─ 冒烟命门(ALWAYS_RUN)────────────┤ (写审计,可追溯)
└─ LLM 读 diff 建议的标签(白名单过滤)┘
│
└─ 取完是空集 / 取不到 diff?──是──▶ 全量兜底
几个刻意的设计:
# reg-tags: order, payment,再维护一张"源码路径 → 业务标签"的粗映射表。selected_audit.json:哪些用例因为标签命中、哪些因为历史失败、哪些是冒烟必跑,一目了然。这就是它的"凭什么"。scripts/select_regression.py
下面这份代码在一个含 7 个用例的最小 git 仓库里实测过:同仓场景覆盖改单模块出子集、历史失败精确拉入、公共/高风险改动与无基准 ref 两种全量兜底、逃逸率计算;独立测试仓场景覆盖 --changed-from 外部清单圈选、清单缺失回退全量。可直接照抄,只改文件顶部那几个配置。
#!/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()
给每个测试文件顶部加一行业务标签:
# tests/test_checkout.py
# reg-tags: order, payment
def test_submit_order(): ...
CI 里串起来(PR 阶段圈子集、记录结果;夜间全量后算逃逸率):
# 形态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 报错不能漏。
第一阶段只让它推荐不跳过:CI 照常全量,额外上传 selected_audit.json,人工对比几周"它圈的"和"实际失败的"重合度。等你对逃逸率有了底,再把 pytest 的入参从全量换成 $(cat selected.txt)。脚本里的映射表和高风险清单,也在这个阶段按真实逃逸案例逐步补全。
你可以把"敢跳过多少"也做成一个随数据收紧或放开的闸门:
| 阶段 | 圈选器的角色 | 跳过权限 | 前置条件 |
|---|---|---|---|
| L1 只看只说 | 在 PR 贴"建议跑这些",CI 仍全量 | 无(0% 跳过) | 圈选器跑起来、审计可查 |
| L2 有界跳过 | PR 只跑子集,夜间全量兜底;高风险改动强制全量 | 跳过非红线用例 | 灰度观察数周、逃逸率稳定达标 |
| L3 更激进优化 | 引入 ML(Launchable 类)按失败概率动态裁剪 | 动态比例 | 数月干净历史、逃逸率长期 < 阈值、有自动回退 |
注意 L3 的"激进"依然带着镣铐:红线清单不跳过、全量兜底不取消、逃逸率一旦抬头自动退回 L2。放权程度不是配置项拍脑袋定的,是被逃逸率这个客观指标顶上去的,也能被它一键拉回来。
日常只需要盯三个数:
回看执行自动化这条线,都在给同一个问题——怎么把"机器跑得快"和"结果靠得住"同时拿到——逐层上保险:
一句话总结:让 AI 做加法、做加速、做它擅长的重复判断;把减法、把"拍板放行"的权力,留在确定性规则和人手里。