自动化测试中的稳定性治理实战:从脆弱用例识别到失败重试与根因定位
自动化测试做久了,大家都会遇到一个很现实的问题:不是没有测试,而是测试不稳定,导致团队不信测试结果。
我见过不少项目,CI 上一片红,开发和测试第一反应不是修代码,而是先问一句:“这次又是误报吧?”一旦走到这一步,自动化测试的价值基本已经被打了折扣。稳定性治理,真正要解决的就是这个问题:让失败更可信,让波动可观测,让问题能定位。
这篇文章不讲空泛理念,而是按“识别脆弱用例 → 做失败重试 → 建立根因定位链路”的顺序,带你搭一套能落地的稳定性治理方案。
背景与问题
自动化测试中的“不稳定”,通常表现为:
- 同一份代码,测试一会儿过、一会儿不过
- 重跑就绿,隔天又红
- 失败日志看起来都差不多,但每次根因都不一样
- 测试平台里失败很多,真正需要处理的问题却筛不出来
这类问题常被统称为 Flaky Test(脆弱/漂移用例)。它的危害比“偶发失败”大得多:
- 降低团队对测试结果的信任
- 增加排查时间
- 污染发布信号
- 掩盖真实缺陷
很多团队上来就加重试,短期看确实“绿了不少”,但如果没有识别机制和根因定位,重试只是在把噪音藏起来。治理稳定性,不能只靠“多跑几次”。
前置知识与环境准备
本文示例使用 Python,依赖尽量保持简单:
- Python 3.9+
- pytest
- requests
- pytest-rerunfailures(演示重试思路,也会给出自定义实现)
- 一份测试结果历史记录文件(JSON)
安装依赖:
pip install pytest requests pytest-rerunfailures
建议准备一个目录结构:
stable-testing-demo/
├─ app/
│ └─ fake_service.py
├─ tests/
│ ├─ test_api.py
│ └─ test_ui_like.py
├─ tools/
│ ├─ flaky_detector.py
│ └─ failure_classifier.py
└─ history/
└─ test_results.json
核心原理
稳定性治理可以拆成三层:
- 识别层:哪些用例不稳定?
- 控制层:失败后如何有限度重试?
- 诊断层:失败后怎么快速知道是环境、数据、脚本还是产品缺陷?
如果把它画成一条链路,大概是这样:
flowchart LR
A[测试执行] --> B[结果采集]
B --> C[脆弱用例识别]
C --> D[失败重试策略]
D --> E[失败日志与上下文聚合]
E --> F[根因分类]
F --> G[告警/修复/隔离]
1. 脆弱用例识别的核心指标
判断一个用例是不是脆弱,不能只看“失败过没有”,而要看历史表现:
- 失败率:最近 N 次执行失败占比
- 波动率:相邻执行状态变化频繁程度
- 重试成功率:失败后重试通过的比例
- 环境相关性:是否只在某些机器、时间段、数据集失败
- 耗时异常:失败前耗时是否显著升高
一个很实用的经验是:
“失败后重跑经常通过”的用例,不一定是好消息,反而大概率是脆弱用例。
2. 失败重试不是“无脑再跑”
重试的目标不是美化报表,而是区分:
- 瞬时噪音:网络抖动、资源竞争、短时超时
- 确定性失败:代码逻辑错误、断言错误、接口契约变更
所以重试要遵循几个原则:
- 只对特定失败类型重试
- 限制次数
- 记录每次失败上下文
- 最终保留首错信息和重试轨迹
- 不能把重试后的成功当成“完全成功”
3. 根因定位需要“结构化失败信息”
排查慢,很多时候不是因为问题难,而是因为日志太散。
根因定位至少要采集:
- 测试名
- 执行时间
- 执行节点/容器
- 依赖服务状态
- 请求响应摘要
- 异常栈
- 截图/页面状态(UI)
- 重试次数
- 环境变量版本信息
一个简单的失败分类模型:
classDiagram
class FailureEvent {
+test_name
+error_type
+message
+node
+duration
+retry_count
+timestamp
}
class RootCauseClassifier {
+classify(event)
}
class Categories {
<<enumeration>>
ENVIRONMENT
DATA
SCRIPT
PRODUCT_BUG
UNKNOWN
}
RootCauseClassifier --> FailureEvent
RootCauseClassifier --> Categories
稳定性治理的落地步骤
这里我建议用一个很实用的四步法。
第一步:先建立“可观测性”,别急着优化
很多团队一开始就想“怎么把通过率提上去”,但如果你还不知道失败都来自哪里,优化往往会打偏。
建议先记录每次执行结果:
- 用例名
- 是否通过
- 执行耗时
- 错误类型
- 执行环境
- 是否重试
- 重试后是否成功
第二步:从历史数据中识别脆弱用例
可以按最近 20~50 次执行来计算一个脆弱分数。比如:
- 失败率 > 10%
- 状态切换次数 > 3
- 重试成功率 > 50%
满足两项以上,就标为高风险脆弱用例。
第三步:对“可恢复失败”做有限重试
适合重试的场景通常有:
- 网络读超时
- 服务瞬时 502/503
- UI 元素短暂无响应
- 异步任务尚未完成
不适合重试的场景:
- 断言值明显错误
- 参数校验失败
- 数据库约束异常
- 接口返回稳定的业务失败
第四步:建立分类与止血机制
你不能指望每个失败都人工看日志。至少应该做到:
- 自动把失败归到几个大类
- 对脆弱用例打标签
- 高波动用例进入隔离池
- 环境问题触发平台告警
- 真正的产品缺陷单独统计
Mermaid:失败重试与根因定位时序
下面这张图比较贴近 CI 流水线里的真实过程:
sequenceDiagram
participant CI as CI流水线
participant T as 测试执行器
participant R as 重试策略器
participant L as 日志聚合器
participant C as 根因分类器
CI->>T: 触发测试执行
T-->>CI: 首次失败
T->>L: 上传首错日志/上下文
T->>R: 判断是否允许重试
alt 可重试
R-->>T: 执行重试
T-->>L: 上传重试结果
T->>C: 提交失败事件
C-->>CI: 输出根因分类
else 不可重试
T->>C: 提交失败事件
C-->>CI: 输出根因分类
end
实战代码(可运行)
下面用一个简单但完整的示例,把识别、重试、分类串起来。
1. 构造一个“偶发失败”的服务
app/fake_service.py
import random
import time
class TransientError(Exception):
pass
class BusinessError(Exception):
pass
def unstable_fetch(user_id: int) -> dict:
time.sleep(0.1)
n = random.random()
# 20% 概率瞬时失败,适合重试
if n < 0.2:
raise TransientError("upstream timeout")
# 10% 概率业务失败,不适合重试
if n < 0.3:
raise BusinessError("user status invalid")
return {"user_id": user_id, "status": "ok"}
这个例子故意模拟了两种失败:
TransientError:瞬时问题,可重试BusinessError:业务问题,不可重试
2. 在测试中实现“有条件重试”
tests/test_api.py
from app.fake_service import unstable_fetch, TransientError, BusinessError
def retry_call(func, retries=2, allowed_exceptions=(Exception,)):
errors = []
for attempt in range(retries + 1):
try:
return func(), errors
except allowed_exceptions as e:
errors.append(f"attempt={attempt + 1}, error={type(e).__name__}: {e}")
if attempt == retries:
raise
except Exception:
# 不在允许重试范围内,直接抛出
raise
def test_fetch_user_profile():
def action():
result = unstable_fetch(1001)
assert result["status"] == "ok"
return result
result, errors = retry_call(
action,
retries=2,
allowed_exceptions=(TransientError,)
)
print("retry_logs:", errors)
assert result["user_id"] == 1001
def test_business_error_should_fail_fast():
def action():
result = unstable_fetch(1002)
assert result["status"] == "ok"
return result
try:
result, errors = retry_call(
action,
retries=2,
allowed_exceptions=(TransientError,)
)
assert result["user_id"] == 1002
except BusinessError:
# 业务错误应快速失败,不继续重试
assert True
这个实现有几个要点:
- 只对
TransientError重试 - 业务异常直接失败
- 保留每次重试日志,便于后续定位
运行:
pytest -s tests/test_api.py
3. 基于历史结果识别脆弱用例
假设我们有一份历史执行结果:history/test_results.json
[
{"test_name": "test_fetch_user_profile", "status": "passed"},
{"test_name": "test_fetch_user_profile", "status": "failed"},
{"test_name": "test_fetch_user_profile", "status": "passed"},
{"test_name": "test_fetch_user_profile", "status": "failed"},
{"test_name": "test_fetch_user_profile", "status": "passed"},
{"test_name": "test_business_error_should_fail_fast", "status": "failed"},
{"test_name": "test_business_error_should_fail_fast", "status": "failed"},
{"test_name": "test_business_error_should_fail_fast", "status": "failed"}
]
编写识别脚本:tools/flaky_detector.py
import json
from collections import defaultdict
def calc_flaky_score(records):
grouped = defaultdict(list)
for r in records:
grouped[r["test_name"]].append(r["status"])
result = []
for test_name, statuses in grouped.items():
total = len(statuses)
fail_count = sum(1 for s in statuses if s == "failed")
fail_rate = fail_count / total if total else 0
switch_count = 0
for i in range(1, len(statuses)):
if statuses[i] != statuses[i - 1]:
switch_count += 1
flaky = fail_rate > 0.1 and switch_count >= 2
result.append({
"test_name": test_name,
"total_runs": total,
"fail_rate": round(fail_rate, 2),
"switch_count": switch_count,
"is_flaky": flaky
})
return sorted(result, key=lambda x: (-x["is_flaky"], -x["fail_rate"]))
if __name__ == "__main__":
with open("history/test_results.json", "r", encoding="utf-8") as f:
records = json.load(f)
for item in calc_flaky_score(records):
print(item)
运行:
python tools/flaky_detector.py
你会看到 test_fetch_user_profile 更像是脆弱用例,而连续稳定失败的 test_business_error_should_fail_fast 反而更像真实缺陷。
这点很关键:
会抖动的失败,优先治理稳定性;稳定复现的失败,优先排查产品或脚本缺陷。
4. 自动做失败根因分类
tools/failure_classifier.py
def classify_failure(error_type: str, message: str) -> str:
text = f"{error_type} {message}".lower()
if "timeout" in text or "connection" in text or "502" in text or "503" in text:
return "ENVIRONMENT"
if "not found test data" in text or "unique constraint" in text or "foreign key" in text:
return "DATA"
if "element not found" in text or "stale element" in text or "assert" in text:
return "SCRIPT"
if "status invalid" in text or "business" in text:
return "PRODUCT_BUG"
return "UNKNOWN"
if __name__ == "__main__":
samples = [
("TransientError", "upstream timeout"),
("IntegrityError", "unique constraint violated"),
("AssertionError", "assert 200 == 500"),
("BusinessError", "user status invalid")
]
for error_type, message in samples:
category = classify_failure(error_type, message)
print(error_type, "=>", category)
运行:
python tools/failure_classifier.py
这个分类器很朴素,但已经能解决很多第一层筛选问题。实际生产中你可以进一步增强:
- 引入正则规则库
- 结合失败堆栈
- 结合节点监控数据
- 结合截图和 DOM 快照
- 最后再接机器学习分类
5. 用 pytest 插件能力接入结果采集
如果你希望把失败事件自动输出成结构化数据,可以使用 pytest_runtest_makereport。
tests/conftest.py
import json
import os
from datetime import datetime
RESULT_FILE = "history/runtime_results.json"
def pytest_runtest_makereport(item, call):
if call.when != "call":
return
os.makedirs("history", exist_ok=True)
record = {
"test_name": item.name,
"status": "passed" if call.excinfo is None else "failed",
"timestamp": datetime.utcnow().isoformat() + "Z"
}
if call.excinfo is not None:
record["error_type"] = call.excinfo.type.__name__
record["message"] = str(call.excinfo.value)
records = []
if os.path.exists(RESULT_FILE):
with open(RESULT_FILE, "r", encoding="utf-8") as f:
try:
records = json.load(f)
except json.JSONDecodeError:
records = []
records.append(record)
with open(RESULT_FILE, "w", encoding="utf-8") as f:
json.dump(records, f, ensure_ascii=False, indent=2)
这样每次 pytest 执行后,都会沉淀一份结构化结果。后续不管是做报表、脆弱识别还是根因分类,都有基础数据可用。
逐步验证清单
如果你准备在团队里真正推这套方案,我建议按下面的顺序验证,不要一步到位:
第 1 阶段:先看见问题
- 所有测试执行结果能结构化落盘
- 能统计每个用例最近 20 次通过/失败情况
- 能区分“稳定失败”和“波动失败”
第 2 阶段:再做失败控制
- 只对白名单异常重试
- 重试次数不超过 2~3 次
- 保留首错日志
- 报表中显示“重试后通过”
第 3 阶段:最后做根因治理
- 失败自动分类到环境/数据/脚本/产品
- 高波动用例进入专项治理列表
- 环境类失败可关联监控
- 脚本类失败能回溯到具体断言和页面状态
常见坑与排查
这部分我尽量讲一些真实容易踩的坑。
1. 把重试当稳定性治理本身
这是最常见的误区。
表现是:
- 通过率很高
- 但平均执行时长越来越长
- 重试次数越来越多
- 团队还是不信结果
原因在于:你只是让 CI 更绿了,但没减少真实波动。
排查建议:
- 单独统计“首轮通过率”和“重试后通过率”
- 如果某些用例重试后通过占比很高,优先治理它们
- 不要只看最终通过率
2. 所有异常都重试
这会把真正的产品缺陷也拖成“偶发问题”。
例如:
AssertionErrorValueError- 业务状态错误
- 数据约束错误
这些通常不该重试。
排查建议:
- 建立异常白名单,而不是黑名单
- 优先允许网络、超时、临时不可用类异常重试
- 断言失败默认不重试,除非你明确知道它属于异步一致性场景
3. 忽略测试数据污染
很多“脆弱用例”本质上不是环境问题,而是数据没隔离。
典型现象:
- 单跑通过,串行也通过
- 并发跑就失败
- 换环境失败模式不同
排查建议:
- 为每次执行生成唯一测试数据
- 清理前置状态
- 避免多个用例共享账号、订单、库存等资源
4. UI 自动化里的等待策略错误
我当时踩过一个坑:页面元素确实会出现,但测试用了固定 sleep(2)。环境快的时候没问题,环境稍慢就失败,导致一堆“偶发红”。
更好的做法:
- 用显式等待替代固定睡眠
- 等待“状态达成”,不要等待“时间过去”
- 失败时保存 DOM、截图和浏览器 console
5. 日志很多,但没有上下文
只看到一个异常堆栈,往往不够。
比如接口测试失败,如果不知道:
- 请求参数
- 响应体
- 调用节点
- 上游依赖状态
那排查效率会很低。
排查建议:
- 为失败事件补充上下文字段
- 日志要能按
test_name + build_id + retry_index聚合 - 首错信息优先保留,不要被最后一次重试覆盖
安全/性能最佳实践
稳定性治理不只是“测得准”,还要“跑得安全、跑得经济”。
安全最佳实践
1. 日志脱敏
失败日志里常常会带:
- token
- 手机号
- 邮箱
- 身份证号
- cookie
- 数据库连接串
建议在采集层统一脱敏。
示例:
import re
def mask_sensitive(text: str) -> str:
text = re.sub(r'Bearer\s+[A-Za-z0-9\-_\.]+', 'Bearer ***', text)
text = re.sub(r'1[3-9]\d{9}', '***********', text)
text = re.sub(r'[\w\.-]+@[\w\.-]+', '***@***', text)
return text
2. 控制失败附件大小
截图、HAR、页面源码、接口报文一多,存储会迅速膨胀。
建议:
- 只对失败用例保存附件
- 对重试过程只保留首错和末错
- 设置日志保留周期
性能最佳实践
1. 限制重试开销
重试是有成本的,特别是在大规模回归中。
建议:
- 每个用例最多重试 1~2 次
- 仅对高价值关键路径开启重试
- 对长期脆弱用例单独治理,不要无限续命
2. 并发下控制资源争用
测试并发度高时,CPU、网络、测试账号池、浏览器实例都可能成为不稳定源。
建议:
- 监控执行节点负载
- 限制单节点并发数
- 对共享资源做配额隔离
- 将环境问题与脚本问题分开统计
3. 做分层治理
不是所有测试都值得投入同样治理成本。
可按优先级分层:
- P0:发布阻断用例,重点治理,允许精细重试
- P1:核心回归用例,监控波动趋势
- P2:普通覆盖用例,波动高时可暂时隔离
一个可落地的治理策略模板
如果你要在团队里推,我建议从下面这份策略起步:
1. 每次测试执行必须产出结构化结果
2. 每周统计一次脆弱用例 Top N
3. 仅对白名单异常进行最多 2 次重试
4. 报表中区分:
- 首次通过
- 重试后通过
- 最终失败
5. 失败自动分类:
- 环境
- 数据
- 脚本
- 产品
- 未知
6. 连续两周高波动的用例进入专项治理
7. 无法短期修复的脆弱用例,隔离但不删除
这里有个边界条件要强调:
如果你的测试环境本身经常不可用,那么稳定性治理的重点应该先放在环境治理,而不是一味优化测试脚本。
总结
自动化测试的稳定性治理,本质上不是“把红灯变绿”,而是建立一套可信、可观测、可诊断的测试体系。
你可以记住这三件事:
-
先识别脆弱用例
不要只看失败次数,要看历史波动和重试行为。 -
再做有限重试
只对可恢复异常重试,保留首错信息,别掩盖真实缺陷。 -
最后做根因定位
用结构化数据把失败归因到环境、数据、脚本或产品,缩短排查链路。
如果你现在就想开始落地,我建议第一周只做一件事:把测试结果结构化记录下来。
有了数据,后面的脆弱识别、重试策略和根因分类才有抓手。没有数据,所谓治理很容易变成拍脑袋。
稳定性治理不是一蹴而就的,但只要方向对,团队会很快感受到变化:CI 更可信,报警更少,排查更快,自动化测试终于不再只是“看起来很美”。