同一段代码、同一个 commit,跑十次红三次——这不叫 bug,这叫你的用例在掷骰子。骰子最麻烦的地方不是它会输,是你没法预测它哪天输,于是每一次红灯都得有人停下来判断一遍:"这次是真炸了,还是又抽风了?"
一、业务场景:约 400 条用例的电商回归集,每天随机红 3—5 条
先把场景钉死,不然方法论都是空的。
我们这套回归集服务于一个电商中台:下单、库存扣减、优惠券核销、支付回调、订单状态机流转,加起来约 400 条接口与端到端用例,跑在 GitHub Actions 上,主干每次合并触发一次,夜间再全量跑一次。
它的典型症状是:每天稳定红 3—5 条,但红的是哪几条不固定。今天 test_coupon_stack_with_vip_price 挂,明天 test_inventory_rollback_on_pay_timeout 挂,后天两条一起挂又一起好。人工点一下重跑,大概率绿。
这 3—5 条带来的真实成本不是"多等两分钟",是下面三件事:
第一,红灯的信号价值被稀释。 值班同学看到红,第一反应是重跑,不是看日志;重跑绿了就归档。真出过的那次线上事故,事后回溯发现前一天夜间的回归集已经红了同一条用例,但当天被当成 flaky 重跑掉了。
第二,责任无法落地。 用例红的时候没人认领,因为大家都知道"它平时也红"。等它连续红三天变成真故障,代码已经合进来好几个 PR,git bisect 的范围大得没人愿意做。
第三,团队会自发开始删用例。 这是最危险的一条。删掉一个老红的用例,是消灭红灯最快的方式,也是消灭这条用例所守护的那个业务风险最快的方式。
二、为什么不能一红了就删,也不能放着不管
三种常见处理方式摆在一起看,差别很清楚:
处理方式
短期效果
长期代价
风险可见性
适用场景
直接删掉用例
红灯立刻消失,流水线全绿
该用例覆盖的业务路径从此无人守护,回归漏洞静默累积
归零,出问题时无人知晓
用例本身已失效(接口下线、业务废弃)
标记 skip/忽略
红灯消失,代码还留在仓库里
skip 一旦打上几乎不会被摘掉,等同软删除;且旁人看不出它原本守护什么
名义上存在,实际为零
已知阻塞的临时绕过,且必须带到期时间
自动隔离(quarantine)
主干恢复稳定绿,问题被移到独立轨道继续跑
需额外维护一条隔离区流水线和一份状态台账
保留且被量化:失败率、隔离天数、归属人
疑似 flaky,需要时间观察与定位
差在最后一列。删和忽略都是把风险从视野里抹掉;隔离是把风险从主干挪到一个专门的地方继续盯着,并且给它记档。
隔离不是缓刑,是转诊。 用例进隔离区那一刻就该带上三个属性:它有多不稳定(失败率)、它在这待了多久(隔离天数)、谁负责把它弄出去(owner)。少任何一个,隔离区就会退化成用例坟场——半年后你打开台账,里面有 80 条用例,没人记得它们为什么进来。
三、先把 flakiness 量化出来:同一个 commit 重跑 N 次
flaky 的定义必须可计算,否则"这条用例不稳定"永远是一句主观判断。我们用的口径是:固定 commit、固定环境,同一条用例重跑 N 次,统计失败次数占比。
下面这段脚本可以直接跑。它锁死当前 commit,对指定用例重跑 N 次,解析 pytest 输出的 JUnit XML,算出失败率并给出判定:
flaky_probe.py
用途:固定 commit 重跑 N 次,量化单条用例的失败率# 依赖:pytest(--junitxml 为 pytest 内置能力,无需额外插件)import subprocessimport sysimport xml.etree.ElementTree as ETfrom pathlib import PathRERUN = 10# 重跑次数,示例值【推断】FAIL_RATE_GATE = 0.2# 失败率门槛,示例值【推断】,我们团队设的是 20%def runonce(nodeid: str, idx: int) -> bool:"""跑一次,返回是否通过。不启用任何自动重试,避免污染统计。""" report = Path(f"reports/probe{idx}.xml") report.parent.mkdir(parents=True, exist_ok=True) cmd = [ sys.executable, "-m", "pytest", nodeid,f"--junitxml={report}","-p", "no:randomly", # 关掉随机执行顺序,排除用例间相互干扰"--no-header", "-q", ] subprocess.run(cmd, capture_output=True) root = ET.parse(report).getroot() case = root.find(".//testcase")if case isNone:returnFalsereturn case.find("failure") isNoneand case.find("error") isNonedef probe(nodeid: str) -> dict: commit = subprocess.run( ["git", "rev-parse", "HEAD"], capture_output=True, text=True, check=True ).stdout.strip() results = [run_once(nodeid, i) for i in range(RERUN)] failures = results.count(False)if failures == 0: verdict = "STABLE"# N 次全绿elif failures == RERUN: verdict = "BROKEN"# N 次全红,这是真故障else: verdict = "FLAKY"# 有绿有红,才是掷骰子return {"nodeid": nodeid,"commit": commit[:12],"runs": RERUN,"failures": failures,"fail_rate": round(failures / RERUN, 3),"verdict": verdict, }if name == "main": target = (sys.argv[1] if len(sys.argv) > 1else"tests/regression/test_coupon.py::test_coupon_stack_with_vip_price") stat = probe(target) print(stat)if stat["verdict"] == "FLAKY"and stat["fail_rate"] >= FAIL_RATE_GATE: print(f"[QUARANTINE] 失败率 {stat['fail_rate']:.0%} 超过门槛,建议隔离") sys.exit(2) # 用 2 区分「需要隔离」与 1「执行本身出错」
三个分支必须分清:STABLE 是正常,FLAKY 进隔离流程,BROKEN 直接拦流水线当天修。
把 BROKEN 混进隔离区是最常见的误用。 一条稳定失败的用例进了隔离区,等于把一个确定的 bug 降级成"待观察",然后再没人管它。隔离区只收掷骰子的,不收一直输的。
重跑次数 N 怎么定?我们团队设的是 10 次【推断】。少于 5 次分辨不出 20% 左右的失败率,多于 20 次对 400 条用例的集合来说时间成本扛不住。真正贵的从来不是单条用例,是全量重跑——所以探测只对"最近 7 天在 CI 里出现过非确定性失败"的用例做,绝不做全量。
四、自动打隔离标签:状态机与进入/退出条件
量化之后,隔离动作必须自动。靠人手动打标签一定会漏,而且漏的往往正是最该隔离的那条。
我们用 pytest marker 加一份 JSON 台账实现:标签写在用例上供 CI 筛选,台账记状态供判定回迁。整套状态机长这样:
状态
进入条件
隔离区每日结果如何记账
退出条件
自动动作
WATCH(观察)
探测判定 FLAKY,或 CI 中同一 commit 重跑后由红转绿
只记录一次绿/红,不计入连续绿
累计 3 次探测均为 STABLE
无,仅记账
QUARANTINE(隔离)
WATCH 状态下 7 天内再次出现非确定性失败
单独流水线跑,红不阻塞主干
连续绿 ≥ 5 天【推断】
打 marker、写台账、指定 owner
RETURN(回迁)
QUARANTINE 连续绿达标
摘掉 marker,回主干回归集
回主干后 3 天内再红 → 退回 QUARANTINE 并升级
提 PR 摘标签,附隔离期完整数据
ESCALATE(开单)
QUARANTINE 停留 ≥ 14 天【推断】仍未达标
停止计数,标记逾期
工单关闭且修复合并
自动开 issue,@owner 与测试负责人
自定义 marker 要先注册,否则 pytest 会告警甚至(开了严格模式时)直接报错:
pytest.ini
[pytest]
markers =
quarantine(reason, since): 已隔离的 flaky 用例,主干回归集通过 -m "not quarantine" 排除
打标与台账维护:
quarantine_tag.py
用途:按探测结果自动加 pytest marker,并维护隔离台账import jsonimport subprocessfrom datetime import date, datetimefrom pathlib import PathLEDGER = Path("quarantine_ledger.json")GREEN_DAYS_TO_RETURN = 5# 连续绿几天回迁,示例值【推断】MAX_QUARANTINE_DAYS = 14# 超期开单阈值,示例值【推断】MARK = "quarantine"def load_ledger() -> dict:return json.loads(LEDGER.read_text(encoding="utf-8")) if LEDGER.exists() else {}def save_ledger(data: dict) -> None: LEDGER.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")def add_marker(nodeid: str, reason: str, fail_rate: float) -> bool:"""在用例定义上一行插入 @pytest.mark.quarantine,已打过则跳过""" filepath, , test_name = nodeid.partition("::") path = Path(file_path) lines = path.read_text(encoding="utf-8").splitlines(keepends=True) tag = (f'@pytest.mark.{MARK}(reason="{reason} 'f'fail_rate={fail_rate:.0%}", since="{date.today()}")\n') out, inserted = [], Falsefor line in lines:if line.startswith(f"def {test_name}("):if MARK notin"".join(out[-3:]): # 往上三行内已有标记就不重复插 out.append(tag) inserted = True out.append(line)if inserted: path.write_text("".join(out), encoding="utf-8")return inserteddef update_after_daily_run(nodeid: str, passed: bool, owner: str) -> str:"""隔离区每天跑完后调用,返回用例当前状态""" ledger = load_ledger() today = date.today() rec = ledger.setdefault(nodeid, {"owner": owner,"state": "QUARANTINE","since": today.isoformat(),"green_streak": 0,"history": [], }) rec["history"].append({"date": today.isoformat(), "passed": passed})# 一次红就清零:连续绿必须是真连续 rec["green_streak"] = rec["green_streak"] + 1if passed else0 days_in = (today - datetime.fromisoformat(rec["since"]).date()).daysif rec["green_streak"] >= GREEN_DAYS_TO_RETURN: rec["state"] = "RETURN" action = "摘标签、提 PR 回主干"elif days_in >= MAX_QUARANTINE_DAYS: rec["state"] = "ESCALATE" action = f"自动开单并 @ {owner},已隔离 {days_in} 天"else: action = f"继续观察,连续绿 {rec['green_streak']}/{GREEN_DAYS_TO_RETURN}" save_ledger(ledger) print(f"[{rec['state']}] {nodeid} -> {action}")return rec["state"]
green_streak 一次红就清零,这条规则不能松。放宽成"7 天里绿 5 天就回迁",你会看到回迁的用例第二天又在主干红——因为它根本没被修好,只是骰子最近手气不错。
五、隔离区每天单独跑,主干只认真故障
隔离区必须是独立的一条流水线:独立调度、独立结论、独立通知渠道。挂在主干上"顺便跑一下"是不行的,因为它的红不该阻塞任何人。
主干这条,隔离区用例被排除,红就是真红:
.github/workflows/regression-main.yml
name:regression-mainon:pull_request:push:branches:[main]jobs:regression:runs-on:ubuntu-lateststeps:-uses:actions/checkout@v4-uses:actions/setup-python@v5with:python-version:"3.11"-run:pipinstall-rrequirements.txt-name:跑主干回归集(排除隔离区)run:|
mkdir -p reports
pytest tests/regression -m "not quarantine" \
--junitxml=reports/main.xml -q
-name:主干失败即拦截,不自动重跑if:failure()run:|
echo "主干红灯不允许靠重跑消除,请查 reports/main.xml"
exit 1
-uses:actions/upload-artifact@v4if:always()with:name:main-reportpath:reports/main.xml
关键在 if: failure() 那一步:主干不做自动重跑。 一旦允许"红了重跑三次取最好成绩",你就亲手把 BROKEN 和 FLAKY 搅成一锅,前面所有量化都失去意义。重跑只发生在探测脚本里,且明确标注为探测行为。
还有一个容易被忽略的配置:两条流水线的通知渠道必须分开。主干红,推到值班群并 @ 当班人;隔离区红,只写进每日汇总,不打扰任何人。混在一个群里发,结果就是值班群每天被 3—5 条隔离区红灯刷屏,两周之后所有人集体屏蔽这个群,连带着主干的真故障也一起被屏蔽了。通知的密度决定了通知的价值,这条规律在测试群里和在告警系统里完全一样。
隔离区这条,每天定时跑,跑完更新台账并判定回迁或开单:
.github/workflows/quarantine-daily.yml
name:quarantine-dailyon:schedule:-cron:"30 22 *"# GitHub Actions cron 用 UTC,对应北京时间次日 06:30workflow_dispatch:jobs:quarantine:runs-on:ubuntu-latestcontinue-on-error:true# 隔离区红不阻塞仓库状态steps:-uses:actions/checkout@v4with:fetch-depth:0# 需要 git 历史做 commit 归属-uses:actions/setup-python@v5with:python-version:"3.11"-run:pipinstall-rrequirements.txt-name:只跑隔离区用例run:|
mkdir -p reports
pytest tests/regression -m "quarantine" \
--junitxml=reports/quarantine.xml -q || true
-name:更新台账并判定回迁/开单id:escalaterun:pythontools/quarantine_daily.py--xmlreports/quarantine.xml# 脚本内部向 $GITHUB_OUTPUT 写 needed=true/false,向 $GITHUB_ENV 写 ESCALATE_LIST-name:超期未修自动开单if:steps.escalate.outputs.needed=='true'uses:actions/github-script@v7with:script:|
const items = JSON.parse(process.env.ESCALATE_LIST || "[]");
for (const it of items) {
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: [隔离超期] ${it.nodeid} 已隔离 ${it.days} 天,
body: 隔离期失败率 ${it.fail_rate},owner @${it.owner}\n完整数据见台账。,
labels: ["quarantine-overdue"],
assignees: [it.owner],
});
}
-uses:actions/upload-artifact@v4if:always()with:name:quarantine-reportpath:reports/quarantine.xml
回迁那一步走 PR,不要直接推主干。PR 描述里贴上隔离期的完整数据:隔离了多少天、期间失败率多少、连续绿几天、最后是谁用什么方式修好的。这份数据就是这条用例的病历,下次它再红,翻病历比从头排查快得多。
六、这套东西真正改变的是什么
跑了两个月之后,最明显的变化不是"红灯变少了",而是红灯的含义变清楚了。
主干红,就是真故障,值班同学直接看日志,不用再猜;隔离区红,是一条已知不稳定用例的又一次抽样,有台账、有 owner、有到期日。两种红走两条路,谁也不污染谁。
约 400 条用例的集合、每天随机红 3—5 条,这个数字本身不会因为你建了隔离流水线就归零。会变的是:这 3—5 条不再散落在主干里制造噪音,而是被集中到一个有状态、有期限、有归属的地方,一条条被修掉,或者被明确判定为"该删"。
删用例这件事,在有了隔离期数据之后反而变容易了。你能拿出一条用例连续 30 天失败率 60%、且它守护的接口半年前就已下线的证据,删得理直气壮;而不是因为"它老红我很烦",删得心虚。
flaky 测试的问题从来不是它会红,而是没人给它的红记账。建一条隔离流水线,本质上不是为了让流水线变绿,是为了让每一次红都有出处、有归属、有期限。