用 Wilson 置信区间 + 最小样本量给 flaky 用例定判据:从"连续 3 次绿就放行"到自动隔离区
结论先行:flaky 判定不是"失败次数多就是 flaky",而是一个二项分布假设检验问题——你需要同时区分"稳定失败(regression)"和"偶发失败(flaky)",而这两者在失败率上可能只差 20pp。本文给一套可复现的判据:最小样本量 N≥10 + 95% Wilson 置信区间 + 三段式判定,把"人工拍脑袋拉黑"换成"数据说话"。
一、现象与背景
我负责一条支付清算链路的接口自动化(Python 3.11 + pytest 7.4 + httpx,约 1800 条用例,3 人共享 staging)。近 3 个月套件里有 40+ 条用例时红时绿,每次 CI 红了要人工翻日志判断"真 bug 还是 flaky",单次平均耗时 9 分钟,3 个月累计 600+ 分钟耗在"判 flaky"上。
更贵的代价:3 次真回归被"重跑变绿"掩盖,直到灰度环境才暴露。我们当时的土办法是"连续 3 次绿就暂时放行 + 加 TODO"——没人说得清"到底失败多少次、在多大样本里,才该判定它是 flaky 而不是回归"。
二、原理:为什么"失败率"一个数不够
一条用例的失败率 p_fail = k/N 是二项比例,它的观测值有固有方差:
σ(p_fail) = sqrt( p·(1-p) / N )
反直觉的地方:
- N=10, p=0.5 → σ ≈ 0.158(95% 区间宽度约 ±31pp)
- N=10, p=0.1 → σ ≈ 0.095(95% 区间宽度约 ±19pp)
- N=50, p=0.5 → σ ≈ 0.071(95% 区间宽度约 ±14pp)
也就是说,一条真正 50% 失败率的 flaky 用例,在 N=10 的样本里观测到 0% 或 100% 失败都是"正常发挥"。你拿"连续 3 次绿"做放行判据,等于在 N=3 的小样本上做判定——统计上毫无意义。
更关键的是:regression(稳定失败,p≈1.0)和 flaky(偶发失败,p≈0.1~0.5)在失败率上可能只差 20~40pp,单看 k/N 无法区分。必须看置信区间的下界:
- regression 的 Wilson 下界通常 ≥ 0.70(即使 N=10 全失败,下界也有 0.68)
- flaky 的 Wilson 下界通常 < 0.30
这就是判据的核心:不是看点估计,而是看 Wilson 置信区间的下界落在哪个区间。
三、具体做法:三段式判定 + pytest 插件
3.1 判据公式
给定一条用例最近 N 次运行(N ≥ 10),失败 k 次:
Wilson 区间 (95%):
center = (p̂ + z²/2N) / (1 + z²/N)
margin = z * sqrt( p̂(1-p̂)/N + z²/4N² ) / (1 + z²/N)
[lo, hi] = center ± margin
其中 p̂ = k/N, z = 1.96
三段式判定:
| 条件 | 判定 | 动作 |
|---|---|---|
| N < 10 | 样本不足 | 不判定,继续观测 |
| lo ≥ 0.70 | regression | 保持红色,不隔离,建 P1 工单 |
| lo < 0.50 且 hi ≥ 0.10 | flaky | 移入隔离区,打 quarantine 标签,建修复工单 |
| hi < 0.10 | 健康 | 正常执行 |
3.2 pytest 插件实现(核心 50 行)
# conftest_addon.py —— 放到 conftest.py 同目录,或打包为 pytest 插件
import json, math, os, time
from pathlib import Path
QUARANTINE_FILE = Path(os.environ.get("QUARANTINE_DB", ".quarantine.json"))
N_MIN = 10
Z = 1.96
def wilson(k, n):
"""返回 95% Wilson 置信区间 (lo, hi)"""
if n == 0:
return (0.0, 1.0)
p = k / n
denom = 1 + Z * Z / n
center = (p + Z * Z / (2 * n)) / denom
margin = Z * math.sqrt(p * (1 - p) / n + Z * Z / (4 * n * n)) / denom
return (max(0, center - margin), min(1, center + margin))
def load_state():
if QUARANTINE_FILE.exists():
return json.loads(QUARANTINE_FILE.read_text())
return {}
def save_state(state):
QUARANTINE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False))
def record_result(nodeid, passed, state):
rec = state.get(nodeid, {
"passes": 0, "fails": 0,
"quarantined": False, "quarantined_at": None,
"reason": "", "verdict": ""
})
if passed:
rec["passes"] += 1
else:
rec["fails"] += 1
n = rec["passes"] + rec["fails"]
if n >= N_MIN:
lo, hi = wilson(rec["fails"], n)
if lo >= 0.70:
rec["verdict"] = "regression"
elif lo < 0.50 and hi >= 0.10:
rec["verdict"] = "flaky"
if not rec["quarantined"]:
rec["quarantined"] = True
rec["quarantined_at"] = int(time.time())
rec["reason"] = f"Wilson [{lo:.2f},{hi:.2f}] after {n} runs"
else:
rec["verdict"] = "healthy"
rec["quarantined"] = False # 自动解除隔离
state[nodeid] = rec
def pytest_runtest_logreport(report):
"""每次用例运行后记录结果(仅首次运行,重试不计入)"""
if report.when != "call":
return
state = load_state()
record_result(report.nodeid, report.passed, state)
save_state(state)
def pytest_collection_modifyitems(config, items):
"""收集阶段跳过已隔离用例"""
state = load_state()
keep = []
for it in items:
rec = state.get(it.nodeid)
if rec and rec.get("quarantined"):
print(f"[QUARANTINE] skip {it.nodeid} ({rec.get('reason','')})")
continue
keep.append(it)
items[:] = keep
3.3 CI 配置(GitLab CI 示例)
test:
script:
- python -m pytest -v --junitxml=report.xml
after_script:
- python ci_flaky_report.py --state .quarantine.json --output flaky_report.json
artifacts:
paths:
- .quarantine.json # 持久化判定状态,跨 pipeline 累积
- flaky_report.json
expire_in: 30 days
3.4 隔离区生命周期
- 进入:Wilson 判定为 flaky 时自动移入;
- 观察期:30 天,期间每 5 次运行重新计算一次 Wilson 区间;
- 自动解除:连续 10 次全绿(N=10, k=0, hi≈0.27)→ 自动恢复执行;
- 超期升级:30 天未修复 → 自动创建 P2 工单,标记
stale-quarantine; - 强制回收:60 天未修复 → 从主套件中移除,归档到
archive/目录。
四、踩过的坑
-
最小样本量 N=10 不是拍脑袋。N=5 时 Wilson 区间宽度 ±39pp(p=0.5),任何判定都是噪声;N=10 时 ±31pp,刚好能区分 p=0.1(lo=0.02, hi=0.44)和 p=0.8(lo=0.44, hi=0.98)。再往上 N=20 收敛更快但等待成本翻倍,10 是性价比拐点。
-
重试会污染判据。如果 pytest 配了
--reruns=2,那"失败 1 次"在日志里变成 3 次记录(1 失败 + 2 重试成功),Wilson 输入被稀释。我的做法:判据只看首次运行结果,重试仅用于"观测"不计入k/N。具体是pytest_runtest_logreport里过滤report.when == "call"且排除重试轮次。 -
隔离区不是垃圾桶。30 天观察期里,隔离区的用例仍然要跑(只是不计入主套件的 pass/fail 统计),否则你永远不知道它修没修好。具体实现是主 pipeline 里 skip,但 nightly job 里单独跑隔离区并报告"今日仍失败 / 已转绿"。
-
regression 和 flaky 的边界会随版本漂移。一次大重构后,原本 50% 失败率的用例可能变成 80%——Wilson 下界从 0.30 跳到 0.70,自动从 flaky 升级为 regression。这是期望行为,但要在 CI 报告里高亮"判定变更"事件,否则团队会以为判定系统坏了。
五、结论与可复用清单
核心思路:flaky 判定是假设检验问题,不是"数失败次数"问题。用 Wilson 置信区间的下界区分 regression 和 flaky,用最小样本量避免小样本噪声,用隔离区生命周期让判定可逆。
可复用清单:
- [ ] 最小样本量 N_min = 10(核心原则:N 使得 Wilson 95% 区间宽度 ≤ ±31pp)
- [ ] Wilson 95% 置信区间,判定看下界而非点估计
- [ ] 三段式:regression(lo≥0.70)/ flaky(lo<0.50 且 hi≥0.10)/ healthy(hi<0.10)
- [ ] 重试不计入判据,仅用于观测
- [ ] 隔离区 30 天观察期 + 自动解除 + 超期升级 + 60 天强制回收
- [ ] 判定状态持久化(
.quarantine.json),CI artifact 保留 30 天 - [ ] CI 报告里高亮"判定变更"事件(flaky→regression、regression→healthy 等)
一句话:把"连续 3 次绿就放行"换成"Wilson 下界 < 0.50 才进隔离区",判据就从感觉变成了数学。