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

相关文章
|
12小时前
|
人工智能 自然语言处理 前端开发
字节用半年让85%的AI用例跑进CI/CD,你的团队还在为“AI生成不能用”发愁?
本文剖析字节跳动NL2Test Agent成功落地的五大关键:聚焦“用例转译”而非替代、先闭环再优化、LLM与程序分工协作、精准治理上下文、优先生成稳定断言。对比失败案例,揭示AI测试成败核心在工程设计,而非模型能力。
|
1月前
|
人工智能 自然语言处理 数据挖掘
阿里云百炼产品月报【2026年7月】
阿里云百炼本月重磅升级:发布企业级AGENT全栈平台,支持可视化编排与长程自主规划;上线Token Plan个人版,提供Qwen等多模态模型统一调用;推出Knowledge Studio知识库RAG方案,毫秒检索+多轮推理;新增39个MCP生态模板及11款应用模板,并优化Qwen-Audio、Qwen-Image等12个新模型。
604 0
|
1月前
|
人工智能 自然语言处理 数据可视化
千问办公:一站式AI生产力平台,免费版、个人标准版、个人高级版配置价格,新用户注册送2000积分
千问办公是阿里巴巴推出的AI原生办公平台,基于Qwen3.8大模型,支持一句话完成PPT生成、数据分析、视频剪辑、网页搭建等复杂任务,直出可用成果。已覆盖桌面端、网页端及钉钉生态,深度融合IM、云盘与本地文件系统,真正实现“对话即交付”。
609 1
|
5月前
|
人工智能 JSON 监控
我写了一套内容分析系统,专门拆解爆款博主的选题逻辑
信息太多了,但真正有用的洞察太少。
787 5
|
12月前
|
自然语言处理 监控 API
小红书爆文解码:用API分析互动数据,精准指导创作方向
在内容为王时代,爆文背后有科学公式!通过小红书API抓取百万笔记数据,提炼出点赞转化率、收藏价值系数、评论情感值三大核心指标,揭秘爆文特征不等式与内容元素矩阵,手把手教你用数据驱动创作,实现从0到百万曝光的逆袭!
1339 0
|
21小时前
|
存储 SQL 运维
【服务器数据恢复】基于不同机型的服务器RAID5容错原理与数据恢复策略
随着信息技术的持续迭代,服务器硬件架构与阵列技术不断升级,不同型号服务器的RAID5故障表现、处理逻辑及恢复方法存在明显差异。当前,大型业务系统的网络架构多采用C/S或B/S模式,核心业务数据库均部署在中心机房的专用服务器中。为保障数据存储的安全性、稳定性与可靠性,行业内普遍采用RAID磁盘阵列技术实现磁盘冗余备份。
47 26
|
机器学习/深度学习 人工智能 自然语言处理
|
25天前
|
人工智能 JavaScript 测试技术
DeepSeek Harness爆火,测试开发的下一个“版本答案”找到了!
DeepSeek于2026年8月开源Agent执行底座Harness,6天获16.7万星。它并非模型,而是“AI操作系统”:以插件化架构解耦模型与工具,支持多厂商LLM,实现“一切皆插件”。专为测试开发等工程场景设计,推动从写脚本到编排Agent的范式升级。
|
30天前
|
数据采集 JavaScript 测试技术
DeepSeek Harness 原生 Agent 框架首发深度评测:从安装到实战,3 小时压测全记录
DeepSeek Harness是其全新Agent执行框架,支持四种运行模式、插件化扩展与Web UI。实测显示任务质量媲美Claude,但效率与稳定性待优化。目前处于公测阶段,潜力巨大。

热门文章

最新文章