背景与问题
很多团队做自动化测试,最开始的目标都很朴素:让回归更快、让上线更稳。但随着用例数量上涨,CI 跑得越来越频繁,问题也会慢慢浮出来:
- 用例不是一直红,而是“偶尔红”
- 同一代码版本,重新跑一次又绿了
- 失败原因看起来像环境问题、数据问题、等待时序问题
- 开发开始不信任测试结果,看到红灯先点“重试”
这类问题有个很典型的名字:脆弱用例(Flaky Test)。
它最麻烦的地方不是“失败”,而是不稳定带来的误导:
- 真缺陷会被误认为环境抖动
- 假失败会阻塞流水线,拉高交付成本
- 团队逐渐形成“红了先重跑”的坏习惯
- CI 的信号质量下降,自动化测试变成噪音源
我自己做过一段时间这类治理,最大的感受是:稳定性问题不能只靠“多 sleep 几秒”解决。真正有效的方式,是把它当成一个持续治理工程,分成:
- 识别:哪些用例最脆弱
- 分类:为什么脆弱
- 修复:按模式治理
- 度量:误报率有没有下降
- 策略:CI 怎么减少无效拦截
这篇文章会从实战角度,带你搭一套最小可用方案。
前置知识与环境准备
本文默认你具备这些基础:
- 会写基础自动化测试
- 知道 CI/CD 的基本概念
- 能运行 Python 脚本
- 理解测试报告里
pass/fail/skip/retry
本文示例环境:
- Python 3.11+
- pytest
- pytest-rerunfailures
- 一份历史测试执行结果(我们会自己造一份样例)
安装依赖:
pip install pandas pytest pytest-rerunfailures
项目结构可以这样放:
.
├── flaky_demo/
│ ├── analyze_flaky.py
│ ├── ci_gate.py
│ ├── test_unstable_demo.py
│ └── test_history.csv
核心原理
稳定性治理不是单点技巧,而是一条链路。先看整体:
flowchart TD
A[CI 执行测试] --> B[收集历史结果]
B --> C[识别脆弱用例]
C --> D[按失败模式分类]
D --> E[修复用例/环境/数据]
E --> F[调整 CI 门禁策略]
F --> G[跟踪误报率下降]
这里有三个核心指标特别重要。
1. 脆弱率
某个测试在最近 N 次执行中,既出现过成功也出现过失败,那么它大概率是脆弱用例。
一个简单定义:
fail_rate = 失败次数 / 总执行次数- 如果
0 < fail_rate < 1,并且达到一定执行样本量,就值得关注
但只看失败率还不够。因为有些测试是“稳定失败”,那通常是产品缺陷或脚本已坏,不属于 flaky。
2. 误报率
在 CI 里,我们更关心的是:失败是不是值得拦截流水线。
可以用一个偏工程化的定义:
误报率 = 最终被判定为非真实产品缺陷的失败次数 / 总失败次数
比如:
- 重跑一次就恢复
- 失败日志显示是网络抖动、依赖服务超时
- 环境资源不足导致超时
- 测试数据污染导致断言失败
这些都可能属于误报。
3. 失败模式分类
实际治理中,我建议至少拆成四类:
| 类别 | 常见表现 | 优先处理建议 |
|---|---|---|
| 时序等待问题 | 元素未出现、异步任务未完成 | 显式等待、事件驱动 |
| 数据污染问题 | 并发执行时数据重复、状态残留 | 数据隔离、幂等清理 |
| 环境依赖问题 | 网络波动、第三方接口超时 | mock、隔离依赖、降级 |
| 资源竞争问题 | 并发下 CPU/内存/端口冲突 | 限流、分组、资源池 |
一套实战治理流程
这一版我不从“如何写更好的测试”讲起,而是从如何在现有 CI 中把问题抓出来开始。因为很多团队不是没有测试,而是已经有一堆测试,只是没人知道该先救谁。
步骤 1:先拿到历史执行数据
理想情况下,每次测试执行都至少保留这些字段:
build_idtest_namestatusdurationerror_typetimestamp
我们先用一份样例 CSV 模拟:
build_id,test_name,status,duration,error_type,timestamp
1001,test_login,PASS,1.2,,2025-12-01T10:00:00
1002,test_login,FAIL,1.4,TimeoutError,2025-12-01T11:00:00
1003,test_login,PASS,1.1,,2025-12-01T12:00:00
1001,test_create_order,PASS,2.0,,2025-12-01T10:00:00
1002,test_create_order,PASS,2.1,,2025-12-01T11:00:00
1003,test_create_order,PASS,2.2,,2025-12-01T12:00:00
1001,test_refund,FAIL,3.8,AssertionError,2025-12-01T10:00:00
1002,test_refund,FAIL,3.9,AssertionError,2025-12-01T11:00:00
1003,test_refund,FAIL,4.0,AssertionError,2025-12-01T12:00:00
1001,test_search,PASS,0.8,,2025-12-01T10:00:00
1002,test_search,FAIL,5.6,ConnectionError,2025-12-01T11:00:00
1003,test_search,PASS,0.9,,2025-12-01T12:00:00
1001,test_profile_update,PASS,1.7,,2025-12-01T10:00:00
1002,test_profile_update,FAIL,6.2,TimeoutError,2025-12-01T11:00:00
1003,test_profile_update,PASS,1.8,,2025-12-01T12:00:00
保存为 test_history.csv。
步骤 2:识别脆弱用例
下面这段 Python 可以直接运行,统计哪些用例是“稳定失败”,哪些是“脆弱失败”。
# analyze_flaky.py
import pandas as pd
def classify_test(group: pd.DataFrame) -> str:
statuses = set(group["status"].tolist())
total = len(group)
fail_count = (group["status"] == "FAIL").sum()
fail_rate = fail_count / total if total else 0
if statuses == {"PASS"}:
return "stable_pass"
if statuses == {"FAIL"}:
return "stable_fail"
if "PASS" in statuses and "FAIL" in statuses:
if fail_rate <= 0.5:
return "flaky_low"
return "flaky_high"
return "unknown"
def main():
df = pd.read_csv("test_history.csv")
summary = (
df.groupby("test_name")
.apply(lambda g: pd.Series({
"runs": len(g),
"fail_count": (g["status"] == "FAIL").sum(),
"pass_count": (g["status"] == "PASS").sum(),
"avg_duration": round(g["duration"].mean(), 2),
"top_error_type": g["error_type"].dropna().mode().iloc[0] if not g["error_type"].dropna().empty else "",
"classification": classify_test(g)
}))
.reset_index()
.sort_values(by=["classification", "fail_count"], ascending=[True, False])
)
print("=== 测试稳定性分析结果 ===")
print(summary.to_string(index=False))
flaky = summary[summary["classification"].str.contains("flaky")]
print("\n=== 建议优先治理的脆弱用例 ===")
print(flaky.to_string(index=False) if not flaky.empty else "无")
if __name__ == "__main__":
main()
运行:
python analyze_flaky.py
你会看到类似输出:
=== 测试稳定性分析结果 ===
test_name runs fail_count pass_count avg_duration top_error_type classification
test_login 3 1 2 1.23 TimeoutError flaky_low
test_search 3 1 2 2.43 ConnectionError flaky_low
test_profile_update 3 1 2 3.23 TimeoutError flaky_low
test_create_order 3 0 3 2.10 stable_pass
test_refund 3 3 0 3.90 AssertionError stable_fail
这个结果很关键:
test_refund是稳定失败,优先按真实缺陷排查test_login、test_search、test_profile_update是脆弱用例test_create_order是稳定通过,不要浪费治理精力
步骤 3:把“失败模式”再往下钻一层
只知道它 flaky 还不够,得知道它为什么 flaky。
可以先做一个简单分类器,把错误类型映射到治理方向:
# 可加到 analyze_flaky.py 中
ERROR_CATEGORY_MAP = {
"TimeoutError": "timing_issue",
"ConnectionError": "environment_dependency",
"AssertionError": "logic_or_data_issue",
}
def map_error_category(error_type: str) -> str:
if not isinstance(error_type, str) or not error_type.strip():
return "none"
return ERROR_CATEGORY_MAP.get(error_type, "other")
再做统计:
# 在 main() 中追加
df["error_category"] = df["error_type"].apply(map_error_category)
category_summary = (
df[df["status"] == "FAIL"]
.groupby(["test_name", "error_category"])
.size()
.reset_index(name="count")
.sort_values(by=["test_name", "count"], ascending=[True, False])
)
print("\n=== 失败模式分类 ===")
print(category_summary.to_string(index=False))
这一步虽然简单,但在团队里非常有用。因为你终于可以从“哪个用例红了”切换到“哪类问题最多”。
Mermaid:定位与治理链路
用例状态判断图
stateDiagram-v2
[*] --> StablePass: 连续通过
[*] --> StableFail: 连续失败
[*] --> Flaky: 通过/失败交替出现
Flaky --> TimingIssue: 等待不足
Flaky --> DataIssue: 数据污染
Flaky --> EnvIssue: 环境波动
Flaky --> ResourceIssue: 资源竞争
CI 中的误报处理时序
sequenceDiagram
participant Dev as 开发者
participant CI as CI流水线
participant T as 测试任务
participant A as 稳定性分析器
Dev->>CI: 提交代码
CI->>T: 执行测试
T-->>CI: 初次结果(部分失败)
CI->>A: 查询失败用例历史稳定性
A-->>CI: 返回 flaky 风险评分
alt 高概率脆弱用例
CI->>T: 定向重跑
T-->>CI: 重跑结果
CI-->>Dev: 标记为疑似误报,生成治理工单
else 稳定失败
CI-->>Dev: 阻断合并,要求修复
end
实战代码(可运行)
下面我们做两件事:
- 写一个故意不稳定的测试,模拟 flaky
- 写一个 CI 门禁脚本,减少误报拦截
示例 1:一个典型的脆弱测试
# test_unstable_demo.py
import random
import time
def fetch_async_result():
# 模拟外部系统异步返回,有时快有时慢
delay = random.uniform(0.1, 1.5)
time.sleep(delay)
return {"status": "done", "delay": delay}
def test_async_job_done():
result = fetch_async_result()
# 故意写得很脆弱:把时间耦合进断言
assert result["delay"] < 1.0, f"任务完成过慢: {result['delay']}"
运行几次:
pytest -q test_unstable_demo.py
你会发现它有时过、有时不过。这就是典型的时间阈值型脆弱用例。
示例 2:改造成更稳的写法
更合理的方式,不是断言“必须 1 秒内完成”,而是给业务上允许的等待窗口,并轮询状态。
# test_unstable_demo.py
import random
import time
def fetch_async_result():
delay = random.uniform(0.1, 1.5)
time.sleep(delay)
return {"status": "done", "delay": delay}
def wait_until_done(timeout=2.0, interval=0.2):
start = time.time()
while time.time() - start < timeout:
result = fetch_async_result()
if result["status"] == "done":
return result
time.sleep(interval)
raise TimeoutError("异步任务在超时时间内未完成")
def test_async_job_done_stable():
result = wait_until_done(timeout=2.0, interval=0.2)
assert result["status"] == "done"
这里的改进点是:
- 不把随机耗时直接写死成断言
- 用业务超时替代瞬时速度要求
- 关注“最终状态”,而不是“碰巧某次很快”
这类修改通常能立刻提升稳定性。
示例 3:CI 门禁脚本,降低误报率
很多团队一开始的门禁策略是:有一个失败就直接阻断。
这很简单,但在脆弱用例多时,几乎一定会把误报率打高。
下面做一个简化版门禁脚本:
- 如果失败用例属于历史高风险 flaky,则允许定向重跑 1 次
- 如果重跑通过,则标记为疑似误报,不直接阻断
- 如果是稳定失败,直接阻断
# ci_gate.py
import pandas as pd
import sys
def load_flaky_tests(history_file: str):
df = pd.read_csv(history_file)
result = {}
for test_name, group in df.groupby("test_name"):
statuses = set(group["status"].tolist())
if "PASS" in statuses and "FAIL" in statuses:
fail_rate = (group["status"] == "FAIL").sum() / len(group)
result[test_name] = {
"is_flaky": True,
"fail_rate": round(fail_rate, 2)
}
else:
result[test_name] = {
"is_flaky": False,
"fail_rate": round((group["status"] == "FAIL").sum() / len(group), 2)
}
return result
def evaluate_failed_tests(history_file: str, failed_tests: list[str]):
flaky_db = load_flaky_tests(history_file)
stable_blockers = []
flaky_suspects = []
for test in failed_tests:
meta = flaky_db.get(test, {"is_flaky": False, "fail_rate": 1.0})
if meta["is_flaky"]:
flaky_suspects.append((test, meta["fail_rate"]))
else:
stable_blockers.append((test, meta["fail_rate"]))
return stable_blockers, flaky_suspects
def main():
if len(sys.argv) < 3:
print("用法: python ci_gate.py test_history.csv test_login test_refund")
sys.exit(2)
history_file = sys.argv[1]
failed_tests = sys.argv[2:]
stable_blockers, flaky_suspects = evaluate_failed_tests(history_file, failed_tests)
print("=== CI 门禁分析 ===")
print("稳定失败候选:", stable_blockers)
print("脆弱失败候选:", flaky_suspects)
if stable_blockers:
print("\n结论:存在稳定失败候选,建议直接阻断流水线。")
sys.exit(1)
if flaky_suspects:
print("\n结论:当前失败更像疑似误报,建议触发定向重跑并记录治理任务。")
sys.exit(0)
print("\n结论:无失败或无历史数据,按默认策略处理。")
sys.exit(0)
if __name__ == "__main__":
main()
运行示例:
python ci_gate.py test_history.csv test_login test_search
再试一个包含稳定失败的场景:
python ci_gate.py test_history.csv test_refund
这个脚本当然还很简化,但已经体现出一个治理思路:
- 不是所有失败都一视同仁
- 历史稳定性可以反向喂给 CI 决策
- 误报率下降,靠的是策略分层,不只是重跑
逐步验证清单
如果你准备在团队里落地,建议按下面顺序验证,不要一步上复杂平台。
第一阶段:先有数据
- 测试结果能按用例维度落表
- 至少保留最近 7~14 天历史
- 能区分
PASS/FAIL/SKIP/RETRY - 失败日志里能提取错误类型
第二阶段:先看最脆弱的前 10 个
- 找出最近失败最多且状态摇摆的用例
- 标注失败模式:时序 / 数据 / 环境 / 资源
- 每类至少选 2 个代表样本治理
- 对比治理前后失败次数
第三阶段:再接入 CI 门禁
- 为历史 flaky 用例建立白名单或风险清单
- 只对高风险 flaky 启用定向重跑
- 重跑结果必须被记录,不能“悄悄吞掉”
- 每周统计误报率、重跑收益、真实缺陷命中率
常见坑与排查
这一部分我尽量说得接地气一点,因为很多坑不是理论问题,而是“大家都会这么写”。
坑 1:把 sleep 当万能药
最常见的修复方式是:
import time
time.sleep(5)
它短期可能能让测试变绿,但副作用很大:
- 测试变慢
- 不确定性没消失,只是被掩盖
- 在更慢的环境里还是会失败
建议:优先改成显式等待、轮询条件、事件完成判定。
坑 2:测试数据复用,互相污染
比如多个并发测试都用固定用户名 test_user,结果:
- 一个测试删掉了数据
- 另一个测试还在读取
- 失败表现却像断言问题
排查方法:
- 给测试数据加唯一后缀
- 执行前后打印关键资源 ID
- 检查失败是否只在并发执行时出现
坑 3:把环境抖动当产品缺陷
像 ConnectionError、502、依赖超时这类问题,很多时候不是产品逻辑 bug,而是环境信号差。
建议:
- 给失败分类
- 对外部依赖做 mock 或 contract test
- 不把第三方系统波动全部算到核心回归集里
坑 4:重跑机制被滥用
重跑是工具,不是遮羞布。
如果一个失败用例重跑通过了,不代表它没问题,只代表它更像脆弱失败。
我踩过一个坑:团队把所有失败自动重跑 3 次,结果 dashboard 看起来一片绿,但实际上大量脆弱用例长期没人处理。
正确做法:
- 重跑只对疑似 flaky 的测试启用
- 重跑后的结果要单独统计
- 每周回收“重跑救活次数最多”的用例进行治理
坑 5:样本太少就下结论
某个用例只执行了 2 次,1 次过 1 次失败,不能马上断定它一定 flaky。
这时候只是“可疑”。
建议边界条件:
- 至少收集最近 10 次以上结果再做稳定性判断
- 高频流水线可以取最近 30~50 次
- 低频任务可以拉长时间窗口,但注意版本变化影响
安全/性能最佳实践
稳定性治理不只是测试工程问题,也会影响安全和性能。
安全最佳实践
1. 日志脱敏
测试失败日志里很可能包含:
- token
- 手机号
- 邮箱
- 数据库连接信息
治理平台在收集失败上下文时,要做脱敏处理。尤其是要把日志汇总到统一平台时。
示例:
import re
def mask_sensitive(text: str) -> str:
text = re.sub(r'Bearer\s+[A-Za-z0-9\-\._]+', 'Bearer ***', text)
text = re.sub(r'\b1\d{10}\b', '1**********', text)
text = re.sub(r'[\w\.-]+@[\w\.-]+', '***@***', text)
return text
2. 不要在测试代码里硬编码凭据
错误示范:
API_TOKEN = "prod-secret-token"
建议改成环境变量:
import os
API_TOKEN = os.getenv("API_TOKEN")
if not API_TOKEN:
raise RuntimeError("缺少 API_TOKEN")
性能最佳实践
1. 不要为了稳定而无限拉长超时
超时调大能缓解一部分失败,但会拖慢整体流水线。
建议做法:
- 区分核心回归集和扩展回归集
- 对慢用例单独分组执行
- 监控平均耗时和 P95 耗时,不只看 pass/fail
2. 用定向重跑代替全量重跑
如果 500 个用例里只有 3 个失败,不要全量再跑一遍。
更优策略:
- 只重跑失败用例
- 最多重跑 1 次
- 重跑结果要归档,用于后续计算误报率
3. 资源隔离优于资源堆叠
如果失败源头是资源竞争,单纯加机器不一定有用。
更有效的是:
- 端口隔离
- 测试账号隔离
- 数据库 schema 隔离
- 每批测试固定资源池
一套可执行的治理建议
如果你现在就要推进,我建议按这个优先级:
优先级 A:一周内能见效
- 收集最近 14 天测试历史
- 找出 top 10 脆弱用例
- 给失败日志做简单错误分类
- CI 对已知 flaky 用例启用“定向重跑一次”
优先级 B:一个迭代内落地
- 建立脆弱用例台账:负责人、原因、修复状态
- 把环境问题和脚本问题分开看板
- 将“重跑后恢复”的失败计入误报统计
- 每周复盘误报率变化
优先级 C:长期演进
- 给每个测试生成稳定性评分
- 门禁策略按风险分层,而不是统一阈值
- 引入失败聚类和根因关联分析
- 将 flaky 治理纳入质量 KPI,但不要简单考核“红灯次数”
总结
自动化测试稳定性治理,真正要解决的不是“怎么让 dashboard 更绿”,而是:
- 让失败更可信
- 让 CI 的信号更干净
- 让团队不再依赖盲目重跑
你可以把这件事理解成三句话:
- 先识别脆弱用例,不要一锅端
- 按失败模式治理,不要只会加 sleep
- 把历史稳定性接入 CI,降低误报率
最后给几个很实用的边界建议:
- 如果团队历史数据都没有,先别急着上复杂算法,先把结果存下来
- 如果 flaky 用例比例很高,不要马上启用“全自动放行”,先做人工确认期
- 如果是核心交易、支付、权限链路,即使怀疑是误报,也建议保守处理
- 如果某个测试长期靠重跑恢复,那不是“已经稳定”,而是“技术债已显性化”
稳定性治理不是一次性的清扫,而是一条持续改进链路。只要你能把“失败”从噪音变成信号,CI 的价值就会真正体现出来。