背景与问题
自动化测试一旦“不稳定”,团队很快就会进入一种熟悉但又很糟糕的状态:
- CI 一会儿红一会儿绿
- 同一个提交,本地能过,流水线失败
- 测试失败后,大家第一反应不是修代码,而是“先 rerun 一次”
- 真缺陷被淹没在大量误报里,信任度越来越低
我在项目里踩过最典型的坑,就是测试数量增长很快,但稳定性治理完全滞后。最开始大家只追求覆盖率,后来发现报表很好看,实际发布时还是提心吊胆。原因不是测试没写,而是测试体系没有分层、环境没有隔离、失败也没有被系统性归因。
这类问题通常会表现为几种典型现象:
典型现象
-
随机失败
- 今天失败,明天同代码又通过
- 多次重跑后“神奇恢复”
-
环境相关失败
- 开发机通过,CI 失败
- 测试环境多人共用时,结果互相污染
-
顺序相关失败
- 单独执行能过,整套跑就挂
- 改变执行顺序后结果不同
-
时间相关失败
- 夜里跑失败,白天跑通过
- 和定时任务、缓存刷新、token 过期有关
-
数据相关失败
- 测试账户被别的用例改坏
- 数据库残留导致断言不成立
如果不治理,自动化测试会从“质量保障工具”变成“流程噪音制造机”。
核心原理
稳定性治理不是“多重跑几次”这么简单,它本质上是一个从测试设计到执行环境再到失败归因的系统工程。我的建议是从三个层面同时推进:
- 用例分层
- 环境隔离
- Flaky Test 的识别、定位与优化
1. 用例分层:不要让所有测试承担同一种职责
很多团队的问题是:UI 测试承担了本该由单元测试和接口测试解决的问题。结果是测试慢、脆、难维护。
一个更稳妥的分层方式如下:
flowchart TD
A[单元测试] --> B[接口/服务测试]
B --> C[集成测试]
C --> D[端到端 E2E 测试]
A1[快, 稳, 定位准] --> A
B1[覆盖业务规则与契约] --> B
C1[验证跨模块协作] --> C
D1[验证关键主流程] --> D
分层原则
- 单元测试:覆盖纯逻辑、边界条件、异常分支
- 接口测试:覆盖核心业务规则、鉴权、参数校验、状态流转
- 集成测试:验证服务与数据库、缓存、消息队列的协作
- E2E/UI 测试:只保留关键主路径,不要贪多
一个经验判断
如果一个用例:
- 依赖浏览器
- 依赖真实网络
- 依赖共享测试账号
- 依赖固定时间窗口
那它天然更容易 flaky。
这类测试要么下沉到更低层,要么严格做隔离与控制。
2. 环境隔离:稳定性问题里,环境经常比代码更“脏”
环境隔离的目标很直接:让测试运行结果只由代码和输入决定,而不是由外部状态决定。
常见污染源包括:
- 共用数据库
- 共用缓存 key
- 共用消息队列 topic / consumer group
- 共用测试账号
- 外部第三方服务抖动
- 定时任务与异步任务抢数据
推荐隔离策略
| 维度 | 建议做法 | 说明 |
|---|---|---|
| 数据库 | 每次执行使用独立 schema / 测试库 | 避免数据串扰 |
| 缓存 | key 增加 run_id 前缀 | 防止不同任务互相覆盖 |
| 用户账号 | 动态生成测试账号 | 不要共用固定账号 |
| 文件存储 | 按任务目录隔离 | 方便清理 |
| 第三方依赖 | mock / stub / sandbox | 降低外部波动 |
| 异步任务 | 提供可控开关或轮询机制 | 避免“还没处理完就断言” |
下面这个执行流程是我在治理中最常用的思路:
sequenceDiagram
participant CI as CI流水线
participant ENV as 测试环境
participant APP as 被测系统
participant DB as 数据库/缓存
participant EXT as 外部依赖Mock
CI->>ENV: 创建独立 run_id
CI->>DB: 初始化测试数据空间
CI->>EXT: 启动 mock 服务
CI->>APP: 注入环境变量与隔离配置
CI->>APP: 执行测试
APP->>DB: 读写带 run_id 的数据
APP->>EXT: 调用可控外部依赖
APP-->>CI: 返回测试结果
CI->>DB: 清理测试数据
CI->>ENV: 销毁临时资源
3. Flaky Test 的本质:非确定性
Flaky Test 最麻烦的地方是:失败不稳定,难以复现。但它并不是“玄学”,背后一般都能归到几类根因。
常见根因分类
classDiagram
class FlakyRootCause {
时间依赖
共享状态
异步未收敛
外部依赖抖动
顺序耦合
随机数据不受控
资源竞争
}
诊断思路
我一般按下面的优先级排查:
-
先看失败是否可重跑通过
- 能,则优先怀疑 flaky
- 不能,则可能是真缺陷
-
看失败是否集中在某类测试
- UI 多:关注等待机制、元素定位、页面异步加载
- API 多:关注数据污染、依赖超时、鉴权过期
- 集成多:关注数据库事务、消息延迟、缓存一致性
-
看失败是否与时间、顺序、并发有关
- 顺序变化导致失败:多半有共享状态
- 并发执行失败,串行执行通过:多半隔离不足
- 夜间失败:多半与定时任务、token、批处理相关
现象复现
下面我们用一个小型示例,复现两个很常见的 flaky 场景:
- 测试之间共享全局状态
- 异步任务处理未完成就断言
为了保证“可运行”,我用 Python 标准库来写,不依赖额外三方服务。你可以直接保存为两个文件执行。
实战代码(可运行)
1. 一个存在稳定性问题的示例
app.py
import threading
import time
import uuid
class OrderService:
def __init__(self):
self.orders = {}
self.lock = threading.Lock()
def create_order(self, user_id: str):
order_id = str(uuid.uuid4())
with self.lock:
self.orders[order_id] = {
"user_id": user_id,
"status": "PENDING"
}
# 模拟异步处理:随机延迟后将订单置为 DONE
def async_complete():
time.sleep(0.2)
with self.lock:
if order_id in self.orders:
self.orders[order_id]["status"] = "DONE"
t = threading.Thread(target=async_complete)
t.daemon = True
t.start()
return order_id
def get_order(self, order_id: str):
with self.lock:
return self.orders.get(order_id)
# 故意放一个全局单例,方便复现“共享状态污染”
service = OrderService()
test_flaky_demo.py
import time
import unittest
from app import service
class TestOrderServiceFlaky(unittest.TestCase):
def test_create_order_status_done_immediately(self):
order_id = service.create_order("u1001")
order = service.get_order(order_id)
# 问题1:异步未完成,立即断言 DONE,具有不稳定性
self.assertEqual(order["status"], "DONE")
def test_total_orders_should_be_one(self):
service.create_order("u1002")
# 问题2:依赖全局 service,前一个测试可能已插入数据
self.assertEqual(len(service.orders), 1)
if __name__ == "__main__":
unittest.main()
运行方式
python test_flaky_demo.py
你大概率会看到:
- 第一个测试经常失败,因为异步还没跑完
- 第二个测试在不同执行顺序下结果也可能不一样
这就是最典型的 flaky 特征:测试结果受时序和共享状态影响。
2. 治理后的稳定版本
修复思路分两步:
- 每个测试用例创建自己的
OrderService实例,消除共享状态 - 对异步结果做“有上限的等待”,而不是盲目立即断言
test_stable_demo.py
import time
import unittest
from app import OrderService
def wait_until(predicate, timeout=1.0, interval=0.05):
start = time.time()
while time.time() - start < timeout:
if predicate():
return True
time.sleep(interval)
return False
class TestOrderServiceStable(unittest.TestCase):
def setUp(self):
# 每个测试使用独立实例,避免共享状态污染
self.service = OrderService()
def test_create_order_eventually_done(self):
order_id = self.service.create_order("u1001")
ok = wait_until(
lambda: self.service.get_order(order_id)["status"] == "DONE",
timeout=1.0,
interval=0.05
)
self.assertTrue(ok, "订单状态在超时时间内未变为 DONE")
def test_total_orders_should_be_one(self):
self.service.create_order("u1002")
self.assertEqual(len(self.service.orders), 1)
if __name__ == "__main__":
unittest.main()
运行方式
python test_stable_demo.py
这个版本的核心价值不在代码多高级,而在于它体现了稳定性治理的两个原则:
- 测试彼此独立
- 等待异步结果时,要等“业务完成条件”,不是瞎 sleep
3. 在 CI 中为测试注入隔离标识
如果你的测试会访问数据库、缓存或文件系统,我建议统一注入 RUN_ID。这样即使是共享环境,也能做到逻辑隔离。
ci_runner.py
import os
import uuid
import subprocess
def main():
run_id = str(uuid.uuid4())
env = os.environ.copy()
env["TEST_RUN_ID"] = run_id
print(f"Start test run with TEST_RUN_ID={run_id}")
result = subprocess.run(
["python", "-m", "unittest", "test_stable_demo.py"],
env=env
)
raise SystemExit(result.returncode)
if __name__ == "__main__":
main()
一个简单的数据命名示例
import os
def build_test_user(prefix="user"):
run_id = os.getenv("TEST_RUN_ID", "local")
return f"{prefix}_{run_id}"
在真实项目里,你可以把它扩展到:
- 数据库 schema:
test_${RUN_ID} - Redis key:
${RUN_ID}:order:123 - 文件路径:
/tmp/${RUN_ID}/report.json
定位路径
当 CI 中出现 flaky 失败时,我建议不要直接“重跑了事”,而是按固定路径定位。这样团队会形成可复用的方法论。
第一步:先判断真假失败
可以这么做
- 失败后自动重跑 1 次,但必须记录首次失败
- 如果首次失败、第二次通过,标记为
suspected flaky - 如果连续失败,优先当真缺陷处理
不建议这么做
- 无限重跑直到通过
- 报表只展示最后结果,不展示首次失败率
因为一旦隐藏了首次失败,团队会误以为测试很稳定,实际上只是把问题扫到了地毯下面。
第二步:收集足够的上下文
排 flaky 时,日志上下文比“失败截图”更重要。至少要收集:
- 提交版本号
- 测试开始与结束时间
- 执行机器 / 容器 ID
- 测试顺序
- 并发度
- 测试数据标识(如 run_id)
- 外部依赖返回值或 mock 记录
- 重跑前后结果对比
如果是 UI 测试,再补充:
- 页面截图
- DOM 快照
- 浏览器控制台日志
- 网络请求 HAR 或关键接口响应
第三步:按根因清单排查
1. 时间依赖问题
症状:
- 用了固定
sleep(1) - 偶发超时
- 环境慢一点就失败
处理:
- 改成显式等待业务条件
- 给超时设置合理上限
- 采集实际耗时分布,别凭感觉设 timeout
2. 共享状态问题
症状:
- 单测单独运行通过,整套失败
- 调整顺序后结果变化
处理:
- 每个测试独立初始化数据
- teardown 清理资源
- 引入 run_id 隔离缓存、库表、文件
3. 外部依赖问题
症状:
- 第三方 API 偶发超时
- 沙箱环境数据不稳定
处理:
- 能 mock 就 mock
- 不能 mock 的接口,做契约校验而不是全链路强依赖
- 对少量关键联调场景单独保留集成用例
4. 随机数据问题
症状:
- 用随机用户名、随机时间戳
- 断言依赖不可预测结果
处理:
- 固定随机种子
- 把随机性限制在可追踪范围内
- 对随机生成的数据做结构性断言,而不是写死具体值
5. 并发资源竞争
症状:
- 并发执行失败,串行通过
- 连接池、端口、临时目录冲突
处理:
- 限制高风险测试并发度
- 使用线程/进程安全资源
- 临时文件名、端口号增加隔离策略
常见坑与排查
下面这些坑,在实际治理中出现频率非常高。
坑 1:把 sleep 当同步手段
很多 flaky 都源于一句看似无害的代码:
time.sleep(1)
问题在于:
- 环境快时,1 秒是浪费
- 环境慢时,1 秒又不够
更好的方式是等待明确条件,例如:
- 订单状态变为
DONE - 页面元素可点击
- 消息队列消费完成
- 数据库中出现目标记录
坑 2:清理逻辑不彻底
我见过不少用例 setUp 做得很认真,tearDown 却几乎没有。最后数据库、缓存、对象存储里都是脏数据。
建议至少保证:
- 创建什么,就清理什么
- 清理失败也要打日志
- 对 CI 失败中断场景,增加兜底清理任务
坑 3:测试环境“半真半假”
比如:
- 数据库是真实共享环境
- 第三方服务是 mock
- 缓存是共用 Redis
- 定时任务还在后台真实运行
这种环境最容易制造认知偏差。你以为问题来自代码,实际上是环境行为不一致。
建议明确环境类型:
- 单元/组件测试环境:尽量本地化、可控
- 集成测试环境:可共享,但必须有命名空间隔离
- 预发联调环境:接受一定不稳定,但不要用于门禁主判断
坑 4:把 flaky 用例长期留在主干门禁
如果某个用例已经确认 flaky,却还一直卡主干,团队很快会形成“失败就 rerun”的坏习惯。
止血方案
- 先把已知 flaky 用例打标签
- 从强门禁中临时摘除
- 建立修复 SLA,比如 3 天内必须归因
- 每周复盘 flaky 增量,而不是只看总量
这里要注意边界:
摘除门禁不是放弃治理,而是为了防止它持续污染正常交付。
安全/性能最佳实践
稳定性治理除了关注“测不测得过”,还要关注测试过程本身是否安全、是否高效。
安全最佳实践
1. 不在测试代码中硬编码敏感信息
不要这样:
API_KEY = "prod-secret-key"
应该通过环境变量或密钥管理注入:
import os
API_KEY = os.getenv("TEST_API_KEY")
if not API_KEY:
raise RuntimeError("TEST_API_KEY is required")
2. 测试数据脱敏
如果测试环境使用了生产脱敏数据,要确保:
- 手机号、身份证号、邮箱不可逆脱敏
- 日志里不要输出完整敏感字段
- 错误快照中注意遮挡 token、cookie、authorization header
3. 权限最小化
测试账号只给必要权限,不要图省事直接给管理员权限。
否则测试虽然能跑过,但掩盖了真实权限问题。
性能最佳实践
1. 把慢测试和不稳定测试分开治理
推荐至少拆成三类:
- smoke:提交即跑,要求快且稳
- regression:合并前或定时跑,覆盖更全
- quarantine:已知 flaky,持续修复中
2. 优先优化前 20% 的高频失败用例
不要一上来全量整治。
通常少数高频 flaky 用例,贡献了大部分噪音。先做这批,收益最大。
3. 建立稳定性指标
建议关注这些指标:
- 首次通过率(First Pass Rate)
- 重跑通过率
- Flaky 用例占比
- 平均执行时长
- Top N 失败原因
- 环境初始化耗时
这些指标能帮助你判断:问题是在测试设计、执行资源,还是环境本身。
一个可落地的治理方案
如果你正在接手一套已经“经常飘红”的自动化测试,我建议按下面节奏推进。
stateDiagram-v2
[*] --> 建立基线
建立基线 --> 用例分层治理
用例分层治理 --> 环境隔离
环境隔离 --> Flaky识别与标签化
Flaky识别与标签化 --> 高优问题修复
高优问题修复 --> 指标看板
指标看板 --> 持续回归
持续回归 --> [*]
第 1 阶段:建立基线
先回答几个问题:
- 总共有多少自动化用例?
- 哪些是 UI,哪些是 API,哪些是单测?
- 最近 30 天失败最多的是哪 20 个?
- 首次通过率是多少?
- 多少失败是重跑后恢复的?
没有基线,就很难知道治理是否有效。
第 2 阶段:快速止血
先做收益最高的动作:
- 给 flaky 用例打标签
- 把最不稳定的 UI 用例从主门禁摘出去
- 为测试加 run_id
- 禁止共享固定账号
- 统一记录失败上下文
第 3 阶段:系统修复
再逐步推进:
- 把能下沉到单测/API 测试的用例下沉
- 异步场景统一封装等待机制
- 数据、缓存、文件、消息通道命名空间隔离
- 对外部依赖引入 mock/stub
- 为常见失败建立归因模板
第 4 阶段:指标驱动持续治理
不要把治理当成一次性项目。
稳定性是会“反弹”的,尤其在团队规模变大、并发增加、流水线加速之后。
总结
自动化测试稳定性治理,最怕两个误区:
- 只把问题归咎于“环境差”
- 只靠“失败重跑”掩盖问题
真正有效的治理,一定同时覆盖三件事:
- 用例分层:让不同层级的测试承担合适职责
- 环境隔离:让测试结果尽可能可重复、可预测
- Flaky 排查优化:把随机失败拆成可定位、可修复的工程问题
如果你想今天就开始,我建议先做这 5 件最有性价比的事:
- 统计最近 30 天首次失败率最高的用例
- 给所有测试执行注入
run_id - 禁止使用共享测试账号和共享缓存 key
- 把固定
sleep改成等待明确业务条件 - 对已知 flaky 用例建立隔离标签和修复时限
最后给一个边界判断:
如果某类测试天然依赖大量外部系统、异步流程又长、环境还不可控,那就不要把它放在最强门禁上。它更适合做预警型回归,而不是提交阻断型检查。
稳定性治理不是追求“永不失败”,而是追求:失败要真实、结果要可信、排查要高效。做到这三点,自动化测试才会重新成为团队愿意依赖的工具。