自动化测试中的稳定性治理:从 Flaky Test 识别、隔离到持续修复的实战方案
自动化测试最让人崩溃的,不是“稳定失败”,而是“有时过、有时不过”。
这类测试我们通常叫 Flaky Test。它的危险不在于某一条用例失败,而在于它会慢慢腐蚀团队对测试系统的信任:
- 开发看到红灯,先怀疑“是不是又抖了”
- CI 失败后第一反应不是修问题,而是重跑
- 最后测试还在跑,但没人真信结果
我见过不少团队,一开始只把 flaky 当成“偶发噪音”,结果半年后,CI 平均要重跑 2~3 次才能合并,测试体系基本失去守门价值。稳定性治理的关键,不是“把失败都消灭”,而是建立一条完整链路:
识别 flaky → 量化影响 → 隔离止血 → 定位根因 → 持续修复 → 防止复发
这篇文章我会按教程方式,带你搭一个能落地的治理方案。重点不是讲概念,而是讲“怎么在团队里真正跑起来”。
背景与问题
什么是 Flaky Test
Flaky Test 指的是:在代码和环境没有本质变化的情况下,同一条测试会出现非确定性结果。
最典型的表现是:
- 第一次失败,第二次重跑通过
- 本地通过,CI 偶发失败
- 单独跑通过,整套并发跑失败
- 在某个时间段稳定,在高峰期大量失败
它和“真实缺陷”最大的区别是:
| 类型 | 表现 | 处理方式 |
|---|---|---|
| 真实缺陷 | 可稳定复现 | 修业务或修测试逻辑 |
| Flaky Test | 结果不稳定、依赖时机/环境/顺序 | 做稳定性治理 |
为什么 Flaky Test 这么难缠
因为它往往不是单点问题,而是多个因素叠加:
- 时间相关:睡眠等待、超时边界、时区、夏令时
- 并发相关:共享状态、资源竞争、异步未收敛
- 环境相关:网络抖动、容器性能波动、外部依赖限流
- 数据相关:脏数据、随机数据碰撞、数据未隔离
- 顺序相关:测试间互相污染、执行顺序依赖
- 观察方式问题:断言过于脆弱,依赖页面细节或日志时序
一个常见误区:只靠“失败重试”
很多团队的第一反应是给 CI 配上 retry: 2。这能临时止血,但如果没有后续治理,它会带来三个副作用:
-
掩盖真实问题
某些真实缺陷也会被“重试”洗掉。 -
放大构建成本
本来 20 分钟的流水线,可能变成 35 分钟。 -
让团队习惯忽略红灯
“再跑一次就好了” 是稳定性退化的开始。
所以,重试只能作为隔离阶段的缓冲手段,不能当最终方案。
前置知识与环境准备
为了让后面的实战代码能直接跑,我这里用 Python + pytest 做演示。原因很简单:示例短、易运行,也很适合说明 flaky 的治理思路。
环境准备
python -m venv .venv
source .venv/bin/activate # Windows 可改用 .venv\Scripts\activate
pip install pytest pytest-rerunfailures
目录结构如下:
project/
├─ app/
│ └─ service.py
├─ tests/
│ ├─ test_flaky_demo.py
│ └─ test_stable_demo.py
└─ scripts/
└─ flaky_analyzer.py
核心原理
稳定性治理不是“见招拆招”,而是一个持续闭环。先看整体流程:
flowchart TD
A[测试执行] --> B[采集结果与上下文]
B --> C[识别 Flaky Test]
C --> D{影响是否显著}
D -- 否 --> E[观察与继续采样]
D -- 是 --> F[隔离止血]
F --> G[根因定位]
G --> H[修复与补充防护]
H --> I[回归验证]
I --> J[纳入指标面板]
J --> B
1. 识别:不要靠感觉,要靠数据
一个测试偶发失败,不代表它就是 flaky。
更稳妥的办法是做重复采样和历史分析。
常用指标:
- Failure Rate:失败率
- Pass-after-Retry Rate:重跑后通过率
- Failure Signature Diversity:失败栈是否多样
- Environment Correlation:是否只在某个节点、某个时段、某个浏览器发生
- Order Sensitivity:是否受执行顺序影响
一个简单判断逻辑:
- 首次失败率高,但重跑后大多通过 → 高概率 flaky
- 只在特定执行顺序下失败 → 状态污染类 flaky
- 只在高并发或资源紧张节点失败 → 环境/竞争类 flaky
2. 隔离:先止血,避免污染主流水线
当 flaky 已经影响交付时,优先目标不是“马上根治”,而是把伤害降下来。
常见手段:
- 单独打标签,迁到 quarantine 队列
- 与阻塞合并的主流水线解耦
- 临时提高观测日志级别
- 针对已知 flaky 做有限次重试
- 失败时自动收集快照、HAR、线程栈、环境变量
3. 修复:根因通常就这几类
可以把 flaky 根因分成 5 大类:
classDiagram
class FlakyRootCause {
+Timing
+SharedState
+ExternalDependency
+Randomness
+AssertionFragility
}
class Timing {
+sleep等待
+异步未完成
+超时边界
}
class SharedState {
+缓存污染
+数据库数据残留
+全局变量复用
}
class ExternalDependency {
+网络波动
+第三方接口不稳定
+限流
}
class Randomness {
+随机输入
+非固定种子
+时间窗口问题
}
class AssertionFragility {
+断言文案
+依赖排序
+页面瞬态状态
}
FlakyRootCause <|-- Timing
FlakyRootCause <|-- SharedState
FlakyRootCause <|-- ExternalDependency
FlakyRootCause <|-- Randomness
FlakyRootCause <|-- AssertionFragility
4. 持续修复:把一次修复变成机制
真正成熟的团队,不是“修掉几个 flaky”,而是建立这些机制:
- 每周输出 flaky 榜单
- 对新增 flaky 做 SLA
- 把 quarantine 数量纳入质量指标
- 对高频根因做规范和基础设施改造
- 在 code review 中拦截易抖写法
实战代码(可运行)
下面我们从一个故意“抖动”的例子开始,再一步步把它治理稳定。
第一步:构造一个典型 Flaky Test
先写一个有随机延迟和时间边界的业务函数。
app/service.py
import random
import time
def fetch_order_status():
# 模拟外部依赖返回时间抖动
delay = random.uniform(0.01, 0.2)
time.sleep(delay)
# 模拟正常返回
return {
"status": "PAID",
"delay": delay
}
tests/test_flaky_demo.py
from app.service import fetch_order_status
def test_order_status_should_be_fast():
result = fetch_order_status()
# 这是一个脆弱断言:把性能抖动当成功能正确性判断
assert result["status"] == "PAID"
assert result["delay"] < 0.1
运行几次:
pytest tests/test_flaky_demo.py -q
pytest tests/test_flaky_demo.py -q
pytest tests/test_flaky_demo.py -q
你会发现它有时过、有时不过。这就是很典型的 flaky:
业务是正常的,但测试断言方式不稳定。
第二步:用重复执行识别可疑用例
我们写一个简单脚本,多次执行 pytest,并统计每条测试的通过/失败分布。
scripts/flaky_analyzer.py
import subprocess
import re
import json
from collections import defaultdict
TEST_PATTERN = re.compile(r"(\S+::\S+)\s+(PASSED|FAILED)")
def run_pytest():
cmd = ["pytest", "tests", "-v", "--tb=short"]
result = subprocess.run(cmd, capture_output=True, text=True)
return result.stdout + "\n" + result.stderr
def parse_results(output):
records = []
for line in output.splitlines():
m = TEST_PATTERN.search(line.strip())
if m:
records.append((m.group(1), m.group(2)))
return records
def main():
stats = defaultdict(lambda: {"PASSED": 0, "FAILED": 0})
rounds = 20
for i in range(rounds):
output = run_pytest()
for test_name, status in parse_results(output):
stats[test_name][status] += 1
report = []
for test_name, result in stats.items():
total = result["PASSED"] + result["FAILED"]
failure_rate = result["FAILED"] / total if total else 0
report.append({
"test": test_name,
"passed": result["PASSED"],
"failed": result["FAILED"],
"failure_rate": round(failure_rate, 2),
"suspected_flaky": 0 < failure_rate < 1
})
report.sort(key=lambda x: x["failure_rate"], reverse=True)
print(json.dumps(report, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
执行:
python scripts/flaky_analyzer.py
示例输出:
[
{
"test": "tests/test_flaky_demo.py::test_order_status_should_be_fast",
"passed": 11,
"failed": 9,
"failure_rate": 0.45,
"suspected_flaky": true
}
]
这一步的重点不是“算法多高级”,而是先把 flaky 从“大家都觉得它有问题”变成“我们有数据证明它不稳定”。
第三步:隔离可疑用例
在 pytest 里,一个实用办法是给可疑用例加标记,先迁入隔离队列。
pytest.ini
[pytest]
markers =
quarantine: unstable tests isolated from main pipeline
修改 tests/test_flaky_demo.py
import pytest
from app.service import fetch_order_status
@pytest.mark.quarantine
def test_order_status_should_be_fast():
result = fetch_order_status()
assert result["status"] == "PAID"
assert result["delay"] < 0.1
主流水线只跑稳定用例:
pytest -m "not quarantine"
隔离流水线单独跑:
pytest -m "quarantine" --reruns 2
这里我建议你记住一个原则:
隔离不是放弃修复,而是为了不让已知噪音继续拖垮主干效率。
第四步:修复根因,而不是继续“调大超时”
观察这个测试,根因其实很清楚:
测试把“响应必须小于 0.1 秒”当成了功能正确性的必要条件,但这个条件并没有稳定环境保证。
更合理的改法有两种。
方案 A:把功能断言和性能断言拆开
tests/test_stable_demo.py
from app.service import fetch_order_status
def test_order_status_should_be_paid():
result = fetch_order_status()
assert result["status"] == "PAID"
如果性能确实重要,就把它放到专门的性能测试或基准测试中,而不是夹在功能测试里。
方案 B:对外部波动做可控替身
如果你的测试目标是“验证逻辑分支”,那就不该依赖随机延迟。可以通过 mock 固定行为。
tests/test_stable_demo.py
from unittest.mock import patch
from app.service import fetch_order_status
@patch("app.service.random.uniform", return_value=0.05)
def test_order_status_should_be_fast_with_controlled_delay(_mock_delay):
result = fetch_order_status()
assert result["status"] == "PAID"
assert result["delay"] < 0.1
这时测试就稳定了,因为外部不确定性已经被控制住。
第五步:为失败自动补充上下文
很多 flaky 难修,不是因为逻辑太复杂,而是因为失败时没有足够信息。
下面给一个 pytest 钩子例子:失败时打印时间戳、进程信息、环境变量片段。
conftest.py
import os
import time
import pytest
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == "call" and report.failed:
print("\n[flaky-debug] failure context:")
print(f"[flaky-debug] test={item.nodeid}")
print(f"[flaky-debug] ts={time.time()}")
print(f"[flaky-debug] pid={os.getpid()}")
print(f"[flaky-debug] ci={os.getenv('CI', 'false')}")
print(f"[flaky-debug] pythonhashseed={os.getenv('PYTHONHASHSEED', 'unset')}")
这类上下文信息看起来不起眼,但我实际排查时,很多问题就是靠这些线索快速定位的,比如:
- 只在 CI 上出现
- 只在某个并发 worker 上出现
- 与 hash seed、时区、语言环境有关
逐步验证清单
如果你打算把这套方案往团队里推,可以按这个顺序来,不容易乱:
阶段 1:先识别
- 拉取最近 2~4 周的测试执行记录
- 找出“失败后重跑通过”的 Top N 用例
- 按失败率、影响范围、所属模块排序
阶段 2:先止血
- 给高频 flaky 打 quarantine 标签
- 主流水线默认跳过 quarantine
- 隔离流水线保留重试,但记录重试成功率
- 失败自动收集日志、截图、线程栈或接口响应
阶段 3:修复
- 判断是时间、状态、环境、随机性还是断言脆弱
- 修测试设计,而不是一味加 sleep
- 修复后至少重复执行 20~50 次验证稳定性
- 去掉 quarantine 标记并观察一周
阶段 4:机制化
- 每周输出 flaky 排行榜
- 新增 flaky 要有归属人
- 每个版本跟踪 quarantine 总量是否下降
- 沉淀编码规范与测试基建能力
常见坑与排查
这一部分很实战,我把最常见、也最容易被误判的坑整理出来。
1. 用 sleep 等待异步完成
这是最典型的“今天能过,明天抖动”的来源。
反例:
import time
def test_async_job_done():
trigger_job()
time.sleep(2)
assert query_job_status() == "DONE"
问题在于:
- 2 秒有时够,有时不够
- 2 秒太保守时会拖慢整套测试
更好的方式是轮询等待带超时:
import time
def wait_until(predicate, timeout=5, interval=0.2):
start = time.time()
while time.time() - start < timeout:
if predicate():
return True
time.sleep(interval)
return False
def test_async_job_done():
trigger_job()
assert wait_until(lambda: query_job_status() == "DONE", timeout=5)
2. 测试之间共享状态
比如:
- 公共测试账号
- 公共数据库记录
- 全局缓存
- 单例对象未重置
这种问题单测单独跑可能永远稳定,但一整套并发跑就开始抖。
排查方法:
- 单独跑通过,整套跑失败 → 优先怀疑状态污染
- 改变执行顺序后结果变化 → 基本可以锁定
- 给每条测试分配独立数据前缀/命名空间
3. 断言过于“贴实现细节”
例如:
- 断言返回列表顺序固定,但业务并未保证
- 断言完整错误文案,而不是错误码
- UI 测试中断言动画过程中的临时节点
更稳的写法应该只断言真正需要保证的契约。
4. 随机数据未固定种子
反例:
import random
def test_random_coupon():
coupon = f"coupon-{random.randint(1, 10)}"
assert create_coupon(coupon)
这会带来碰撞和不可复现。
改进:
import random
def test_random_coupon():
random.seed(42)
coupon = f"coupon-{random.randint(1000, 9999)}"
assert create_coupon(coupon)
或者更好:直接使用唯一 ID 生成策略,而不是伪随机碰运气。
5. 外部依赖直接联调
如果功能测试依赖:
- 第三方支付
- 短信服务
- 搜索服务
- 不稳定网络环境
那么 flaky 基本只是时间问题。
经验上建议:
- 功能测试默认 mock 外部依赖
- 少量端到端联调用例保留,但独立分层
- 对联调失败单独统计,不要与核心回归混算
定位路径:从现象到根因
如果你面对的是线上团队已经“抱怨很久”的 flaky 问题,我建议按下面路径排查:
sequenceDiagram
participant CI as CI流水线
participant T as 测试执行器
participant Q as 隔离队列
participant A as 分析脚本
participant D as 开发/测试负责人
CI->>T: 执行测试
T-->>CI: 出现失败
CI->>A: 汇总历史结果与重试结果
A-->>D: 标记疑似Flaky Test
D->>Q: 加入 quarantine
Q->>T: 独立重复执行并采集上下文
T-->>D: 返回失败模式与根因线索
D->>T: 修复后重复验证
T-->>CI: 恢复到主流水线
具体操作时,可以按这几个问题往下问:
- 它是稳定失败,还是偶发失败?
- 单独跑是否稳定?
- 改变顺序后是否变化?
- 失败是否只出现在某个环境?
- 失败时日志是否指向超时、资源竞争或数据冲突?
- 断言的是业务契约,还是实现细节?
这套问法非常有效,因为它基本能把问题归入前面说的五大类。
安全/性能最佳实践
稳定性治理不只是测试质量问题,也和系统安全性、构建性能密切相关。
安全最佳实践
1. 失败日志不要泄露敏感信息
很多团队为了排查 flaky,会把请求/响应全量打出来。但这里很容易泄露:
- Token
- Cookie
- 手机号
- 邮箱
- 身份证号
- 支付信息
建议:
- 对日志做脱敏
- 测试凭证和生产凭证分离
- 截图、录屏、HAR 文件设置访问权限和保留周期
2. 隔离环境使用最小权限账号
不要让自动化测试账号拥有过高权限,尤其是:
- 删除全库数据
- 修改租户配置
- 调用生产接口
稳定性排查时常会加入更多调试接口,这时候更要控制权限边界。
性能最佳实践
1. 限制重试次数
重试的目标是“识别与缓冲”,不是无限兜底。
建议:
- 默认最多 1~2 次
- 超过阈值就直接标记 quarantine
- 统计重试带来的额外耗时
2. 只对高价值路径做高频重复验证
反复跑 100 次当然更容易发现 flaky,但成本也高。
建议把资源集中在:
- 主干合并必经路径
- 高变更模块
- 历史 flaky 高频区域
- UI/E2E 等天然不稳定层
3. 分层治理,不要所有问题都堆到 E2E
很多 flaky,本质上是因为你把太多验证放在端到端层面。
建议分层:
- 单元测试:验证纯逻辑,最快、最稳
- 集成测试:验证模块协作和契约
- E2E:只保留关键用户路径
层次越靠上,测试越贵,也越容易抖。
4. 固定执行环境
以下因素都可能导致非确定性:
- 时区
- Locale
- Python/Node/Java 版本
- 浏览器版本
- CPU 配额
- 并发 worker 数
所以 CI 环境最好:
- 镜像版本固定
- 依赖锁定
- 时区统一
- 资源配额明确
- 并发策略可控
团队落地建议:别只修个例,要建制度
如果你是测试负责人或质量平台负责人,我建议从下面几件事开始:
1. 定义 Flaky Test 判定标准
例如:
- 最近 20 次执行中既有通过也有失败
- 重试通过率超过 60%
- 只在特定 worker 或特定时段失败
没有统一标准,团队就会一直停留在“我感觉它抖”。
2. 建立 quarantine 生命周期
一个健康的隔离机制至少要回答:
- 谁能把测试放进去?
- 放进去后谁负责修?
- 最长允许留多久?
- 何时移出?
我见过最危险的情况,是 quarantine 变成“永久垃圾场”。
如果没有 SLA,隔离区只会越堆越多。
3. 让指标可见
至少跟踪这几个:
- Flaky Test 数量
- 新增 flaky 数量
- quarantine 总量
- 平均修复时长
- 重试带来的额外流水线耗时
4. 把经验沉淀成规范
例如:
- 不允许直接
sleep等异步 - 禁止共享测试数据
- 随机数据必须可复现
- 外部依赖默认 mock
- 断言必须面向契约,不面向页面细节
当规范和脚手架到位后,新增 flaky 的速度会明显下降。
总结
Flaky Test 真正可怕的,不是“偶发失败”,而是它会持续削弱团队对自动化测试的信任。
一个可落地的治理方案,核心不是某个插件,也不是简单加重试,而是这条完整链路:
- 识别:用重复执行和历史数据找出真正的 flaky
- 隔离:把高噪音用例移出主流水线,先止血
- 修复:按时间、状态、环境、随机性、断言脆弱性分类定位
- 持续治理:用指标、SLA、规范和基建防止复发
如果你准备在团队里开始做这件事,我建议先做三件最小可执行的事:
- 先做一个 flaky 榜单,找出影响最大的前 10 条
- 给这些用例加上 quarantine,避免继续污染主流水线
- 修复时优先消灭 sleep、共享状态、脆弱断言 这三类高频根因
最后给一个边界条件判断:
如果某条测试依赖真实外部系统、时延波动明显、且业务契约本身不要求毫秒级稳定,那么它就不适合作为严格阻塞式功能测试。与其强行压进主流水线,不如拆层、隔离、降级治理。
自动化测试的价值,不是“跑了很多”,而是“结果值得信”。而稳定性治理,正是让这句话成立的基础。