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

《自动化测试中的稳定性治理实战:从脆弱用例识别到 Flaky Test 持续修复体系搭建》

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

自动化测试中的稳定性治理实战:从脆弱用例识别到 Flaky Test 持续修复体系搭建

自动化测试做久了,大家都会碰到一种很烦的情况:代码没改,测试却红了;重跑一下,又绿了。
这类测试通常被称为 Flaky Test。它的危险不在于“偶尔失败”,而在于它会慢慢腐蚀团队对测试结果的信任:开发开始习惯性点 re-run,CI 红灯也没人第一时间看,最后自动化测试变成“形式上有、实际上没人信”。

这篇文章我会从一个更偏“治理”的角度来讲,不只停留在“如何修一个 flaky case”,而是带你从:

  1. 识别脆弱用例
  2. 建立分类和优先级
  3. 做最小可行修复
  4. 搭持续发现、持续修复、持续度量的体系

一步步搭起一套可落地的方法。


背景与问题

为什么 Flaky Test 比普通失败更难处理?

普通失败通常有明确因果链:

  • 代码改坏了
  • 断言不成立
  • 用例稳定复现

而 Flaky Test 的特点是:

  • 非确定性
  • 复现概率低
  • 受环境、时间、并发、依赖状态影响
  • 经常“重跑通过”

所以它比功能缺陷更像一种“系统噪音源”。如果不治理,常见后果是:

  • CI 失败率升高,发布节奏被拖慢
  • 开发对红灯麻木
  • 测试结果可信度下降
  • 真缺陷被淹没在随机失败里
  • 排查成本持续升高

Flaky Test 常见来源

从实战看,脆弱用例大致可以分为这几类:

  1. 时间相关

    • 固定 sleep
    • 依赖系统时间
    • 时区/夏令时问题
  2. 异步与并发相关

    • 任务尚未完成就开始断言
    • 多线程共享状态污染
    • 执行顺序不稳定
  3. 环境依赖

    • 网络抖动
    • 外部服务不可用
    • 数据库残留脏数据
  4. 测试设计问题

    • 用例之间共享数据
    • 断言过度依赖 UI 文案、顺序、随机值
    • 选择器脆弱
  5. 资源竞争

    • 端口冲突
    • 文件锁
    • CPU/内存波动导致超时

一个常见误区

很多团队会说:“Flaky Test 没法彻底消灭,只能忍。”
这话只对一半。确实不能保证绝对为零,但完全可以把它从“随缘处理”变成“可观测、可分级、可持续压降”的工程问题


前置知识与环境准备

本文示例使用 Python,原因很简单:容易复现不稳定场景,也方便展示治理脚本。

环境准备

python -m venv venv
source venv/bin/activate
pip install pytest pytest-rerunfailures

项目结构如下:

project/
├─ app/
│  └─ service.py
├─ tests/
│  ├─ test_stable.py
│  ├─ test_flaky_random.py
│  ├─ test_flaky_async.py
│  └─ test_flaky_shared_state.py
└─ tools/
   └─ flaky_detector.py

核心原理

稳定性治理不是“见一个修一个”,而是一个闭环。先给出整体图,再展开。

flowchart TD
    A[测试执行] --> B[采集结果与日志]
    B --> C[识别疑似 Flaky Test]
    C --> D[按类型归因]
    D --> E[制定修复策略]
    E --> F[回归验证]
    F --> G[纳入指标看板]
    G --> H[持续巡检与治理]

1. Flaky Test 的判定,不只看“失败过”

一个用例失败一次,不代表它一定 flaky。更合理的判断方式是:

  • 同一版本代码下重复执行
  • 有时通过,有时失败
  • 失败原因不稳定或与代码改动无直接关系

可抽象成一个简单指标:

Flaky Rate = 在相同代码版本下,既出现 Pass 又出现 Fail 的执行批次 / 总执行批次

这跟普通失败的区别是:

  • 普通失败:持续 Fail
  • Flaky:Fail/Pass 混杂出现

2. 用例脆弱性识别的核心维度

我建议把“脆弱性识别”拆成 4 个维度:

维度关注点典型信号
历史结果是否频繁重跑后通过同一用例近 7 天内多次 Pass/Fail 交替
执行时长是否接近超时边界P95 时长明显升高
依赖复杂度是否依赖外部系统网络、数据库、缓存、消息队列
测试设计质量是否隔离、是否可重复共享状态、硬编码 sleep、弱断言

3. 治理不是只修代码,还要修流程

一个成熟的体系至少有三层:

classDiagram
    class Detection {
      +collect_ci_results()
      +rerun_suite()
      +compute_flaky_rate()
      +mark_suspects()
    }

    class Classification {
      +by_timeout()
      +by_randomness()
      +by_shared_state()
      +by_external_dependency()
    }

    class Remediation {
      +fix_test_code()
      +improve_env_isolation()
      +quarantine_if_needed()
      +verify_and_close()
    }

    Detection --> Classification
    Classification --> Remediation
  • Detection:先发现问题
  • Classification:明确是哪一类问题
  • Remediation:按类型修,不是一律 rerun

4. “重跑”可以用于识别,不能作为长期修复

CI 中配置失败自动重跑,短期能止血,但不能当最终方案。
因为它会带来两个副作用:

  • 隐藏真实不稳定性
  • 拉长流水线时长

我一般建议:

  • 识别阶段:允许有限重跑,收集数据
  • 治理阶段:重跑仅作辅助,不作通过标准
  • 稳定后:逐步降低重跑次数

逐步验证清单

在开始修复前,可以先用这份清单快速筛查:

  • 这个失败能在相同代码版本下重复出现吗?
  • 重跑后是否容易通过?
  • 是否使用了固定 sleep
  • 是否依赖系统当前时间?
  • 是否依赖外部服务、网络、数据库残留数据?
  • 用例之间是否共享了全局状态?
  • 断言是否过于依赖顺序、随机值、弱稳定字段?
  • 失败是否集中出现在高负载时段?
  • 是否存在并行执行冲突?
  • 是否有足够的日志、截图、trace 方便定位?

实战代码(可运行)

下面我故意构造几个常见的 flaky 场景,然后演示怎么识别和修。


示例一:随机数导致的脆弱用例

业务代码

# app/service.py
import random
import threading
import time

shared_cache = []

def maybe_discount():
    # 模拟某个依赖了随机策略的逻辑
    return "DISCOUNT" if random.random() > 0.2 else "NO_DISCOUNT"

def async_job(result_container):
    delay = random.uniform(0.05, 0.2)
    time.sleep(delay)
    result_container["done"] = True

def append_shared_data(item):
    shared_cache.append(item)

def clear_shared_data():
    shared_cache.clear()

脆弱测试

# tests/test_flaky_random.py
from app.service import maybe_discount

def test_should_always_discount():
    assert maybe_discount() == "DISCOUNT"

这个测试的问题非常明显:它把随机行为当成确定行为断言了

正确修法

如果你的目的是测“返回值属于合法集合”,就应该这么写:

# tests/test_flaky_random.py
from app.service import maybe_discount

def test_discount_result_should_be_valid():
    assert maybe_discount() in {"DISCOUNT", "NO_DISCOUNT"}

如果你的目的是测某个随机分支逻辑,那就要控制随机源,而不是赌概率。比如用 mock:

# tests/test_stable.py
from unittest.mock import patch
from app.service import maybe_discount

def test_should_return_discount_when_random_gt_threshold():
    with patch("app.service.random.random", return_value=0.9):
        assert maybe_discount() == "DISCOUNT"

def test_should_return_no_discount_when_random_le_threshold():
    with patch("app.service.random.random", return_value=0.1):
        assert maybe_discount() == "NO_DISCOUNT"

示例二:异步任务 + 固定 sleep

这是我见过最常见的一类 flaky:
测试里写个 sleep(1),心想“应该够了吧”。结果 CI 上偶发超时。

脆弱测试

# tests/test_flaky_async.py
import time
from app.service import async_job

def test_async_job_done_with_fixed_sleep():
    result = {"done": False}
    async_job(result)
    time.sleep(0.05)
    assert result["done"] is True

上面这个例子甚至还有一个隐藏问题:async_job 本身是同步调用,但很多真实项目里,测试逻辑会把异步概念混在一起,最后靠 sleep 硬等。

我们更真实地改一下,用线程模拟异步:

# tests/test_flaky_async.py
import threading
import time
from app.service import async_job

def test_async_job_done_with_fixed_sleep():
    result = {"done": False}
    t = threading.Thread(target=async_job, args=(result,))
    t.start()
    time.sleep(0.05)
    assert result["done"] is True

这个测试会随机失败,因为任务耗时是 0.05 ~ 0.2s

正确修法:显式等待条件

# tests/test_stable.py
import threading
import time
from app.service import async_job

def wait_until(predicate, timeout=1.0, interval=0.01):
    start = time.time()
    while time.time() - start < timeout:
        if predicate():
            return True
        time.sleep(interval)
    return False

def test_async_job_done_with_polling():
    result = {"done": False}
    t = threading.Thread(target=async_job, args=(result,))
    t.start()

    assert wait_until(lambda: result["done"] is True, timeout=1.0)
    t.join()

这里的关键点是:

  • 不用固定 sleep
  • 等待状态达成
  • 有明确超时边界

示例三:共享状态污染

脆弱测试

# tests/test_flaky_shared_state.py
from app.service import append_shared_data, clear_shared_data, shared_cache

def test_append_item_a():
    append_shared_data("A")
    assert shared_cache == ["A"]

def test_append_item_b():
    append_shared_data("B")
    assert shared_cache == ["B"]

如果这两个测试按不同顺序执行,就会互相污染。

正确修法:每个用例隔离状态

# tests/test_stable.py
import pytest
from app.service import append_shared_data, clear_shared_data, shared_cache

@pytest.fixture(autouse=True)
def reset_shared_cache():
    clear_shared_data()
    yield
    clear_shared_data()

def test_append_item_a_isolated():
    append_shared_data("A")
    assert shared_cache == ["A"]

def test_append_item_b_isolated():
    append_shared_data("B")
    assert shared_cache == ["B"]

这类问题在 UI 自动化、接口自动化里也很常见,比如:

  • 同一个账号被多个用例复用
  • 测试订单没有清理
  • 共享数据库记录没有隔离

本质上都一样:测试之间不独立


构建一个简单的 Flaky Test 检测脚本

下面这个脚本会重复运行某个测试文件多次,然后统计哪些用例在同一轮次中既通过又失败。

# tools/flaky_detector.py
import subprocess
import json
import re
from collections import defaultdict

TEST_TARGET = "tests"
RUNS = 10

def run_pytest():
    cmd = ["pytest", TEST_TARGET, "-q"]
    result = subprocess.run(cmd, capture_output=True, text=True)
    return result.returncode, result.stdout + "\n" + result.stderr

def parse_failed_cases(output):
    failed = set()
    for line in output.splitlines():
        # 兼容 pytest 简短输出
        m = re.search(r"FAILED\s+([^\s]+::[^\s]+)", line)
        if m:
            failed.add(m.group(1))
    return failed

def main():
    case_stats = defaultdict(lambda: {"pass": 0, "fail": 0})

    for i in range(RUNS):
        code, output = run_pytest()
        failed_cases = parse_failed_cases(output)

        # 通过收集所有测试标识更稳,这里做简化处理
        print(f"Run {i+1}/{RUNS}, exit_code={code}")

        # 简单做法:从输出中提取所有 case 节点
        all_cases = set(re.findall(r"([^\s]+::test_[^\s]+)", output))

        for case in all_cases:
            if case in failed_cases:
                case_stats[case]["fail"] += 1
            else:
                case_stats[case]["pass"] += 1

    suspects = {}
    for case, stat in case_stats.items():
        if stat["pass"] > 0 and stat["fail"] > 0:
            suspects[case] = stat

    print("\n=== Suspected Flaky Tests ===")
    print(json.dumps(suspects, indent=2, ensure_ascii=False))

if __name__ == "__main__":
    main()

运行方式:

python tools/flaky_detector.py

如果某个用例结果类似这样:

{
  "tests/test_flaky_async.py::test_async_job_done_with_fixed_sleep": {
    "pass": 4,
    "fail": 6
  }
}

那它就是非常典型的疑似 flaky。


从“单个修复”到“持续治理体系”

只修几个失败用例,价值有限。更重要的是形成制度化闭环。

一套实用流程

sequenceDiagram
    participant Dev as 开发
    participant CI as CI流水线
    participant Detector as Flaky检测器
    participant Owner as 用例负责人
    participant Board as 治理看板

    Dev->>CI: 提交代码触发测试
    CI->>Detector: 上报测试结果
    Detector->>Detector: 重复执行/计算波动率
    Detector->>Owner: 标记疑似Flaky
    Owner->>Owner: 分类、修复、补日志
    Owner->>CI: 提交修复
    CI->>Board: 更新稳定性指标

建议落地的治理规则

1. 引入“疑似 flaky”状态,而不是二元判断

不要一上来就说“这个用例就是 flaky”。可以设置状态机:

stateDiagram-v2
    [*] --> Stable
    Stable --> SuspectedFlaky: 多次波动
    SuspectedFlaky --> ConfirmedFlaky: 重复执行复现
    ConfirmedFlaky --> InFix: 指派修复
    InFix --> Stable: 修复验证通过
    InFix --> Quarantined: 短期无法修复
    Quarantined --> InFix: 重新纳入治理

这样做的好处是:

  • 减少误判
  • 方便追踪治理进度
  • 能做指标分层统计

2. 给用例绑定负责人

没有 owner,Flaky Test 很容易变成“大家都知道,但没人动”。

建议最少做到:

  • 用例归属到模块
  • 模块归属到负责人
  • flaky 超过阈值自动建单

3. 设置隔离区(Quarantine),但要有退出机制

有些脆弱用例短期确实修不了,比如依赖第三方环境。
这时可以先从主发布门禁中隔离,避免持续污染流水线。

但必须满足:

  • 隔离有时限
  • 隔离用例单独跑
  • 有看板统计数量和滞留时长

否则隔离区最后会变成“测试坟场”。


常见坑与排查

下面这些坑,基本是稳定性治理里的高频项。

坑 1:把失败都归咎于环境

很多团队一看到测试不稳定,就说“CI 机器不行”。
环境当然会导致问题,但环境只是放大器,不一定是根因

排查顺序建议:

  1. 先看是否固定 sleep
  2. 再看是否共享状态
  3. 再看是否依赖随机值/时间
  4. 最后再查环境资源、网络、容器负载

坑 2:自动重跑掩盖问题

如果流水线配置成失败自动重跑 3 次,并且最终通过就算成功,那会直接导致:

  • flaky 被掩埋
  • 没有治理动力
  • 测试时长翻倍

更合理的做法是:

  • 重跑可以有
  • 但要记录“首次失败、重跑通过”
  • 这种结果应该进入 flaky 指标

坑 3:日志不够,根本没法定位

我踩过一个坑:某接口测试偶发超时,但日志只有一句 AssertionError。最后排查花了两天。
后来补齐这些信息后,定位效率明显提高:

  • 请求参数
  • 响应摘要
  • 执行时长
  • 线程/进程信息
  • 环境标识
  • 重试次数
  • 失败时截图/trace

坑 4:并行执行放大共享资源问题

单机串行跑全绿,并行跑就随机红,十有八九是:

  • 共享账号
  • 共享数据库记录
  • 共享端口
  • 临时文件重名

排查要点:

  • 给每个 worker 独立数据命名空间
  • 使用随机但可追踪的 test data id
  • 避免全局单例可变状态

坑 5:断言写得太“紧”

例如:

  • 精确断言文案全部内容
  • 断言列表顺序固定
  • 断言接口耗时一定小于非常小的阈值
  • 断言动态字段完全一致

这类断言不是“严格”,而是“脆”。


安全/性能最佳实践

稳定性治理不只是质量问题,也会和安全、性能直接相关。

安全最佳实践

1. 不要在日志里打印敏感信息

为了排查 flaky,很多人会把请求/响应全打出来。但如果里面有:

  • Token
  • 用户手机号
  • 身份证号
  • 密码
  • Cookie

那就有安全风险。

建议做日志脱敏:

# tools/log_mask.py
def mask_sensitive(text: str) -> str:
    if not text:
        return text
    text = text.replace("Bearer secret-token", "Bearer ***")
    text = text.replace("13800138000", "138****8000")
    return text

2. 测试环境账号权限最小化

不要为了方便,给自动化测试一个超级管理员账号。
否则测试脚本一旦失控,可能误删大量数据。

3. 隔离测试数据与生产数据

听起来像常识,但现实里真有人把回归脚本打到了生产库。
至少要做到:

  • 环境变量明确区分
  • 数据库连接串加白名单校验
  • 高危环境默认禁止 destructive 操作

性能最佳实践

1. 不要无限重试

等待条件时,应该有:

  • timeout
  • interval
  • 明确失败信息

错误示例:

# bad example
while not done:
    pass

正确示例:

# good example
import time

def wait_until(predicate, timeout=2.0, interval=0.05):
    start = time.time()
    while time.time() - start < timeout:
        if predicate():
            return True
        time.sleep(interval)
    return False

2. 把“稳定性成本”指标化

建议至少看这几个指标:

  • 测试总失败率
  • 首次失败后重跑通过率
  • 疑似 flaky 用例数
  • 隔离区用例数
  • 平均修复时长
  • CI 平均耗时

3. 分层运行测试

不是所有测试都该进主门禁。建议按层拆分:

  • L1:纯单元测试,必须快且稳定
  • L2:集成测试,控制外部依赖
  • L3:端到端测试,数量少但覆盖关键链路

端到端测试天然更容易 flaky,所以不要把大量不稳定的大而全用例都堆进主干流水线。


一套可执行的治理落地建议

如果你准备在团队里推动这件事,我建议从下面这套“轻量版”开始,别一开始就搞太重。

第 1 步:先量化,不争论

先收集两周数据:

  • 哪些用例首次失败后重跑通过
  • 哪些模块 flaky 最多
  • 哪些失败类型最常见

有了数据,推动治理会容易很多。

第 2 步:建立 Top N 名单

按影响排序,不要平均用力。优先处理:

  1. 主干门禁里最常失败的用例
  2. 核心业务链路相关用例
  3. 修复成本低但收益高的问题

第 3 步:修测试代码,不是只加重试

优先修复方向:

  • 去掉固定 sleep
  • 清理共享状态
  • 控制随机源和时间源
  • 替换脆弱选择器/断言
  • 补充日志与 trace

第 4 步:建立隔离和回归机制

短期修不了的:

  • 进 quarantine
  • 单独看板
  • 每周清理

第 5 步:把稳定性纳入质量指标

比如:

  • 每个模块每月 flaky 新增数
  • 已确认 flaky 平均关闭时长
  • 发布门禁首次通过率

只有进入指标,治理才会持续。


总结

Flaky Test 的本质不是“偶发失败”,而是测试系统失去确定性
而稳定性治理的目标,也不是追求“永不失败”,而是让失败变得:

  • 可解释
  • 可复现
  • 可修复
  • 可度量

你可以把本文的方法浓缩成一句话:

先识别脆弱用例,再按类型归因修复,最后用数据和流程把治理固化下来。

如果你现在就想开始,我建议先做三件事:

  1. 跑一轮重复执行,找出 Pass/Fail 波动最大的 Top 10 用例
  2. 优先清理固定 sleep、共享状态、随机依赖
  3. 把“首次失败后重跑通过”纳入 CI 指标,而不是当成没事发生

边界条件也要说清楚:

  • 如果你的测试高度依赖第三方不可控环境,短期不可能做到完全稳定
  • 如果团队没有 owner 机制,治理很容易半途而废
  • 如果只靠 rerun,不修测试设计,稳定性只会越来越差

真正有效的治理,往往不是某个高级工具,而是一套可重复执行的工程纪律
把这套纪律建立起来,自动化测试才会重新变成团队能信、敢依赖的质量防线。


分享到:

上一篇
《从 0 到 1 搭建企业级开源项目评估清单:许可证、社区活跃度与可维护性的实战方法》
下一篇
《区块链智能合约安全实战:从常见漏洞分析到 Solidity 审计流程落地》