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

《自动化测试中的稳定性治理实战:从脆弱用例识别到失败重试与根因定位》

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

自动化测试中的稳定性治理实战:从脆弱用例识别到失败重试与根因定位

自动化测试做久了,大家都会遇到一个很现实的问题:不是没有测试,而是测试不稳定,导致团队不信测试结果

我见过不少项目,CI 上一片红,开发和测试第一反应不是修代码,而是先问一句:“这次又是误报吧?”一旦走到这一步,自动化测试的价值基本已经被打了折扣。稳定性治理,真正要解决的就是这个问题:让失败更可信,让波动可观测,让问题能定位。

这篇文章不讲空泛理念,而是按“识别脆弱用例 → 做失败重试 → 建立根因定位链路”的顺序,带你搭一套能落地的稳定性治理方案。


背景与问题

自动化测试中的“不稳定”,通常表现为:

  • 同一份代码,测试一会儿过、一会儿不过
  • 重跑就绿,隔天又红
  • 失败日志看起来都差不多,但每次根因都不一样
  • 测试平台里失败很多,真正需要处理的问题却筛不出来

这类问题常被统称为 Flaky Test(脆弱/漂移用例)。它的危害比“偶发失败”大得多:

  1. 降低团队对测试结果的信任
  2. 增加排查时间
  3. 污染发布信号
  4. 掩盖真实缺陷

很多团队上来就加重试,短期看确实“绿了不少”,但如果没有识别机制和根因定位,重试只是在把噪音藏起来。治理稳定性,不能只靠“多跑几次”。


前置知识与环境准备

本文示例使用 Python,依赖尽量保持简单:

  • Python 3.9+
  • pytest
  • requests
  • pytest-rerunfailures(演示重试思路,也会给出自定义实现)
  • 一份测试结果历史记录文件(JSON)

安装依赖:

pip install pytest requests pytest-rerunfailures

建议准备一个目录结构:

stable-testing-demo/
├─ app/
│  └─ fake_service.py
├─ tests/
│  ├─ test_api.py
│  └─ test_ui_like.py
├─ tools/
│  ├─ flaky_detector.py
│  └─ failure_classifier.py
└─ history/
   └─ test_results.json

核心原理

稳定性治理可以拆成三层:

  1. 识别层:哪些用例不稳定?
  2. 控制层:失败后如何有限度重试?
  3. 诊断层:失败后怎么快速知道是环境、数据、脚本还是产品缺陷?

如果把它画成一条链路,大概是这样:

flowchart LR
    A[测试执行] --> B[结果采集]
    B --> C[脆弱用例识别]
    C --> D[失败重试策略]
    D --> E[失败日志与上下文聚合]
    E --> F[根因分类]
    F --> G[告警/修复/隔离]

1. 脆弱用例识别的核心指标

判断一个用例是不是脆弱,不能只看“失败过没有”,而要看历史表现:

  • 失败率:最近 N 次执行失败占比
  • 波动率:相邻执行状态变化频繁程度
  • 重试成功率:失败后重试通过的比例
  • 环境相关性:是否只在某些机器、时间段、数据集失败
  • 耗时异常:失败前耗时是否显著升高

一个很实用的经验是:

“失败后重跑经常通过”的用例,不一定是好消息,反而大概率是脆弱用例。

2. 失败重试不是“无脑再跑”

重试的目标不是美化报表,而是区分:

  • 瞬时噪音:网络抖动、资源竞争、短时超时
  • 确定性失败:代码逻辑错误、断言错误、接口契约变更

所以重试要遵循几个原则:

  • 只对特定失败类型重试
  • 限制次数
  • 记录每次失败上下文
  • 最终保留首错信息和重试轨迹
  • 不能把重试后的成功当成“完全成功”

3. 根因定位需要“结构化失败信息”

排查慢,很多时候不是因为问题难,而是因为日志太散。

根因定位至少要采集:

  • 测试名
  • 执行时间
  • 执行节点/容器
  • 依赖服务状态
  • 请求响应摘要
  • 异常栈
  • 截图/页面状态(UI)
  • 重试次数
  • 环境变量版本信息

一个简单的失败分类模型:

classDiagram
    class FailureEvent {
        +test_name
        +error_type
        +message
        +node
        +duration
        +retry_count
        +timestamp
    }

    class RootCauseClassifier {
        +classify(event)
    }

    class Categories {
        <<enumeration>>
        ENVIRONMENT
        DATA
        SCRIPT
        PRODUCT_BUG
        UNKNOWN
    }

    RootCauseClassifier --> FailureEvent
    RootCauseClassifier --> Categories

稳定性治理的落地步骤

这里我建议用一个很实用的四步法。

第一步:先建立“可观测性”,别急着优化

很多团队一开始就想“怎么把通过率提上去”,但如果你还不知道失败都来自哪里,优化往往会打偏。

建议先记录每次执行结果:

  • 用例名
  • 是否通过
  • 执行耗时
  • 错误类型
  • 执行环境
  • 是否重试
  • 重试后是否成功

第二步:从历史数据中识别脆弱用例

可以按最近 20~50 次执行来计算一个脆弱分数。比如:

  • 失败率 > 10%
  • 状态切换次数 > 3
  • 重试成功率 > 50%

满足两项以上,就标为高风险脆弱用例。

第三步:对“可恢复失败”做有限重试

适合重试的场景通常有:

  • 网络读超时
  • 服务瞬时 502/503
  • UI 元素短暂无响应
  • 异步任务尚未完成

不适合重试的场景:

  • 断言值明显错误
  • 参数校验失败
  • 数据库约束异常
  • 接口返回稳定的业务失败

第四步:建立分类与止血机制

你不能指望每个失败都人工看日志。至少应该做到:

  • 自动把失败归到几个大类
  • 对脆弱用例打标签
  • 高波动用例进入隔离池
  • 环境问题触发平台告警
  • 真正的产品缺陷单独统计

Mermaid:失败重试与根因定位时序

下面这张图比较贴近 CI 流水线里的真实过程:

sequenceDiagram
    participant CI as CI流水线
    participant T as 测试执行器
    participant R as 重试策略器
    participant L as 日志聚合器
    participant C as 根因分类器

    CI->>T: 触发测试执行
    T-->>CI: 首次失败
    T->>L: 上传首错日志/上下文
    T->>R: 判断是否允许重试
    alt 可重试
        R-->>T: 执行重试
        T-->>L: 上传重试结果
        T->>C: 提交失败事件
        C-->>CI: 输出根因分类
    else 不可重试
        T->>C: 提交失败事件
        C-->>CI: 输出根因分类
    end

实战代码(可运行)

下面用一个简单但完整的示例,把识别、重试、分类串起来。

1. 构造一个“偶发失败”的服务

app/fake_service.py

import random
import time


class TransientError(Exception):
    pass


class BusinessError(Exception):
    pass


def unstable_fetch(user_id: int) -> dict:
    time.sleep(0.1)

    n = random.random()

    # 20% 概率瞬时失败,适合重试
    if n < 0.2:
        raise TransientError("upstream timeout")

    # 10% 概率业务失败,不适合重试
    if n < 0.3:
        raise BusinessError("user status invalid")

    return {"user_id": user_id, "status": "ok"}

这个例子故意模拟了两种失败:

  • TransientError:瞬时问题,可重试
  • BusinessError:业务问题,不可重试

2. 在测试中实现“有条件重试”

tests/test_api.py

from app.fake_service import unstable_fetch, TransientError, BusinessError


def retry_call(func, retries=2, allowed_exceptions=(Exception,)):
    errors = []

    for attempt in range(retries + 1):
        try:
            return func(), errors
        except allowed_exceptions as e:
            errors.append(f"attempt={attempt + 1}, error={type(e).__name__}: {e}")
            if attempt == retries:
                raise
        except Exception:
            # 不在允许重试范围内,直接抛出
            raise


def test_fetch_user_profile():
    def action():
        result = unstable_fetch(1001)
        assert result["status"] == "ok"
        return result

    result, errors = retry_call(
        action,
        retries=2,
        allowed_exceptions=(TransientError,)
    )

    print("retry_logs:", errors)
    assert result["user_id"] == 1001


def test_business_error_should_fail_fast():
    def action():
        result = unstable_fetch(1002)
        assert result["status"] == "ok"
        return result

    try:
        result, errors = retry_call(
            action,
            retries=2,
            allowed_exceptions=(TransientError,)
        )
        assert result["user_id"] == 1002
    except BusinessError:
        # 业务错误应快速失败,不继续重试
        assert True

这个实现有几个要点:

  • 只对 TransientError 重试
  • 业务异常直接失败
  • 保留每次重试日志,便于后续定位

运行:

pytest -s tests/test_api.py

3. 基于历史结果识别脆弱用例

假设我们有一份历史执行结果:history/test_results.json

[
  {"test_name": "test_fetch_user_profile", "status": "passed"},
  {"test_name": "test_fetch_user_profile", "status": "failed"},
  {"test_name": "test_fetch_user_profile", "status": "passed"},
  {"test_name": "test_fetch_user_profile", "status": "failed"},
  {"test_name": "test_fetch_user_profile", "status": "passed"},
  {"test_name": "test_business_error_should_fail_fast", "status": "failed"},
  {"test_name": "test_business_error_should_fail_fast", "status": "failed"},
  {"test_name": "test_business_error_should_fail_fast", "status": "failed"}
]

编写识别脚本:tools/flaky_detector.py

import json
from collections import defaultdict


def calc_flaky_score(records):
    grouped = defaultdict(list)
    for r in records:
        grouped[r["test_name"]].append(r["status"])

    result = []

    for test_name, statuses in grouped.items():
        total = len(statuses)
        fail_count = sum(1 for s in statuses if s == "failed")
        fail_rate = fail_count / total if total else 0

        switch_count = 0
        for i in range(1, len(statuses)):
            if statuses[i] != statuses[i - 1]:
                switch_count += 1

        flaky = fail_rate > 0.1 and switch_count >= 2

        result.append({
            "test_name": test_name,
            "total_runs": total,
            "fail_rate": round(fail_rate, 2),
            "switch_count": switch_count,
            "is_flaky": flaky
        })

    return sorted(result, key=lambda x: (-x["is_flaky"], -x["fail_rate"]))


if __name__ == "__main__":
    with open("history/test_results.json", "r", encoding="utf-8") as f:
        records = json.load(f)

    for item in calc_flaky_score(records):
        print(item)

运行:

python tools/flaky_detector.py

你会看到 test_fetch_user_profile 更像是脆弱用例,而连续稳定失败的 test_business_error_should_fail_fast 反而更像真实缺陷。

这点很关键:

会抖动的失败,优先治理稳定性;稳定复现的失败,优先排查产品或脚本缺陷。


4. 自动做失败根因分类

tools/failure_classifier.py

def classify_failure(error_type: str, message: str) -> str:
    text = f"{error_type} {message}".lower()

    if "timeout" in text or "connection" in text or "502" in text or "503" in text:
        return "ENVIRONMENT"

    if "not found test data" in text or "unique constraint" in text or "foreign key" in text:
        return "DATA"

    if "element not found" in text or "stale element" in text or "assert" in text:
        return "SCRIPT"

    if "status invalid" in text or "business" in text:
        return "PRODUCT_BUG"

    return "UNKNOWN"


if __name__ == "__main__":
    samples = [
        ("TransientError", "upstream timeout"),
        ("IntegrityError", "unique constraint violated"),
        ("AssertionError", "assert 200 == 500"),
        ("BusinessError", "user status invalid")
    ]

    for error_type, message in samples:
        category = classify_failure(error_type, message)
        print(error_type, "=>", category)

运行:

python tools/failure_classifier.py

这个分类器很朴素,但已经能解决很多第一层筛选问题。实际生产中你可以进一步增强:

  • 引入正则规则库
  • 结合失败堆栈
  • 结合节点监控数据
  • 结合截图和 DOM 快照
  • 最后再接机器学习分类

5. 用 pytest 插件能力接入结果采集

如果你希望把失败事件自动输出成结构化数据,可以使用 pytest_runtest_makereport

tests/conftest.py

import json
import os
from datetime import datetime


RESULT_FILE = "history/runtime_results.json"


def pytest_runtest_makereport(item, call):
    if call.when != "call":
        return

    os.makedirs("history", exist_ok=True)

    record = {
        "test_name": item.name,
        "status": "passed" if call.excinfo is None else "failed",
        "timestamp": datetime.utcnow().isoformat() + "Z"
    }

    if call.excinfo is not None:
        record["error_type"] = call.excinfo.type.__name__
        record["message"] = str(call.excinfo.value)

    records = []
    if os.path.exists(RESULT_FILE):
        with open(RESULT_FILE, "r", encoding="utf-8") as f:
            try:
                records = json.load(f)
            except json.JSONDecodeError:
                records = []

    records.append(record)

    with open(RESULT_FILE, "w", encoding="utf-8") as f:
        json.dump(records, f, ensure_ascii=False, indent=2)

这样每次 pytest 执行后,都会沉淀一份结构化结果。后续不管是做报表、脆弱识别还是根因分类,都有基础数据可用。


逐步验证清单

如果你准备在团队里真正推这套方案,我建议按下面的顺序验证,不要一步到位:

第 1 阶段:先看见问题

  • 所有测试执行结果能结构化落盘
  • 能统计每个用例最近 20 次通过/失败情况
  • 能区分“稳定失败”和“波动失败”

第 2 阶段:再做失败控制

  • 只对白名单异常重试
  • 重试次数不超过 2~3 次
  • 保留首错日志
  • 报表中显示“重试后通过”

第 3 阶段:最后做根因治理

  • 失败自动分类到环境/数据/脚本/产品
  • 高波动用例进入专项治理列表
  • 环境类失败可关联监控
  • 脚本类失败能回溯到具体断言和页面状态

常见坑与排查

这部分我尽量讲一些真实容易踩的坑。

1. 把重试当稳定性治理本身

这是最常见的误区。

表现是:

  • 通过率很高
  • 但平均执行时长越来越长
  • 重试次数越来越多
  • 团队还是不信结果

原因在于:你只是让 CI 更绿了,但没减少真实波动。

排查建议:

  • 单独统计“首轮通过率”和“重试后通过率”
  • 如果某些用例重试后通过占比很高,优先治理它们
  • 不要只看最终通过率

2. 所有异常都重试

这会把真正的产品缺陷也拖成“偶发问题”。

例如:

  • AssertionError
  • ValueError
  • 业务状态错误
  • 数据约束错误

这些通常不该重试。

排查建议:

  • 建立异常白名单,而不是黑名单
  • 优先允许网络、超时、临时不可用类异常重试
  • 断言失败默认不重试,除非你明确知道它属于异步一致性场景

3. 忽略测试数据污染

很多“脆弱用例”本质上不是环境问题,而是数据没隔离。

典型现象:

  • 单跑通过,串行也通过
  • 并发跑就失败
  • 换环境失败模式不同

排查建议:

  • 为每次执行生成唯一测试数据
  • 清理前置状态
  • 避免多个用例共享账号、订单、库存等资源

4. UI 自动化里的等待策略错误

我当时踩过一个坑:页面元素确实会出现,但测试用了固定 sleep(2)。环境快的时候没问题,环境稍慢就失败,导致一堆“偶发红”。

更好的做法:

  • 用显式等待替代固定睡眠
  • 等待“状态达成”,不要等待“时间过去”
  • 失败时保存 DOM、截图和浏览器 console

5. 日志很多,但没有上下文

只看到一个异常堆栈,往往不够。

比如接口测试失败,如果不知道:

  • 请求参数
  • 响应体
  • 调用节点
  • 上游依赖状态

那排查效率会很低。

排查建议:

  • 为失败事件补充上下文字段
  • 日志要能按 test_name + build_id + retry_index 聚合
  • 首错信息优先保留,不要被最后一次重试覆盖

安全/性能最佳实践

稳定性治理不只是“测得准”,还要“跑得安全、跑得经济”。

安全最佳实践

1. 日志脱敏

失败日志里常常会带:

  • token
  • 手机号
  • 邮箱
  • 身份证号
  • cookie
  • 数据库连接串

建议在采集层统一脱敏。

示例:

import re


def mask_sensitive(text: str) -> str:
    text = re.sub(r'Bearer\s+[A-Za-z0-9\-_\.]+', 'Bearer ***', text)
    text = re.sub(r'1[3-9]\d{9}', '***********', text)
    text = re.sub(r'[\w\.-]+@[\w\.-]+', '***@***', text)
    return text

2. 控制失败附件大小

截图、HAR、页面源码、接口报文一多,存储会迅速膨胀。

建议:

  • 只对失败用例保存附件
  • 对重试过程只保留首错和末错
  • 设置日志保留周期

性能最佳实践

1. 限制重试开销

重试是有成本的,特别是在大规模回归中。

建议:

  • 每个用例最多重试 1~2 次
  • 仅对高价值关键路径开启重试
  • 对长期脆弱用例单独治理,不要无限续命

2. 并发下控制资源争用

测试并发度高时,CPU、网络、测试账号池、浏览器实例都可能成为不稳定源。

建议:

  • 监控执行节点负载
  • 限制单节点并发数
  • 对共享资源做配额隔离
  • 将环境问题与脚本问题分开统计

3. 做分层治理

不是所有测试都值得投入同样治理成本。

可按优先级分层:

  • P0:发布阻断用例,重点治理,允许精细重试
  • P1:核心回归用例,监控波动趋势
  • P2:普通覆盖用例,波动高时可暂时隔离

一个可落地的治理策略模板

如果你要在团队里推,我建议从下面这份策略起步:

1. 每次测试执行必须产出结构化结果
2. 每周统计一次脆弱用例 Top N
3. 仅对白名单异常进行最多 2 次重试
4. 报表中区分:
   - 首次通过
   - 重试后通过
   - 最终失败
5. 失败自动分类:
   - 环境
   - 数据
   - 脚本
   - 产品
   - 未知
6. 连续两周高波动的用例进入专项治理
7. 无法短期修复的脆弱用例,隔离但不删除

这里有个边界条件要强调:

如果你的测试环境本身经常不可用,那么稳定性治理的重点应该先放在环境治理,而不是一味优化测试脚本。


总结

自动化测试的稳定性治理,本质上不是“把红灯变绿”,而是建立一套可信、可观测、可诊断的测试体系。

你可以记住这三件事:

  1. 先识别脆弱用例
    不要只看失败次数,要看历史波动和重试行为。

  2. 再做有限重试
    只对可恢复异常重试,保留首错信息,别掩盖真实缺陷。

  3. 最后做根因定位
    用结构化数据把失败归因到环境、数据、脚本或产品,缩短排查链路。

如果你现在就想开始落地,我建议第一周只做一件事:把测试结果结构化记录下来
有了数据,后面的脆弱识别、重试策略和根因分类才有抓手。没有数据,所谓治理很容易变成拍脑袋。

稳定性治理不是一蹴而就的,但只要方向对,团队会很快感受到变化:CI 更可信,报警更少,排查更快,自动化测试终于不再只是“看起来很美”。


分享到:

上一篇
《Java 中基于 CompletableFuture 与线程池隔离的异步任务编排实战:性能优化、超时控制与异常治理》
下一篇
《从 Prompt 到 Workflow:面向中级开发者的 AI Agent 实战设计与落地指南》