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

相关文章
|
27天前
|
人工智能 自然语言处理 前端开发
字节用半年让85%的AI用例跑进CI/CD,你的团队还在为“AI生成不能用”发愁?
本文剖析字节跳动NL2Test Agent成功落地的五大关键:聚焦“用例转译”而非替代、先闭环再优化、LLM与程序分工协作、精准治理上下文、优先生成稳定断言。对比失败案例,揭示AI测试成败核心在工程设计,而非模型能力。
|
存储 前端开发 关系型数据库
No106.精选前端面试题,享受每天的挑战和学习
No106.精选前端面试题,享受每天的挑战和学习
|
25天前
|
SQL 测试技术 数据库连接
我用ChatGPT把回归测试从3天压到3小时,提示词全公开
本文分享如何用ChatGPT优化电商后台回归测试:通过提示词引导,实现用例梳理、自动化脚本生成与日志分析三步提效,将3天人工测试压缩至3小时。强调其辅助定位而非替代人力,聚焦释放工程师精力于高价值工作。
|
21天前
|
人工智能 自然语言处理 测试技术
Google 开源 ARTEMIS:AI Agent 如何接管 Android 真机测试?
Google开源的ARTEMIS是一款AI驱动的Android自动化测试框架,支持自然语言指令、跨App长流程操作、多模态控件识别(Accessibility+OCR+视觉模型),原生集成MCP协议,可无缝接入Antigravity等AI编程环境,实现“描述目标→自主规划→真机执行→结果分析”闭环,标志着移动端测试迈向AI Agent时代。
|
21天前
|
SQL 人工智能 安全
Agent Harness 又要多一层?Jev 开始接管这些高频判断
Jev作为新型System One Model,专司Agent中高频、明确的判断任务(如工具路由、技能筛选、上下文过滤、安全守门与执行复核),将LLM从繁重决策中解放,推动Agent架构向“规则+决策模型+LLM+工具”多层协同演进。
|
24天前
|
人工智能 Kubernetes 测试技术
会AI的测试开发,薪资到底能高多少?我扒了100份JD
2026年测试岗巨变:手工测试需求降47%,测开岗涨340%。AI测试开发年薪达35–100万+,远超传统测开;岗位核心已从“验证功能”转向“评测能力”,要求大模型、Agent、RAG等实战经验。
|
24天前
|
人工智能 监控 安全
DeepSeek Harness爆火,测试人能拿它做什么?这5个场景最实用
本文揭秘DeepSeek在测试领域的5大实战用法:用例生成、接口自动化、代码审查、单元测试、日志分析,辅以详细步骤与避坑指南。实测可减少60%-70%重复劳动,强调“AI代劳+人工审核”——替手不替脑。
|
1月前
|
机器学习/深度学习 人工智能 自然语言处理
2026测试Skill大爆发:从“会写脚本”到“会设计智能体”
2026年测试行业正经历结构性变革:手工测试需求降47%,全栈测开增340%。“熟悉MCP协议”“具备Skill封装与工程化能力”已成硬性门槛,而非加分项。测试核心正从“写脚本”跃迁为“设计智能体”——验证对象由功能转向AI决策能力,底层资产从用例库升级为可复用Skill库。
|
1月前
|
人工智能 JavaScript NoSQL
24.5万 Star、安装超2000万次,这套顶级Skills到底强在哪?
AI写代码越快,返工越多?问题不在模型不够强,而在缺乏工程规范。TypeScript开发者Matt Pocock开源的**skills**项目(24万Star、2030万次安装),将TDD、需求澄清、Bug复现等经典工程实践转化为53个可插拔AI工作模块,为Claude/Codex等编码Agent注入“软件工程纪律”,让AI不止会写,更懂怎么正确地写。
|
2月前
|
人工智能 JavaScript 测试技术
保姆级教程:DeepSeek Harness从安装到跑通测试,30分钟上手
DeepSeek Harness是DeepSeek开源的AI Agent运行时,主打“一行命令安装、5分钟跑通”。它让模型真正动手干活——读代码、跑测试、分析失败、生成修复方案。本文手把手教你30分钟从零上手,覆盖安装、配置、实测及避坑指南,助你快速掌握下一代AI编程范式。