跳转到内容
123xiao | 无名键客

《自动化测试中的稳定性治理实战:从用例脆弱性分析到 Flaky Test 持续收敛》

字数: 0 阅读时长: 1 分钟

自动化测试中的稳定性治理实战:从用例脆弱性分析到 Flaky Test 持续收敛

自动化测试跑得多了,团队几乎一定会碰到一个很烦的问题:同一份代码、同一套测试,今天过、明天挂、重跑又过。这类测试我们通常叫它 Flaky Test

很多团队对 Flaky Test 的处理方式非常“务实”——失败了先重跑,重跑过了就当没事。但时间久了,代价会慢慢显现:

  • CI 结果失去可信度
  • 开发对测试报警麻木
  • 回归时间越来越长
  • 真缺陷被“噪音”淹没
  • 团队开始怀疑自动化测试的价值

这篇文章我会按“能落地”的方式,带你从 用例脆弱性分析 入手,建立一套 Flaky Test 持续收敛机制。重点不是讲概念,而是讲怎么做、怎么验证、怎么避免一边治理一边制造新的不稳定。


背景与问题

先说结论:Flaky Test 本质上不是“测试偶尔失败”,而是“测试结果对非业务因素敏感”

这些非业务因素可能包括:

  • 时间:依赖当前时间、时区、定时任务触发点
  • 并发:共享状态、异步未收敛、竞态条件
  • 环境:网络抖动、外部服务不稳定、机器性能差异
  • 数据:脏数据、随机数据碰撞、用例间互相污染
  • 顺序:测试执行顺序改变后结果不同
  • UI:元素渲染延迟、动画、遮挡、浏览器差异

典型症状

你可能已经见过这些场景:

  1. 本地跑通过,CI 上失败
  2. 单独跑通过,整套一起跑失败
  3. 首次失败,第二次重跑通过
  4. 只在某个时间段失败,比如零点、月底、夏令时切换
  5. 只在资源紧张时失败,比如高并发流水线

为什么“重跑”不能算治理

重跑在某些时候可以作为“止血”,但不能作为“治理”。原因很简单:

  • 它掩盖了真实不稳定源头
  • 它拉长了反馈周期
  • 它让失败信号变得不可信
  • 它会让团队默认接受低质量测试资产

真正有效的治理,应该回答四个问题:

  1. 哪些用例最脆弱?
  2. 脆弱性来自哪里?
  3. 怎么修复才不会引入新问题?
  4. 修完后如何持续验证已经收敛?

前置知识

阅读本文前,建议你对以下内容有基本了解:

  • 单元测试 / 集成测试 / 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. 识别脆弱用例
  2. 按类型归因
  3. 局部修复
  4. 度量收敛
  5. 持续防回退

我通常会把它拆成两层来看:

  • 测试设计层:用例写法是否天然脆弱
  • 执行系统层:环境、依赖、调度是否放大了脆弱性

一、用例脆弱性的常见分类

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 客户端创建代码
  • 给出统一依赖接入层
  • 在单元测试阶段默认禁真实外连

坑四:测试数据没有生命周期管理

脏数据污染是顺序依赖的大本营。

排查路径:

  1. 同一个账号是否被多个用例共享
  2. 用例是否依赖“数据库里本来就有数据”
  3. 测试结束后是否清理
  4. 是否支持按测试用例生成独立数据命名空间

坑五:把 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: 多轮回归验证

对应的实操问题是:

  1. 能不能稳定复现?
  2. 单跑和整跑有区别吗?
  3. 并行和串行有区别吗?
  4. 是否依赖时间、顺序、外部环境?
  5. 是否存在共享状态和异步未收敛?
  6. 修复后是否做了多轮验证?

总结

Flaky Test 最麻烦的地方,不是它难修,而是它会慢慢侵蚀团队对自动化测试的信任

如果你只记住三件事,我建议是这三条:

  1. 先识别脆弱性,再谈修复

    • 时间、顺序、并发、外部依赖、随机性,是最常见五类根因
  2. 重跑用于识别,不用于粉饰

    • 真正的目标是收敛波动,而不是把红灯洗成绿灯
  3. 修复后必须持续验证

    • 连续多轮、本地与 CI、串行与并行,都要看

最后给几个可执行建议,适合马上落地:

  • 从失败 Top 10 用例开始做脆弱性归因
  • 禁止新增无意义 sleep
  • 把时间与外部依赖改为可注入
  • 给疑似 Flaky 建一个轻量台账
  • 对无法立刻修复的测试做带责任人的临时隔离

边界条件也要讲清楚:
不是所有波动都值得重投入治理。低频、低价值、非主链路的用例,可以控制成本处理;但只要它影响主干、发布、核心业务回归,就必须进入持续收敛闭环。

自动化测试的价值,不在“数量很多”,而在结果可信。当你把 Flaky Test 真正治理起来,CI 才会重新变成团队愿意相信的工程信号。


分享到:

上一篇
《Java开发踩坑实录:8个最容易被忽视的线程池误用场景与排查修复方案》
下一篇
《AI 智能体在企业知识库问答中的落地实践:从 RAG 架构设计到效果评估》