霍格沃兹测试开发学社

《Python测试开发进阶训练营》(随到随学!)
2023年第2期《Python全栈开发与自动化测试班》(开班在即)
报名联系weixin/qq:2314507862

Flaky 测试别急着删:给它建一条自动隔离(quarantine)流水线

关注 霍格沃兹软件测试开发 公众号,回复「资料」, 领取人工智能测试开发技术合集

同一段代码、同一个 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 run_once(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,已打过则跳过""" file_path, _, 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 测试的问题从来不是它会红,而是没人给它的红记账。建一条隔离流水线,本质上不是为了让流水线变绿,是为了让每一次红都有出处、有归属、有期限。

推荐学习
智能化测试落地公开课,从大模型到Harness,再到用例生成与自动化实战。不需要你懂大模型原理,但需要你有真实的测试业务场景。听完之后,你至少能判断:自己团队当前阶段该从哪个方向切入,该用什么工具,该避开哪些坑。

扫码进群,报名公开课。

image

你们团队的隔离区里现在躺着多少条用例?最老的那条进去多久了?留言区报个数。

关于我们
霍格沃兹测试开发学社,隶属于 测吧(北京)科技有限公司,是一个面向软件测试爱好者的技术交流社区。

学社围绕现代软件测试工程体系展开,内容涵盖软件测试入门、自动化测试、性能测试、接口测试、测试开发、全栈测试,以及人工智能测试与 AI 在测试工程中的应用实践。

我们关注测试工程能力的系统化建设,包括 Python 自动化测试、Java 自动化测试、Web 与 App 自动化、持续集成与质量体系建设,同时探索 AI 驱动的测试设计、用例生成、自动化执行与质量分析方法,沉淀可复用、可落地的测试开发工程经验。

在技术社区与工程实践之外,学社还参与测试工程人才培养体系建设,面向高校提供测试实训平台与实践支持,组织开展 “火焰杯” 软件测试相关技术赛事,并探索以能力为导向的人才培养模式,包括高校学员先学习、就业后付款的实践路径。

同时,学社结合真实行业需求,为在职测试工程师与高潜学员提供名企大厂 1v1 私教服务,用于个性化能力提升与工程实践指导。

posted @ 2026-09-15 21:23  霍格沃兹测试开发学社  阅读(6)  评论(0)    收藏  举报