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

《自动化测试中的稳定性治理:从 Flaky Test 识别、隔离到持续修复的实战方案》

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

自动化测试中的稳定性治理:从 Flaky Test 识别、隔离到持续修复的实战方案

自动化测试最让人崩溃的,不是“稳定失败”,而是“有时过、有时不过”。

这类测试我们通常叫 Flaky Test。它的危险不在于某一条用例失败,而在于它会慢慢腐蚀团队对测试系统的信任:

  • 开发看到红灯,先怀疑“是不是又抖了”
  • CI 失败后第一反应不是修问题,而是重跑
  • 最后测试还在跑,但没人真信结果

我见过不少团队,一开始只把 flaky 当成“偶发噪音”,结果半年后,CI 平均要重跑 2~3 次才能合并,测试体系基本失去守门价值。稳定性治理的关键,不是“把失败都消灭”,而是建立一条完整链路:

识别 flaky → 量化影响 → 隔离止血 → 定位根因 → 持续修复 → 防止复发

这篇文章我会按教程方式,带你搭一个能落地的治理方案。重点不是讲概念,而是讲“怎么在团队里真正跑起来”。


背景与问题

什么是 Flaky Test

Flaky Test 指的是:在代码和环境没有本质变化的情况下,同一条测试会出现非确定性结果

最典型的表现是:

  • 第一次失败,第二次重跑通过
  • 本地通过,CI 偶发失败
  • 单独跑通过,整套并发跑失败
  • 在某个时间段稳定,在高峰期大量失败

它和“真实缺陷”最大的区别是:

类型表现处理方式
真实缺陷可稳定复现修业务或修测试逻辑
Flaky Test结果不稳定、依赖时机/环境/顺序做稳定性治理

为什么 Flaky Test 这么难缠

因为它往往不是单点问题,而是多个因素叠加:

  • 时间相关:睡眠等待、超时边界、时区、夏令时
  • 并发相关:共享状态、资源竞争、异步未收敛
  • 环境相关:网络抖动、容器性能波动、外部依赖限流
  • 数据相关:脏数据、随机数据碰撞、数据未隔离
  • 顺序相关:测试间互相污染、执行顺序依赖
  • 观察方式问题:断言过于脆弱,依赖页面细节或日志时序

一个常见误区:只靠“失败重试”

很多团队的第一反应是给 CI 配上 retry: 2。这能临时止血,但如果没有后续治理,它会带来三个副作用:

  1. 掩盖真实问题
    某些真实缺陷也会被“重试”洗掉。

  2. 放大构建成本
    本来 20 分钟的流水线,可能变成 35 分钟。

  3. 让团队习惯忽略红灯
    “再跑一次就好了” 是稳定性退化的开始。

所以,重试只能作为隔离阶段的缓冲手段,不能当最终方案。


前置知识与环境准备

为了让后面的实战代码能直接跑,我这里用 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. 它是稳定失败,还是偶发失败?
  2. 单独跑是否稳定?
  3. 改变顺序后是否变化?
  4. 失败是否只出现在某个环境?
  5. 失败时日志是否指向超时、资源竞争或数据冲突?
  6. 断言的是业务契约,还是实现细节?

这套问法非常有效,因为它基本能把问题归入前面说的五大类。


安全/性能最佳实践

稳定性治理不只是测试质量问题,也和系统安全性、构建性能密切相关。

安全最佳实践

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 真正可怕的,不是“偶发失败”,而是它会持续削弱团队对自动化测试的信任。

一个可落地的治理方案,核心不是某个插件,也不是简单加重试,而是这条完整链路:

  1. 识别:用重复执行和历史数据找出真正的 flaky
  2. 隔离:把高噪音用例移出主流水线,先止血
  3. 修复:按时间、状态、环境、随机性、断言脆弱性分类定位
  4. 持续治理:用指标、SLA、规范和基建防止复发

如果你准备在团队里开始做这件事,我建议先做三件最小可执行的事:

  • 先做一个 flaky 榜单,找出影响最大的前 10 条
  • 给这些用例加上 quarantine,避免继续污染主流水线
  • 修复时优先消灭 sleep、共享状态、脆弱断言 这三类高频根因

最后给一个边界条件判断:
如果某条测试依赖真实外部系统、时延波动明显、且业务契约本身不要求毫秒级稳定,那么它就不适合作为严格阻塞式功能测试。与其强行压进主流水线,不如拆层、隔离、降级治理。

自动化测试的价值,不是“跑了很多”,而是“结果值得信”。而稳定性治理,正是让这句话成立的基础。


分享到:

下一篇
《Web逆向实战:中级开发者如何定位并复现前端签名算法实现接口自动化调用》