自动化测试中的稳定性治理实战:从用例脆弱性分析到 Flaky Test 持续收敛
自动化测试跑得多了,团队几乎一定会碰到一个很烦的问题:同一份代码、同一套测试,今天过、明天挂、重跑又过。这类测试我们通常叫它 Flaky Test。
很多团队对 Flaky Test 的处理方式非常“务实”——失败了先重跑,重跑过了就当没事。但时间久了,代价会慢慢显现:
- CI 结果失去可信度
- 开发对测试报警麻木
- 回归时间越来越长
- 真缺陷被“噪音”淹没
- 团队开始怀疑自动化测试的价值
这篇文章我会按“能落地”的方式,带你从 用例脆弱性分析 入手,建立一套 Flaky Test 持续收敛机制。重点不是讲概念,而是讲怎么做、怎么验证、怎么避免一边治理一边制造新的不稳定。
背景与问题
先说结论:Flaky Test 本质上不是“测试偶尔失败”,而是“测试结果对非业务因素敏感”。
这些非业务因素可能包括:
- 时间:依赖当前时间、时区、定时任务触发点
- 并发:共享状态、异步未收敛、竞态条件
- 环境:网络抖动、外部服务不稳定、机器性能差异
- 数据:脏数据、随机数据碰撞、用例间互相污染
- 顺序:测试执行顺序改变后结果不同
- UI:元素渲染延迟、动画、遮挡、浏览器差异
典型症状
你可能已经见过这些场景:
- 本地跑通过,CI 上失败
- 单独跑通过,整套一起跑失败
- 首次失败,第二次重跑通过
- 只在某个时间段失败,比如零点、月底、夏令时切换
- 只在资源紧张时失败,比如高并发流水线
为什么“重跑”不能算治理
重跑在某些时候可以作为“止血”,但不能作为“治理”。原因很简单:
- 它掩盖了真实不稳定源头
- 它拉长了反馈周期
- 它让失败信号变得不可信
- 它会让团队默认接受低质量测试资产
真正有效的治理,应该回答四个问题:
- 哪些用例最脆弱?
- 脆弱性来自哪里?
- 怎么修复才不会引入新问题?
- 修完后如何持续验证已经收敛?
前置知识
阅读本文前,建议你对以下内容有基本了解:
- 单元测试 / 集成测试 / UI 自动化测试的差异
- CI 流水线基础
- Python 基础语法
- 测试框架中的 fixture、setup/teardown、mock 概念
本文示例会用 pytest,因为它足够轻量,也比较容易复现实战场景。
环境准备
安装依赖:
python -m venv .venv
source .venv/bin/activate # Windows 请改用 .venv\Scripts\activate
pip install pytest pytest-rerunfailures
项目结构示例:
stable-testing-demo/
├── app/
│ ├── order_service.py
│ └── flaky_tracker.py
├── tests/
│ ├── test_flaky_examples.py
│ └── test_stable_examples.py
└── pytest.ini
pytest.ini:
[pytest]
addopts = -q
testpaths = tests
核心原理
稳定性治理,不是单点修 bug,而是一个闭环:
- 识别脆弱用例
- 按类型归因
- 局部修复
- 度量收敛
- 持续防回退
我通常会把它拆成两层来看:
- 测试设计层:用例写法是否天然脆弱
- 执行系统层:环境、依赖、调度是否放大了脆弱性
一、用例脆弱性的常见分类
1. 时间敏感型
比如直接依赖 datetime.now(),或者断言“5 秒内一定完成”。
问题在于:
- 不同机器性能不同
- 不同时区结果不同
- 边界时间点容易翻车
2. 顺序依赖型
一个测试修改了共享数据,另一个测试恰好依赖初始状态。
这种问题特别隐蔽,因为单独运行时通常没事。
3. 并发竞态型
异步任务还没完成,断言就开始了;或者多个线程争抢共享资源。
4. 外部依赖型
数据库、消息队列、第三方接口、浏览器驱动、网络请求等。
依赖越多,不确定性越强。
5. 随机数据型
随机用户名、随机端口、随机排序。看起来“更真实”,但如果没有边界控制,很容易变成不稳定源头。
二、稳定性治理的核心指标
治理必须可量化,否则只能靠感觉。
常见指标包括:
- Flaky Rate:同一测试在相同代码版本下,多次执行结果不一致的比例
- False Failure Rate:非代码问题导致的失败比例
- Quarantine Ratio:被隔离测试占比
- MTTR(平均修复时间):发现不稳定到修复关闭的平均耗时
- Top N Flaky Cases:高频不稳定用例清单
一个简单但实用的经验是:
不要一上来追求“全部归零”,先盯住“最常失败、影响主干、复现概率高”的前 20% 用例。
三、治理闭环流程
flowchart TD
A[CI 执行测试] --> B{是否失败}
B -- 否 --> C[记录通过结果]
B -- 是 --> D[自动重跑有限次数]
D --> E{结果是否不一致}
E -- 否 --> F[普通失败,进入缺陷排查]
E -- 是 --> G[标记为疑似 Flaky]
G --> H[按类型归因: 时间/顺序/并发/外部依赖/数据]
H --> I[修复用例或隔离依赖]
I --> J[回归验证多轮执行]
J --> K[更新 Flaky 指标面板]
这个流程里有两个关键点:
- 重跑是为了识别,不是为了粉饰
- 修复后必须多轮验证,不然只是“碰巧稳定了”
从脆弱性分析开始:如何快速找出高风险用例
如果你接手的是一套已经很大的自动化测试,最忌讳一条条人工看。建议优先做“脆弱性扫描”。
重点扫描特征
1. 代码层面的脆弱信号
- 直接使用
sleep - 直接依赖系统当前时间
- 直接访问真实外部服务
- 使用全局共享变量
- 随机数无固定种子
- 写死端口、写死文件路径
- 断言依赖列表顺序但业务不保证顺序
2. 运行层面的脆弱信号
- 只在 CI 失败
- 并行执行失败率明显升高
- 同一测试重跑结果不一致
- 与其他用例组合执行时失败
- 失败日志里出现 timeout / connection reset / element not found
一张分类图看清归因路径
classDiagram
class FlakyTest {
+name
+fail_rate
+rerun_diff
+last_seen
}
class TimeSensitive {
+clock_dependency
+timezone_issue
+boundary_time
}
class OrderDependent {
+shared_state
+dirty_data
+execution_order
}
class AsyncRace {
+unfinished_task
+race_condition
+eventual_consistency
}
class ExternalDependency {
+network
+db
+third_party_api
}
class RandomnessIssue {
+random_seed
+non_deterministic_order
+dynamic_port
}
FlakyTest <|-- TimeSensitive
FlakyTest <|-- OrderDependent
FlakyTest <|-- AsyncRace
FlakyTest <|-- ExternalDependency
FlakyTest <|-- RandomnessIssue
实战代码(可运行)
下面我用一组小例子,先演示“怎么制造 Flaky”,再演示“怎么收敛”。
示例一:时间依赖导致的不稳定
脆弱实现
app/order_service.py:
from datetime import datetime
def is_night_discount():
hour = datetime.now().hour
return hour >= 20 or hour < 6
tests/test_flaky_examples.py:
from app.order_service import is_night_discount
def test_night_discount_enabled():
assert is_night_discount() is True
这个测试白天大概率会挂,晚上大概率会过。它不是在测业务,而是在测“你几点跑它”。
稳定改造
把“当前时间”变成可注入依赖:
app/order_service.py:
from datetime import datetime
def is_night_discount(now: datetime):
hour = now.hour
return hour >= 20 or hour < 6
tests/test_stable_examples.py:
from datetime import datetime
from app.order_service import is_night_discount
def test_night_discount_enabled():
assert is_night_discount(datetime(2024, 1, 1, 21, 0, 0)) is True
def test_night_discount_disabled():
assert is_night_discount(datetime(2024, 1, 1, 14, 0, 0)) is False
关键经验
不要在测试里“碰运气”依赖系统时间,而要显式控制时间输入。
示例二:共享状态导致的顺序依赖
脆弱实现
app/flaky_tracker.py:
counter = 0
def increase():
global counter
counter += 1
return counter
def reset():
global counter
counter = 0
tests/test_flaky_examples.py:
from app.flaky_tracker import increase, reset
def test_first_increment():
reset()
assert increase() == 1
def test_second_increment():
assert increase() == 1
这个例子单独跑第二个测试,可能通过;整套跑时,可能失败,因为它依赖执行顺序。
稳定改造
更好的方式是避免全局状态,或者用 fixture 做隔离。
app/flaky_tracker.py:
class Counter:
def __init__(self):
self.value = 0
def increase(self):
self.value += 1
return self.value
tests/test_stable_examples.py:
from app.flaky_tracker import Counter
def test_first_increment():
counter = Counter()
assert counter.increase() == 1
def test_second_increment():
counter = Counter()
assert counter.increase() == 1
示例三:异步未收敛导致的竞态问题
这个问题在线上项目里很常见,比如你发了一条消息,然后立刻去查结果,但消费者还没处理完。
脆弱实现
tests/test_flaky_examples.py:
import threading
import time
result = {"done": False}
def background_job():
time.sleep(0.2)
result["done"] = True
def test_async_job_done():
result["done"] = False
t = threading.Thread(target=background_job)
t.start()
assert result["done"] is True
这个测试几乎必挂,因为断言时后台线程大概率还没完成。
错误修复方式
很多人会这样修:
import time
time.sleep(1)
assert result["done"] is True
这在我看来是最常见、也最“危险的伪修复”之一。它的问题是:
- 让测试变慢
- 仍然不稳定
- 只是把竞态窗口推迟了
稳定改造
用同步机制等待明确完成信号:
tests/test_stable_examples.py:
import threading
def test_async_job_done():
event = threading.Event()
result = {"done": False}
def background_job():
result["done"] = True
event.set()
t = threading.Thread(target=background_job)
t.start()
assert event.wait(timeout=1.0) is True
assert result["done"] is True
示例四:重跑识别 Flaky,而不是掩盖问题
下面写一个简单脚本,用多轮运行统计“结果不一致”的测试。
app/flaky_tracker.py:
import subprocess
import re
from collections import defaultdict
def run_pytest_once():
result = subprocess.run(
["pytest", "-q"],
capture_output=True,
text=True
)
return result.stdout + "\n" + result.stderr
def parse_failed_tests(output: str):
failed = set()
for line in output.splitlines():
match = re.search(r"FAILED\s+([^\s]+)", line)
if match:
failed.add(match.group(1))
return failed
def detect_flaky(rounds=5):
history = defaultdict(list)
for i in range(rounds):
output = run_pytest_once()
failed = parse_failed_tests(output)
all_seen = set(history.keys()) | failed
for name in all_seen:
history[name].append(name in failed)
flaky = []
for name, results in history.items():
if any(results) and not all(results):
flaky.append((name, results))
return flaky
if __name__ == "__main__":
flaky_tests = detect_flaky(rounds=5)
if not flaky_tests:
print("未检测到 Flaky Test")
else:
print("检测到疑似 Flaky Test:")
for name, results in flaky_tests:
print(f"- {name}: {results}")
运行:
python app/flaky_tracker.py
这不是生产级实现,但很适合作为团队内部最小闭环原型。你可以先用它把疑似不稳定用例找出来,再接入 CI 数据平台。
逐步验证清单
治理 Flaky 最怕“我以为修好了”。建议每次修复后都按下面这份清单走一遍。
修复前
- 能复现至少一种失败路径
- 知道失败属于哪一类:时间、顺序、并发、外部依赖、随机性
- 明确本次修复目标,不顺手改 unrelated 逻辑
修复中
- 去掉无意义
sleep - 注入时间 / 随机种子 / 外部依赖
- 清理共享状态
- 让断言对业务本质负责,而不是对偶然表现负责
修复后
- 本地连续执行 20 次
- CI 串行 + 并行模式都验证
- 单独运行和整套运行都验证
- 查看执行耗时是否恶化
- 补充回归用例防止同类问题再次出现
持续收敛机制:不要只修一次
稳定性治理真正难的,不是定位某个 Flaky,而是防止它持续长回来。
推荐的治理策略
1. 建立疑似 Flaky 自动识别规则
例如:
- 同一 commit 下失败后重跑通过,记为疑似 Flaky
- 过去 7 天内结果波动超过阈值,自动进入治理列表
- 高价值主干用例优先治理
2. 分级处理
- P0:阻塞主干 / 发布链路的高频 Flaky
- P1:影响核心模块但可绕行
- P2:低频、低影响,进入定期清理池
3. 隔离而不是放任
对短期无法修复的用例,可以临时 quarantine,但必须带上:
- 责任人
- 原因标签
- 到期时间
- 退出条件
4. 评审时检查“脆弱性引入”
代码评审不要只看断言对不对,也要看:
- 是否新增真实外部依赖
- 是否用了固定等待
- 是否引入了共享状态
- 是否依赖时间和执行顺序
一张状态图看收敛过程
stateDiagram-v2
[*] --> New
New --> SuspectedFlaky: 失败后重跑通过
SuspectedFlaky --> ConfirmedFlaky: 多轮复现结果波动
ConfirmedFlaky --> Fixing: 已归因并开始修复
Fixing --> Verifying: 连续多轮回归验证
Verifying --> Closed: 收敛成功
Verifying --> ConfirmedFlaky: 仍有波动
ConfirmedFlaky --> Quarantined: 短期无法修复
Quarantined --> Fixing: 排期处理
常见坑与排查
这一节我会讲一些特别容易踩的坑,很多都是“看起来像修复,实际上只是换了一种不稳定”。
坑一:用 sleep 当同步手段
这是最常见的。
现象:
- 测试偶发失败
- 加长
sleep后似乎好一些
问题本质:
- 你并没有等待“条件满足”,只是等待“时间过去”
正确做法:
- 等事件
- 轮询明确状态
- 用框架提供的显式等待机制
坑二:断言了不该断言的东西
比如业务不保证顺序,但你断言了列表绝对顺序:
def test_user_list():
users = ["bob", "alice"]
assert users == ["alice", "bob"]
如果业务只要求“包含这些用户”,那更稳的写法是:
def test_user_list():
users = ["bob", "alice"]
assert set(users) == {"alice", "bob"}
边界条件:
如果产品明确要求顺序,那就必须断顺序,不能为了“稳定”弱化业务约束。
坑三:混用真实依赖与模拟依赖
比如大多数测试都 mock 掉第三方接口,只有个别测试偷偷调真实环境。结果就是:
- 本地没网络时失败
- 测试环境限流时失败
- 数据状态不一致时失败
排查建议:
- 搜索
requests、数据库直连、真实 MQ 客户端创建代码 - 给出统一依赖接入层
- 在单元测试阶段默认禁真实外连
坑四:测试数据没有生命周期管理
脏数据污染是顺序依赖的大本营。
排查路径:
- 同一个账号是否被多个用例共享
- 用例是否依赖“数据库里本来就有数据”
- 测试结束后是否清理
- 是否支持按测试用例生成独立数据命名空间
坑五:把 Flaky 归因给“CI 机器不行”
我踩过这个坑。机器慢确实会放大问题,但它通常不是根因。
建议先问:
- 测试是否写死了极短 timeout?
- 是否存在共享资源竞争?
- 是否依赖机器时钟、CPU、IO 抢占?
- 是否并行执行后才暴露问题?
安全/性能最佳实践
稳定性治理不仅是“让测试通过”,还要兼顾安全性和执行效率。
安全最佳实践
1. 不要在测试中暴露真实密钥
- 使用环境变量注入
- 用测试专用账号
- 日志脱敏输出 token、手机号、邮箱
示例:
import os
def get_api_token():
token = os.getenv("TEST_API_TOKEN")
if not token:
raise RuntimeError("缺少 TEST_API_TOKEN")
return token
2. 测试数据要可控、可清理
- 使用测试库、测试命名空间
- 避免误删生产数据
- 清理脚本要有环境保护
import os
def ensure_test_env():
env = os.getenv("APP_ENV", "dev")
if env != "test":
raise RuntimeError("仅允许在 test 环境执行清理操作")
3. 禁止测试直接访问生产依赖
即使只是“读操作”,也可能引入:
- 不可预测数据变化
- 合规风险
- 限流干扰
- 测试结果不可重现
性能最佳实践
1. 区分“稳定等待”和“长时间等待”
稳定不等于慢。好的等待应该是:
- 条件满足立即继续
- 超时才失败
- 失败日志能说明等的是什么
2. 分层治理
- 单元测试:最快、最稳定,优先覆盖逻辑
- 集成测试:验证接口与模块协作
- E2E/UI 测试:少而关键,不承担所有验证责任
3. 为高风险用例单独统计耗时
有些用例为了“修稳定”加了很多等待,最后变成 CI 性能黑洞。建议持续统计:
- 平均耗时
- P95 耗时
- 重跑次数
- 超时失败比例
一个可落地的团队治理方案
如果你现在要在团队里推动这件事,可以按下面的顺序来。
第一步:建立最小可见性
至少拿到这几项数据:
- 每日失败测试 Top N
- 重跑后通过的测试列表
- 失败类型标签
- 模块维度的 Flaky 分布
第二步:先打最痛点
优先处理:
- 主干阻塞
- 发布阻塞
- 高频失败
- 涉及多人重复处理的用例
第三步:定义编码规范
例如:
- 禁止无注释
sleep - 禁止单元测试访问真实外部网络
- 随机数据必须可复现
- 涉及时间逻辑必须可注入时钟
- fixture 默认隔离数据
第四步:加入门禁
不是一开始就“一票否决”,而是渐进:
- 先告警
- 再要求新增测试不能引入新的 flaky 模式
- 最后对高价值流水线设置严格门禁
一个简单的排查顺序建议
我自己排 Flaky 时,通常按这个顺序:
sequenceDiagram
participant CI as CI流水线
participant T as 测试用例
participant D as 依赖服务
participant A as 分析者
CI->>T: 执行失败
A->>CI: 查看是否重跑后通过
CI-->>A: 返回执行历史
A->>T: 单独运行/整套运行/并行运行对比
A->>T: 检查时间、随机、共享状态、sleep
A->>D: 检查外部依赖波动与日志
A->>T: 修改用例隔离依赖
T-->>CI: 多轮回归验证
对应的实操问题是:
- 能不能稳定复现?
- 单跑和整跑有区别吗?
- 并行和串行有区别吗?
- 是否依赖时间、顺序、外部环境?
- 是否存在共享状态和异步未收敛?
- 修复后是否做了多轮验证?
总结
Flaky Test 最麻烦的地方,不是它难修,而是它会慢慢侵蚀团队对自动化测试的信任。
如果你只记住三件事,我建议是这三条:
-
先识别脆弱性,再谈修复
- 时间、顺序、并发、外部依赖、随机性,是最常见五类根因
-
重跑用于识别,不用于粉饰
- 真正的目标是收敛波动,而不是把红灯洗成绿灯
-
修复后必须持续验证
- 连续多轮、本地与 CI、串行与并行,都要看
最后给几个可执行建议,适合马上落地:
- 从失败 Top 10 用例开始做脆弱性归因
- 禁止新增无意义
sleep - 把时间与外部依赖改为可注入
- 给疑似 Flaky 建一个轻量台账
- 对无法立刻修复的测试做带责任人的临时隔离
边界条件也要讲清楚:
不是所有波动都值得重投入治理。低频、低价值、非主链路的用例,可以控制成本处理;但只要它影响主干、发布、核心业务回归,就必须进入持续收敛闭环。
自动化测试的价值,不在“数量很多”,而在结果可信。当你把 Flaky Test 真正治理起来,CI 才会重新变成团队愿意相信的工程信号。