自动化测试中的稳定性治理实战:从脆弱用例识别到 Flaky Test 持续修复体系搭建
自动化测试做久了,大家都会碰到一种很烦的情况:代码没改,测试却红了;重跑一下,又绿了。
这类测试通常被称为 Flaky Test。它的危险不在于“偶尔失败”,而在于它会慢慢腐蚀团队对测试结果的信任:开发开始习惯性点 re-run,CI 红灯也没人第一时间看,最后自动化测试变成“形式上有、实际上没人信”。
这篇文章我会从一个更偏“治理”的角度来讲,不只停留在“如何修一个 flaky case”,而是带你从:
- 识别脆弱用例
- 建立分类和优先级
- 做最小可行修复
- 搭持续发现、持续修复、持续度量的体系
一步步搭起一套可落地的方法。
背景与问题
为什么 Flaky Test 比普通失败更难处理?
普通失败通常有明确因果链:
- 代码改坏了
- 断言不成立
- 用例稳定复现
而 Flaky Test 的特点是:
- 非确定性
- 复现概率低
- 受环境、时间、并发、依赖状态影响
- 经常“重跑通过”
所以它比功能缺陷更像一种“系统噪音源”。如果不治理,常见后果是:
- CI 失败率升高,发布节奏被拖慢
- 开发对红灯麻木
- 测试结果可信度下降
- 真缺陷被淹没在随机失败里
- 排查成本持续升高
Flaky Test 常见来源
从实战看,脆弱用例大致可以分为这几类:
-
时间相关
- 固定
sleep - 依赖系统时间
- 时区/夏令时问题
- 固定
-
异步与并发相关
- 任务尚未完成就开始断言
- 多线程共享状态污染
- 执行顺序不稳定
-
环境依赖
- 网络抖动
- 外部服务不可用
- 数据库残留脏数据
-
测试设计问题
- 用例之间共享数据
- 断言过度依赖 UI 文案、顺序、随机值
- 选择器脆弱
-
资源竞争
- 端口冲突
- 文件锁
- CPU/内存波动导致超时
一个常见误区
很多团队会说:“Flaky Test 没法彻底消灭,只能忍。”
这话只对一半。确实不能保证绝对为零,但完全可以把它从“随缘处理”变成“可观测、可分级、可持续压降”的工程问题。
前置知识与环境准备
本文示例使用 Python,原因很简单:容易复现不稳定场景,也方便展示治理脚本。
环境准备
python -m venv venv
source venv/bin/activate
pip install pytest pytest-rerunfailures
项目结构如下:
project/
├─ app/
│ └─ service.py
├─ tests/
│ ├─ test_stable.py
│ ├─ test_flaky_random.py
│ ├─ test_flaky_async.py
│ └─ test_flaky_shared_state.py
└─ tools/
└─ flaky_detector.py
核心原理
稳定性治理不是“见一个修一个”,而是一个闭环。先给出整体图,再展开。
flowchart TD
A[测试执行] --> B[采集结果与日志]
B --> C[识别疑似 Flaky Test]
C --> D[按类型归因]
D --> E[制定修复策略]
E --> F[回归验证]
F --> G[纳入指标看板]
G --> H[持续巡检与治理]
1. Flaky Test 的判定,不只看“失败过”
一个用例失败一次,不代表它一定 flaky。更合理的判断方式是:
- 同一版本代码下重复执行
- 有时通过,有时失败
- 失败原因不稳定或与代码改动无直接关系
可抽象成一个简单指标:
Flaky Rate = 在相同代码版本下,既出现 Pass 又出现 Fail 的执行批次 / 总执行批次
这跟普通失败的区别是:
- 普通失败:持续 Fail
- Flaky:Fail/Pass 混杂出现
2. 用例脆弱性识别的核心维度
我建议把“脆弱性识别”拆成 4 个维度:
| 维度 | 关注点 | 典型信号 |
|---|---|---|
| 历史结果 | 是否频繁重跑后通过 | 同一用例近 7 天内多次 Pass/Fail 交替 |
| 执行时长 | 是否接近超时边界 | P95 时长明显升高 |
| 依赖复杂度 | 是否依赖外部系统 | 网络、数据库、缓存、消息队列 |
| 测试设计质量 | 是否隔离、是否可重复 | 共享状态、硬编码 sleep、弱断言 |
3. 治理不是只修代码,还要修流程
一个成熟的体系至少有三层:
classDiagram
class Detection {
+collect_ci_results()
+rerun_suite()
+compute_flaky_rate()
+mark_suspects()
}
class Classification {
+by_timeout()
+by_randomness()
+by_shared_state()
+by_external_dependency()
}
class Remediation {
+fix_test_code()
+improve_env_isolation()
+quarantine_if_needed()
+verify_and_close()
}
Detection --> Classification
Classification --> Remediation
- Detection:先发现问题
- Classification:明确是哪一类问题
- Remediation:按类型修,不是一律
rerun
4. “重跑”可以用于识别,不能作为长期修复
CI 中配置失败自动重跑,短期能止血,但不能当最终方案。
因为它会带来两个副作用:
- 隐藏真实不稳定性
- 拉长流水线时长
我一般建议:
- 识别阶段:允许有限重跑,收集数据
- 治理阶段:重跑仅作辅助,不作通过标准
- 稳定后:逐步降低重跑次数
逐步验证清单
在开始修复前,可以先用这份清单快速筛查:
- 这个失败能在相同代码版本下重复出现吗?
- 重跑后是否容易通过?
- 是否使用了固定
sleep? - 是否依赖系统当前时间?
- 是否依赖外部服务、网络、数据库残留数据?
- 用例之间是否共享了全局状态?
- 断言是否过于依赖顺序、随机值、弱稳定字段?
- 失败是否集中出现在高负载时段?
- 是否存在并行执行冲突?
- 是否有足够的日志、截图、trace 方便定位?
实战代码(可运行)
下面我故意构造几个常见的 flaky 场景,然后演示怎么识别和修。
示例一:随机数导致的脆弱用例
业务代码
# app/service.py
import random
import threading
import time
shared_cache = []
def maybe_discount():
# 模拟某个依赖了随机策略的逻辑
return "DISCOUNT" if random.random() > 0.2 else "NO_DISCOUNT"
def async_job(result_container):
delay = random.uniform(0.05, 0.2)
time.sleep(delay)
result_container["done"] = True
def append_shared_data(item):
shared_cache.append(item)
def clear_shared_data():
shared_cache.clear()
脆弱测试
# tests/test_flaky_random.py
from app.service import maybe_discount
def test_should_always_discount():
assert maybe_discount() == "DISCOUNT"
这个测试的问题非常明显:它把随机行为当成确定行为断言了。
正确修法
如果你的目的是测“返回值属于合法集合”,就应该这么写:
# tests/test_flaky_random.py
from app.service import maybe_discount
def test_discount_result_should_be_valid():
assert maybe_discount() in {"DISCOUNT", "NO_DISCOUNT"}
如果你的目的是测某个随机分支逻辑,那就要控制随机源,而不是赌概率。比如用 mock:
# tests/test_stable.py
from unittest.mock import patch
from app.service import maybe_discount
def test_should_return_discount_when_random_gt_threshold():
with patch("app.service.random.random", return_value=0.9):
assert maybe_discount() == "DISCOUNT"
def test_should_return_no_discount_when_random_le_threshold():
with patch("app.service.random.random", return_value=0.1):
assert maybe_discount() == "NO_DISCOUNT"
示例二:异步任务 + 固定 sleep
这是我见过最常见的一类 flaky:
测试里写个 sleep(1),心想“应该够了吧”。结果 CI 上偶发超时。
脆弱测试
# tests/test_flaky_async.py
import time
from app.service import async_job
def test_async_job_done_with_fixed_sleep():
result = {"done": False}
async_job(result)
time.sleep(0.05)
assert result["done"] is True
上面这个例子甚至还有一个隐藏问题:async_job 本身是同步调用,但很多真实项目里,测试逻辑会把异步概念混在一起,最后靠 sleep 硬等。
我们更真实地改一下,用线程模拟异步:
# tests/test_flaky_async.py
import threading
import time
from app.service import async_job
def test_async_job_done_with_fixed_sleep():
result = {"done": False}
t = threading.Thread(target=async_job, args=(result,))
t.start()
time.sleep(0.05)
assert result["done"] is True
这个测试会随机失败,因为任务耗时是 0.05 ~ 0.2s。
正确修法:显式等待条件
# tests/test_stable.py
import threading
import time
from app.service import async_job
def wait_until(predicate, timeout=1.0, interval=0.01):
start = time.time()
while time.time() - start < timeout:
if predicate():
return True
time.sleep(interval)
return False
def test_async_job_done_with_polling():
result = {"done": False}
t = threading.Thread(target=async_job, args=(result,))
t.start()
assert wait_until(lambda: result["done"] is True, timeout=1.0)
t.join()
这里的关键点是:
- 不用固定
sleep - 等待状态达成
- 有明确超时边界
示例三:共享状态污染
脆弱测试
# tests/test_flaky_shared_state.py
from app.service import append_shared_data, clear_shared_data, shared_cache
def test_append_item_a():
append_shared_data("A")
assert shared_cache == ["A"]
def test_append_item_b():
append_shared_data("B")
assert shared_cache == ["B"]
如果这两个测试按不同顺序执行,就会互相污染。
正确修法:每个用例隔离状态
# tests/test_stable.py
import pytest
from app.service import append_shared_data, clear_shared_data, shared_cache
@pytest.fixture(autouse=True)
def reset_shared_cache():
clear_shared_data()
yield
clear_shared_data()
def test_append_item_a_isolated():
append_shared_data("A")
assert shared_cache == ["A"]
def test_append_item_b_isolated():
append_shared_data("B")
assert shared_cache == ["B"]
这类问题在 UI 自动化、接口自动化里也很常见,比如:
- 同一个账号被多个用例复用
- 测试订单没有清理
- 共享数据库记录没有隔离
本质上都一样:测试之间不独立。
构建一个简单的 Flaky Test 检测脚本
下面这个脚本会重复运行某个测试文件多次,然后统计哪些用例在同一轮次中既通过又失败。
# tools/flaky_detector.py
import subprocess
import json
import re
from collections import defaultdict
TEST_TARGET = "tests"
RUNS = 10
def run_pytest():
cmd = ["pytest", TEST_TARGET, "-q"]
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode, result.stdout + "\n" + result.stderr
def parse_failed_cases(output):
failed = set()
for line in output.splitlines():
# 兼容 pytest 简短输出
m = re.search(r"FAILED\s+([^\s]+::[^\s]+)", line)
if m:
failed.add(m.group(1))
return failed
def main():
case_stats = defaultdict(lambda: {"pass": 0, "fail": 0})
for i in range(RUNS):
code, output = run_pytest()
failed_cases = parse_failed_cases(output)
# 通过收集所有测试标识更稳,这里做简化处理
print(f"Run {i+1}/{RUNS}, exit_code={code}")
# 简单做法:从输出中提取所有 case 节点
all_cases = set(re.findall(r"([^\s]+::test_[^\s]+)", output))
for case in all_cases:
if case in failed_cases:
case_stats[case]["fail"] += 1
else:
case_stats[case]["pass"] += 1
suspects = {}
for case, stat in case_stats.items():
if stat["pass"] > 0 and stat["fail"] > 0:
suspects[case] = stat
print("\n=== Suspected Flaky Tests ===")
print(json.dumps(suspects, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
运行方式:
python tools/flaky_detector.py
如果某个用例结果类似这样:
{
"tests/test_flaky_async.py::test_async_job_done_with_fixed_sleep": {
"pass": 4,
"fail": 6
}
}
那它就是非常典型的疑似 flaky。
从“单个修复”到“持续治理体系”
只修几个失败用例,价值有限。更重要的是形成制度化闭环。
一套实用流程
sequenceDiagram
participant Dev as 开发
participant CI as CI流水线
participant Detector as Flaky检测器
participant Owner as 用例负责人
participant Board as 治理看板
Dev->>CI: 提交代码触发测试
CI->>Detector: 上报测试结果
Detector->>Detector: 重复执行/计算波动率
Detector->>Owner: 标记疑似Flaky
Owner->>Owner: 分类、修复、补日志
Owner->>CI: 提交修复
CI->>Board: 更新稳定性指标
建议落地的治理规则
1. 引入“疑似 flaky”状态,而不是二元判断
不要一上来就说“这个用例就是 flaky”。可以设置状态机:
stateDiagram-v2
[*] --> Stable
Stable --> SuspectedFlaky: 多次波动
SuspectedFlaky --> ConfirmedFlaky: 重复执行复现
ConfirmedFlaky --> InFix: 指派修复
InFix --> Stable: 修复验证通过
InFix --> Quarantined: 短期无法修复
Quarantined --> InFix: 重新纳入治理
这样做的好处是:
- 减少误判
- 方便追踪治理进度
- 能做指标分层统计
2. 给用例绑定负责人
没有 owner,Flaky Test 很容易变成“大家都知道,但没人动”。
建议最少做到:
- 用例归属到模块
- 模块归属到负责人
- flaky 超过阈值自动建单
3. 设置隔离区(Quarantine),但要有退出机制
有些脆弱用例短期确实修不了,比如依赖第三方环境。
这时可以先从主发布门禁中隔离,避免持续污染流水线。
但必须满足:
- 隔离有时限
- 隔离用例单独跑
- 有看板统计数量和滞留时长
否则隔离区最后会变成“测试坟场”。
常见坑与排查
下面这些坑,基本是稳定性治理里的高频项。
坑 1:把失败都归咎于环境
很多团队一看到测试不稳定,就说“CI 机器不行”。
环境当然会导致问题,但环境只是放大器,不一定是根因。
排查顺序建议:
- 先看是否固定
sleep - 再看是否共享状态
- 再看是否依赖随机值/时间
- 最后再查环境资源、网络、容器负载
坑 2:自动重跑掩盖问题
如果流水线配置成失败自动重跑 3 次,并且最终通过就算成功,那会直接导致:
- flaky 被掩埋
- 没有治理动力
- 测试时长翻倍
更合理的做法是:
- 重跑可以有
- 但要记录“首次失败、重跑通过”
- 这种结果应该进入 flaky 指标
坑 3:日志不够,根本没法定位
我踩过一个坑:某接口测试偶发超时,但日志只有一句 AssertionError。最后排查花了两天。
后来补齐这些信息后,定位效率明显提高:
- 请求参数
- 响应摘要
- 执行时长
- 线程/进程信息
- 环境标识
- 重试次数
- 失败时截图/trace
坑 4:并行执行放大共享资源问题
单机串行跑全绿,并行跑就随机红,十有八九是:
- 共享账号
- 共享数据库记录
- 共享端口
- 临时文件重名
排查要点:
- 给每个 worker 独立数据命名空间
- 使用随机但可追踪的 test data id
- 避免全局单例可变状态
坑 5:断言写得太“紧”
例如:
- 精确断言文案全部内容
- 断言列表顺序固定
- 断言接口耗时一定小于非常小的阈值
- 断言动态字段完全一致
这类断言不是“严格”,而是“脆”。
安全/性能最佳实践
稳定性治理不只是质量问题,也会和安全、性能直接相关。
安全最佳实践
1. 不要在日志里打印敏感信息
为了排查 flaky,很多人会把请求/响应全打出来。但如果里面有:
- Token
- 用户手机号
- 身份证号
- 密码
- Cookie
那就有安全风险。
建议做日志脱敏:
# tools/log_mask.py
def mask_sensitive(text: str) -> str:
if not text:
return text
text = text.replace("Bearer secret-token", "Bearer ***")
text = text.replace("13800138000", "138****8000")
return text
2. 测试环境账号权限最小化
不要为了方便,给自动化测试一个超级管理员账号。
否则测试脚本一旦失控,可能误删大量数据。
3. 隔离测试数据与生产数据
听起来像常识,但现实里真有人把回归脚本打到了生产库。
至少要做到:
- 环境变量明确区分
- 数据库连接串加白名单校验
- 高危环境默认禁止 destructive 操作
性能最佳实践
1. 不要无限重试
等待条件时,应该有:
- timeout
- interval
- 明确失败信息
错误示例:
# bad example
while not done:
pass
正确示例:
# good example
import time
def wait_until(predicate, timeout=2.0, interval=0.05):
start = time.time()
while time.time() - start < timeout:
if predicate():
return True
time.sleep(interval)
return False
2. 把“稳定性成本”指标化
建议至少看这几个指标:
- 测试总失败率
- 首次失败后重跑通过率
- 疑似 flaky 用例数
- 隔离区用例数
- 平均修复时长
- CI 平均耗时
3. 分层运行测试
不是所有测试都该进主门禁。建议按层拆分:
- L1:纯单元测试,必须快且稳定
- L2:集成测试,控制外部依赖
- L3:端到端测试,数量少但覆盖关键链路
端到端测试天然更容易 flaky,所以不要把大量不稳定的大而全用例都堆进主干流水线。
一套可执行的治理落地建议
如果你准备在团队里推动这件事,我建议从下面这套“轻量版”开始,别一开始就搞太重。
第 1 步:先量化,不争论
先收集两周数据:
- 哪些用例首次失败后重跑通过
- 哪些模块 flaky 最多
- 哪些失败类型最常见
有了数据,推动治理会容易很多。
第 2 步:建立 Top N 名单
按影响排序,不要平均用力。优先处理:
- 主干门禁里最常失败的用例
- 核心业务链路相关用例
- 修复成本低但收益高的问题
第 3 步:修测试代码,不是只加重试
优先修复方向:
- 去掉固定
sleep - 清理共享状态
- 控制随机源和时间源
- 替换脆弱选择器/断言
- 补充日志与 trace
第 4 步:建立隔离和回归机制
短期修不了的:
- 进 quarantine
- 单独看板
- 每周清理
第 5 步:把稳定性纳入质量指标
比如:
- 每个模块每月 flaky 新增数
- 已确认 flaky 平均关闭时长
- 发布门禁首次通过率
只有进入指标,治理才会持续。
总结
Flaky Test 的本质不是“偶发失败”,而是测试系统失去确定性。
而稳定性治理的目标,也不是追求“永不失败”,而是让失败变得:
- 可解释
- 可复现
- 可修复
- 可度量
你可以把本文的方法浓缩成一句话:
先识别脆弱用例,再按类型归因修复,最后用数据和流程把治理固化下来。
如果你现在就想开始,我建议先做三件事:
- 跑一轮重复执行,找出 Pass/Fail 波动最大的 Top 10 用例
- 优先清理固定 sleep、共享状态、随机依赖
- 把“首次失败后重跑通过”纳入 CI 指标,而不是当成没事发生
边界条件也要说清楚:
- 如果你的测试高度依赖第三方不可控环境,短期不可能做到完全稳定
- 如果团队没有 owner 机制,治理很容易半途而废
- 如果只靠 rerun,不修测试设计,稳定性只会越来越差
真正有效的治理,往往不是某个高级工具,而是一套可重复执行的工程纪律。
把这套纪律建立起来,自动化测试才会重新变成团队能信、敢依赖的质量防线。