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

简介: 这段文字介绍了一套针对“不稳定测试”(flaky test)的工程化治理方案:通过量化失败率、自动隔离、状态机管理与独立流水线,将随机红灯从噪音转化为可追踪、可归责、可修复的质量信号,真正实现“红灯有因、处置有据、风险可见”。

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

相关文章
|
7天前
|
人工智能 API 内存技术
刚刚 DeepSeek V4.1 Flash 开启内测,1 分钟教你用上!
刚刚 DeepSeek 内测群发布了 DeepSeek V4.1 Flash 中间版本内测的消息,这次的模型采用了新的结构,原生支持多模态、能力更强、速度更快、且成本更低。
1793 10
|
11天前
|
人工智能 运维 BI
阿里云千问办公QwenWork深度解析:基于Qwen3.8,六大核心能力重构企业全自动化工作流与计费选型指南
传统AI办公工具大多停留在对话问答、文档摘要、简单文案生成层面,只能完成单点碎片化任务,无法自主拆解复杂业务流程,很难串联多工具、多文档、外部业务系统完成端到端完整工作交付。很多企业在落地AI办公的时候,需要组合多款不同工具,来回切换界面,手动复制粘贴中间结果,智能化改造落地门槛居高不下。千问办公QwenWork是整合多款智能体产品能力打造的一体化企业办公智能体平台,底层基座依托Qwen3.8大模型,打通桌面端Agent、云端Agent、企业协同Agent三种运行形态,不再局限简单问答,接收业务目标之后自主拆解任务步骤,调用各类工具,处理文档、表格、浏览器自动化、数据查询,直接输出可交付的办公
1644 3
|
12天前
|
网络协议 Linux iOS开发
【2026实测】Wireshark下载+安装+汉化+使用教程(图文版,巨详细)
Wireshark 是一款免费开源的网络协议分析工具,可实时捕获、解析并可视化数据包,助你诊断网络故障、分析通信协议(如HTTP、DNS、TCP等)。支持Windows/macOS/Linux,含中文界面,新手入门便捷。(239字)
|
8天前
|
SQL 人工智能 前端开发
QoderWake 1.0 正式发布:从桌面里的 Agent,到工作现场的数字员工
QoderWake v1.0正式发布:企业级数字员工团队平台。支持“一句话建岗”,预置10类特训岗位;Waker常驻钉钉/飞书群,@即响应、自动协作、跨任务记忆;具备定时/事件/API多触发方式与统一任务看板;已沉淀27.6万条记忆、12.3万项技能,助力组织实现人机协同增效。
780 2
|
6天前
|
缓存 测试技术 API
DeepSeek V4.1 Flash 内测接入:改个模型名即可调用(附代码)
DeepSeek V4.1 Flash 内测不用申请,base_url 不变、改个模型名就能调,9/10 到期。本文讲清接入、计费限流与多模态注意点。
807 0
DeepSeek V4.1 Flash 内测接入:改个模型名即可调用(附代码)
|
20天前
|
人工智能 自然语言处理 安全
阿里云千问办公、Qoder Teams、Qoder CN区别与选择指南:模型能力、适用场景与最新活动参考
本文聚焦阿里云2026年推出的三款自研AI办公产品,清晰拆解千问办公、Qoder Teams、Qoder CN的差异化定位与能力边界:千问办公主打职场全场景提效,支持自然语言指令一键完成PPT生成、数据分析等高频办公任务;Qoder Teams面向程序员团队,深度整合AI代码生成、团队协同与企业知识库能力;Qoder CN则专为金融、政务等强合规场景打造,实现数据不出境与VPC私有化部署。文章同步给出分场景选型指南与最新活动定价,帮助不同类型的企业按需组合产品,实现业务岗、研发岗与强合规场景的AI能力全覆盖。
3970 5
阿里云千问办公、Qoder Teams、Qoder CN区别与选择指南:模型能力、适用场景与最新活动参考
|
11天前
|
人工智能 自然语言处理 安全
阿里云AI数智鉴密:AI 生成内容如何拿到一张"防篡改的身份证"
隐形水印 + C2PA签名:让AI生成内容“持证上岗”。
1156 0
|
13天前
|
缓存 数据可视化 开发工具
DeepSeek Harness 怎么更新?dsh 更新完整指南:更新本体(npx、npm、源码)与更新插件两种方式
DeepSeek Harness 的更新分两层:本体更新(npx 自动最新、npm update -g、源码 git pull)与插件更新(插件市场点更新、命令行覆盖安装)。本文按「准备 → 更新本体 → 更新插件 → 更新后检查」四步走,覆盖新手常见疑问。
1534 1
DeepSeek Harness 怎么更新?dsh 更新完整指南:更新本体(npx、npm、源码)与更新插件两种方式
|
6天前
|
人工智能
千问办公官网入口:阿里AI办公QwenWork产品页和免费网页端链接
千问办公官网含两大入口:一是网页端(qwenwork.cn),即开即用,支持浏览器直接访问;二是阿里云产品页 https://t.aliyun.com/U/JNKJuO 提供免费/付费版详情、功能介绍及使用指南。

热门文章

最新文章