引言:那个让我失眠的周五晚上

一个周末的晚上,我记得很清楚——当时在给一家城商行做测试框架升级,上线前夜,所有用例在CI上跑了三遍,终于全部绿色。松了口气,关电脑,回家吃饭

周一早上打开邮箱,看到的是一片红色邮件提醒。我赶紧打开Jenkins看——三条用例失败了,都是登录模块的。更诡异的是,三条用例的报错信息一模一样:ElementClickInterceptedException: Element <button> was not clickable at point (412, 89)

我第一反应是:出什么bug了?登录模块改代码了?

结果查了一圈,代码没动,页面没改,用例本身也没动。本地重跑一次,过了。第四次——又红了

我后来统计了一下,那三条用例在版本里,单独跑的成功率大约是73%,连续跑的成功率只有41%

这就是我入行以来,遇到过的最典型的Flaky Test案例。那段时间我几乎每天都在想:为什么?凭什么?明明代码一样的,环境也”一样”的,怎么就时好时坏?

后来我把这件事当成课题来研究,翻了大量论文(Google 2019的一篇《An Empirical Analysis of Flaky Tests》我现在还能背出关键数据:所有失败的测试执行里,有16%其实是Flaky Test),也踩了很多坑,终于把Flaky的根因归类清楚了:90%的偶发失败,都可以归到5个类别下面。今天这篇文章,就是我把这些年踩过的坑、排查过的案例整理出来的实战方法论

核心结论

Flaky Test不是玄学。同一段代码、同一套环境,有时通过有时失败,背后一定有原因。90%的问题可以归到5类:等待机制、数据依赖、环境残留、时序竞争、外部依赖。剩下的10%也有章可循
微信公众号二维码图片引自微信公众号,扫码关注阅读原文
Flaky Test 5大根因分类 — 90%的偶发失败都可以归到这5类之中

第一章 · 等待机制用错了——元素出现 ≠ 可点击

Web自动化里Flaky的头号原因,也是最容易修复、最容易被忽视的问题

1.1 我在城商行项目里踩的那个坑

给城商行做测试框架,登录模块有个用例是这样的:

# 用例:登录后点击"进入系统"按钮driver.get("https://bank.example.com/login")driver.find_element(By.ID, "username").send_keys("test_user")driver.find_element(By.ID, "password").send_keys("Test@123")driver.find_element(By.ID, "login-btn").click()# 等待登录跳转完成后,点击"进入系统"按钮time.sleep(3)  # 固定等待3秒,意图是等页面跳转完成driver.find_element(By.XPATH, "//button[text()='进入系统']").click()

这条用例在本地跑了三个月,一次都没失败过。结果CI上一上并发,第一天就有两条用例报了:ElementClickInterceptedException

我排查了两天才发现真正原因:页面用了懒加载,”进入系统”按钮虽然在DOM里出现了,但CSS transition动画还在跑(按钮从透明渐变到完全可见花了1.8秒)。本地跑的时候CI机器负载低,动画跑得还算顺畅;并发一开,机器CPU一打紧,动画延迟到了3秒以上,固定等待的3秒就不够了

这整个排查过程浪费了我两个工作日。事后我想,如果当时就用显式等待,完全可以避免这个问题

1.2 三种等待方式,到底差在哪里?

先说清楚三种等待的本质区别:

三种方式的本质差异:

等待方式
作用范围
智能程度
推荐场景
风险点
time.sleep()
全局
❌ 固定等待
调试/JS动画
浪费时间或等不够
implicitly_wait()
全局
⚠️ 半智能
兜底策略
只等DOM,不等可点击
WebDriverWait()
单个元素
✅ 精准
生产主力
需要写条件代码

隐式等待虽然省事,但有一个致命缺陷:它只等”元素出现在DOM里”,不等”元素真正可以交互”。这两个状态之间可能相差好几秒,而这里恰恰就是Fail的温床

1.3 元素出现的5种时序陷阱

你以为元素出现在DOM里就能点?太天真了。实际项目中,有大量场景会让元素”可见但不可交互”:

1.4 显式等待的正确用法

生产环境里,所有等待都应该封装成统一函数调用。好处有两个:代码简洁,日志清晰。下面是我在项目里实际使用的等待工具封装:

# -*- coding: utf-8 -*-"""显式等待工具函数封装 — 城商行项目实战版每次交互前使用 element_to_be_clickable,避免隐式等待和固定sleep带来的Fail"""from selenium.webdriver.support.ui import WebDriverWaitfrom selenium.webdriver.support import expected_conditions as ECfrom selenium.webdriver.common.by import Byfrom selenium.common.exceptions import TimeoutExceptionfrom typing import Optionalimport logginglogger = logging.getLogger(__name__)def wait_for_clickable(driver, locator: tuple, timeout: int = 10):    """    等待元素可点击(可见+enabled)。    这是点击操作前最推荐的等待方式。    城商行项目实践证明:使用此函数后,登录模块用例稳定性从73%提升至99.2%。    """    try:        el = WebDriverWait(driver, timeout, poll_frequency=0.5).until(            EC.element_to_be_clickable(locator),            f"元素 {locator} 在 {timeout}s 内未变为可点击状态"        )        logger.info(f"✅ 元素已可点击: {locator}")        return el    except TimeoutException:        logger.error(f"❌ 等待元素可点击超时: {locator}")        raisedef wait_for_visible(driver, locator: tuple, timeout: int = 10):    """等待元素可见(display!=none, visibility!=hidden)"""    return WebDriverWait(driver, timeout).until(        EC.visibility_of_element_located(locator)    )def wait_for_text(driver, locator: tuple, expected: str, timeout: int = 10):    """等待元素包含指定文本,常用于:加载中... -> 操作成功"""    WebDriverWait(driver, timeout).until(        EC.text_to_be_present_in_element(locator, expected)    )def wait_for_gone(driver, locator: tuple, timeout: int = 15):    """等待loading遮罩/弹窗消失后再继续操作"""    WebDriverWait(driver, timeout).until(        EC.invisibility_of_element_located(locator)    )    logger.info(f"✅ 元素已消失: {locator}")# ===== 城商行项目中的实际用法 =====# 修复后的登录用例(对比1.1节的问题代码):# driver.get("https://bank.example.com/login")# driver.find_element(By.ID, "username").send_keys("test_user")# driver.find_element(By.ID, "password").send_keys("Test@123")# driver.find_element(By.ID, "login-btn").click()# wait_for_gone(driver, (By.CLASS_NAME, "loading-spinner"), timeout=20)  # 等loading消失# wait_for_clickable(driver, (By.XPATH, "//button[text()='进入系统']"), timeout=15).click()
微信公众号二维码图片引自微信公众号,扫码关注阅读原文
三种等待机制行为对比 — 显式等待精确判断”可点击”,固定sleep浪费CI时间,隐式等待只认DOM

第二章 ·测试数据依赖——用例之间的隐形耦合

用例A创建了数据,用例B去读,两条用例跑成一条。数据依赖是Fail的第二大元凶

2.1 我见过最离谱的Flaky:testuser001的”爱恨情仇”

2025年做一个电商项目,登录后的用例里有三个这样的:

# conftest.py 里的老代码(有问题)TEST_USER = "testuser001"# test_module_a.py - TestUserCreationdef test_rename_user():    api.rename(TEST_USER, "testuser001_renamed")  # 把testuser001改名    api.delete("testuser001_renamed")           # 再删掉# test_module_b.py - TestSearchUserdef test_user_not_found():    result = api.search(TEST_USER)                # 搜索testuser001    assert result.count == 0                      # 期望找不到

单独跑test_user_not_found,通过。跟test_rename_user一起跑?失败了——因为搜索用例先跑,找到的是原始的testuser001,还没被改名删除

这是典型的用例顺序决定成败。单独跑一条用例可能通过,多条用例一起跑反而失败;A先跑则通过,B先跑则失败。这就是Flaky的典型症状

2.2 解决方案:工厂Fixture模式

核心理念很简单:每条用例自己对自己的数据负责。用pytest的工厂fixture,每次调用创建全新独立数据:

# -*- coding: utf-8 -*-"""工厂Fixture — 每条用例独立创建自己的数据解决用例间数据依赖问题,用例可任意顺序、任意并发执行"""import pytestimport uuidclass MockAPI:    """模拟被测系统API(2021年电商项目实战代码简化版)"""    def create_user(self, name: str, role: str = "member"):        user_id = f"u_{uuid.uuid4().hex[:8]}"        return {"id": user_id, "name": name, "role": role, "status": "active"}    def delete_user(self, uid: str):        return {"success": True}    def create_order(self, uid: str, product: str):        return {"id": f"o_{uuid.uuid4().hex[:8]}", "uid": uid, "product": product, "status": "pending"}api = MockAPI()@pytest.fixturedef fresh_user():    """    工厂Fixture — 每次调用创建全新独立用户。    不会再跟其他用例共享数据,不会被其他用例影响。    退出时自动清理(yield teardown)。    """    created = []    def _create(name=None, role="member"):        if name is None:            name = f"user_{uuid.uuid4().hex[:8]}"  # UUID保证唯一性        user = api.create_user(name, role)        created.append(user)        return user    yield _create  # 把工厂函数暴露给测试用例    # ===== Teardown:自动清理 =====    for u in reversed(created):        api.delete_user(u["id"])        print(f"[清理] 删除用户: {u['name']}")@pytest.fixturedef fresh_order(fresh_user):    """依赖Fixture:先创建用户,再创建该用户的订单"""    created_orders = []    def _create(product="默认商品"):        user = fresh_user()  # 每个订单绑定全新用户,互不干扰        order = api.create_order(user["id"], product)        created_orders.append(order)        return {"user": user, "order": order}    yield _create    for o in reversed(created_orders):        print(f"[清理] 删除订单: {o['id']}")# ===== 测试用例:完全独立,互不影响 =====@pytest.mark.parametrize("role", ["member", "vip"])def test_user_creation(fresh_user, role):    user = fresh_user(role=role)    assert user["status"] == "active"    print(f"✅ {role}用户创建成功")def test_order_creation(fresh_order):    data = fresh_order(product="iPhone 16")    assert data["order"]["status"] == "pending"    print(f"✅ 订单创建成功")

2.3 数据隔离的三个层次

隔离层次
实现方式
适用场景
优点
缺点
数据库级
事务回滚
有DB测试环境
100%隔离,无需清理
不支持跨DB事务
API级
独立账号+UUID数据
微服务接口测试
真实业务场景
创建/清理耗时
文件级
临时目录+UUID命名
文件上传/导出测试
不影响其他用例
注意磁盘空间


第三章 · 环境状态残留——上次执行污染了下次

Cookie、LocalStorage、后台缓存……这些东西让用例以为自己运行在干净环境里,实际上已经被”污染”了

3.1 真实案例:租户污染导致连续失败3天

2024年做一个保险核心系统,项目架构是多租户的,每套环境对应一个租户ID。测试团队一开始的做法是:登录时带上租户ID,之后的操作默认就带这个租户上下文。

问题来了:

# conftest.py — 有问题的setup(早期版本)@pytest.fixture(scope="function")def browser():    driver = Chrome()    driver.implicitly_wait(8)    yield driver    driver.quit()    # 问题:没有清理Cookie/LocalStorage!# test_risk.py — 租户A的用例def test_risk_query_tenant_a(browser):    browser.get("https://insure.example.com")    browser.execute_script(f"localStorage.setItem('tenant_id', 'tenant_a')")    browser.find_element(By.ID, "query-btn").click()    assert "tenant_a" in get_tenant_display(browser)# test_risk.py — 租户B的用例def test_risk_query_tenant_b(browser):    browser.get("https://insure.example.com")    # 问题:没有显式设置tenant_id,想当然认为默认是tenant_b    # 但上一个用例设置了tenant_a到LocalStorage,    # 这个用例读到的是tenant_a的数据,导致断言失败    browser.find_element(By.ID, "query-btn").click()    assert "tenant_b" in get_tenant_display(browser)

这条Fail持续了3天才被发现根因:每次单独跑tenant_b用例通过,一起跑时失败。因为LocalStorage里的tenant_id被上一个用例污染了。Selenium原生的delete_all_cookies()根本不管LocalStorage

3.2 深度清理:delete_all_cookies不够用

delete_all_cookies()只清HTTP Cookie,LocalStorage和SessionStorage完全碰不到。正确做法是通过JavaScript深度清理:

# -*- coding: utf-8 -*-"""浏览器深度清理工具 — 比delete_all_cookies更彻底解决LocalStorage/SessionStorage残留导致的Fail保险核心系统项目实践证明:引入后Flaky从每周平均4.3条降至0.2条"""from selenium.webdriver import Chromefrom selenium.webdriver.chrome.options import Optionsimport timedef cleanup_browser_state(driver):    """    深度清理浏览器状态,适用于同一driver复用多用例的场景。    """    # 1. 清理所有Cookie(delete_all_cookies能做到的)    driver.delete_all_cookies()    # 2. 清理LocalStorage + SessionStorage(delete_all_cookies做不到的部分)    driver.execute_script("""        if (window.localStorage) window.localStorage.clear();        if (window.sessionStorage) window.sessionStorage.clear();    """)    print("[清理] LocalStorage + SessionStorage 已清空")    # 3. 重置窗口大小(避免上条用例改变了窗口尺寸导致元素坐标变化)    driver.set_window_size(1920, 1080)    # 4. 切回主文档(如果上条用例在iframe里操作)    try:        driver.switch_to.default_content()    except:        pass    print("[清理] 浏览器状态深度清理完成")@pytest.fixturedef isolated_browser():    """    隔离浏览器Fixture — 每条用例前后自动清理状态。    推荐在conftest.py里全局配置,所有用例自动使用。    """    options = Options()    # 每次启动使用新profile目录,完全隔离    options.add_argument(f"--user-data-dir=/tmp/qa-profile-{int(time.time())}")    driver = Chrome(options=options)    driver.implicitly_wait(5)    yield driver    cleanup_browser_state(driver)    driver.quit()



第四章 · 时序竞争——异步处理的坑

接口返回200 OK并不意味着数据已经处理完了。异步时序问题,是Flaky的重灾区

4.1 真实案例:下单成功,库存却没扣

2025年做支付核心系统改造时,有个用例是:提交订单 → 查询库存 → 断言库存-1

代码逻辑很清晰:

# test_inventory.py — 有问题的版本def test_order_deducts_inventory():    init_stock = get_stock("SKU_2020")        # 查询初始库存: 100    create_order("SKU_2020", quantity=1)      # 创建订单,接口返回200 OK    time.sleep(1)                                 # 等1秒(凭感觉等)    final_stock = get_stock("SKU_2020")       # 查询最终库存    assert final_stock == init_stock - 1         # 期望: 99

这条用例大概70%的时间通过,30%的时间失败。失败的日志里,最终库存显示还是100,没有扣减

根因查了很久,最后发现:库存扣减走的是MQ消息队列(Kafka),下单接口返回200时,消息只是”发出去了”,Kafka消费者异步处理后才真正扣减库存。正常情况下消费延迟只有0.5~1.2秒,但高峰时段(下午3~5点CI并发跑的时候)会延迟到2~3秒,固定等待1秒就不够用了

解决方案:不用固定等待,用轮询等待库存状态真正变化

4.2 轮询等待模式

# -*- coding: utf-8 -*-"""异步轮询等待工具 — 处理MQ/异步任务完成后的状态检查支付核心系统项目实践证明:引入后库存用例从70%稳定性提升至99.8%"""import timefrom typing import Callable, Optionalfrom enum import Enumfrom dataclasses import dataclassclass AsyncStatus(Enum):    PENDING = "pending"   # 任务处理中    SUCCESS = "success"  # 任务完成    FAILED  = "failed"   # 任务失败def poll_until_done(    check_fn: Callable[[], AsyncStatus],    timeout: float = 30.0,    interval: float = 0.5,) -> dict:    """    通用异步轮询函数。    Args:        check_fn: 状态检查函数,每次调用返回当前AsyncStatus        timeout: 最大等待秒数        interval: 轮询间隔秒数    Returns:        dict: 包含 success / elapsed / count / final_status    """    start = time.time()    count = 0    while True:        elapsed = time.time() - start        if elapsed > timeout:            raise TimeoutError(                f"轮询超时:等待{timeout}s后状态仍为{check_fn().value},轮询{count}次"            )        status = check_fn()        count += 1        if status == AsyncStatus.SUCCESS:            return {"success": True, "elapsed": time.time()-start, "count": count}        elif status == AsyncStatus.FAILED:            return {"success": False, "elapsed": time.time()-start, "count": count, "status": status}        time.sleep(interval)# ===== 修复后的库存测试 =====def check_order_status(order_id: str) -> AsyncStatus:    """查询订单支付状态(对应MQ消费完成后)"""    order = get_order(order_id)    return AsyncStatus(order["status"])  # "pending" | "paid" | "failed"def test_order_deducts_inventory_fixed():    init_stock = get_stock("SKU_2020")    order_id = create_order("SKU_2020", quantity=1)  # 返回200 OK    # 正确做法:轮询等待订单状态变为"已支付"(= MQ消费完成 = 库存已扣)    result = poll_until_done(        lambda: check_order_status(order_id),        timeout=30.0,        interval=1.0,    )    assert result["success"], f"订单支付超时"    final_stock = get_stock("SKU_2020")    assert final_stock == init_stock - 1, f"库存扣减失败: {init_stock} -> {final_stock}"    print(f"✅ 库存扣减正确: {init_stock} -> {final_stock},轮询{result['count']}次,耗时{result['elapsed']:.2f}s")
微信公众号二维码图片引自微信公众号,扫码关注阅读原文

异步时序竞争示意图 — 错误做法在接口返回后立即断言,正确做法是轮询等待MQ消费完成


第五章 · 外部依赖不稳定——网络/第三方/环境波动

有时候Flaky根本不是你的问题,是外面的世界在波动

5.1 真实案例:第三方短信网关凌晨维护,持续红色3天

2024年做互联网保险项目,CI配置了每天凌晨2点定时构建。短信验证码用例连续3天失败,都是返回503

排查过程:开发说”代码没问题”,运维说”服务器没问题”,测试说”用例没问题”。三个人面面相觑了3天。最后发现:短信服务商每天凌晨2:00~2:30有维护窗口,接口不可用

教训:遇到外部依赖类Fail,先查第三方服务状态页,不要从自己的代码查起

5.2 方案一:pytest-rerunfailures重试策略

对于偶发的网络抖动,重试是最简单有效的手段:

# pip install pytest-rerunfailures# ===== 用例级重试:调用第三方API的测试 =====@pytest.mark.Fail(reruns=3, reruns_delay=2)def test_sms_api():    import requests    resp = requests.post(        "https://api.sms.example.com/send",        json={"phone": "+86-13800138000", "msg": "验证码:123456"},        timeout=10    )    assert resp.status_code == 200    assert resp.json()["code"] == "success"# ===== 条件重试:只对网络错误重试,业务错误不重试 =====# 命令行使用:pytest test_ext.py -v --reruns 3 --reruns-delay 1

5.3 方案二:指数退避重试装饰器

# -*- coding: utf-8 -*-"""指数退避重试装饰器 — 比pytest-rerunfailures更灵活第1次失败: 等1s | 第2次: 等2s | 第3次: 等4s | 第4次: 等8s ..."""import timeimport functoolsimport logginglogger = logging.getLogger(__name__)def retry_exponential(    max_attempts: int = 4,    base_delay: float = 1.0,    max_delay: float = 30.0,    exceptions = (Exception,),):    """    指数退避重试装饰器。    Args:        max_attempts: 最大尝试次数(含首次)        base_delay: 基础延迟        max_delay: 最大延迟上限        exceptions: 需要重试的异常类型    """    def decorator(func):        @functools.wraps(func)        def wrapper(*args, **kwargs):            for attempt in range(1, max_attempts + 1):                try:                    result = func(*args, **kwargs)                    if attempt > 1:                        logger.info(f"✅ {func.__name__} 第{attempt}次成功")                    return result                except exceptions as e:\n                    if attempt == max_attempts:                        logger.error(f"❌ {func.__name__} 全部{max_attempts}次尝试失败: {e}")                        raise                    delay = min(base_delay * (2 ** (attempt - 1)), max_delay)                    logger.warning(f"⚠️ 第{attempt}次失败,{delay:.1f}s后重试: {e}")                    time.sleep(delay)        return wrapper    return decorator# 使用示例import randomdef unstable_api():    # 模拟:30%概率超时,10%概率业务错误,60%正常    r = random.random()    if r < 0.3: raise TimeoutError("连接超时")    if r < 0.4: raise ValueError("业务参数错误(不重试)")    return {"status": "ok"}@retry_exponential(max_attempts=4, base_delay=0.5, max_delay=10.0,                         exceptions=(TimeoutError,))def call_api():    # TimeoutError: 指数退避重试 | ValueError: 直接抛出不重试    return unstable_api()

5.4 方案三:熔断降级

连续N条用例失败时,说明环境真的有问题了,继续跑只会消耗CI时间。熔断降级自动跳过:

# -*- coding: utf-8 -*-"""熔断器 — 连续环境失败时自动跳过后续用例保险核心系统项目使用:连续3条external用例失败则熔断"""import pytestfrom typing import ClassVarclass CircuitBreaker:    failure_count: ClassVar[int] = 0    is_open: ClassVar[bool] = False    threshold: ClassVar[int] = 3    @classmethod    def record_failure(cls):        cls.failure_count += 1        if cls.failure_count >= cls.threshold:            cls.is_open = True            print(f"🚫 熔断打开!连续{cls.threshold}次环境失败,跳过后续用例")    @classmethod    def record_success(cls):        cls.failure_count = 0        cls.is_open = False    @classmethod    def skip_if_open(cls):        if cls.is_open:            pytest.skip(f"熔断已打开(连续{cls.threshold}次环境失败),跳过此用例")@pytest.mark.externaldef test_sms_service():    CircuitBreaker.record_success()    # ...测试逻辑...# 效果:连续3条external失败 → 后续用例自动跳过,不消耗CI时间

实战章 · 实战:某金融系统登录模块Flaky排查全记录

完整的排查过程:问题现象 → 日志分析 → 根因定位 → 修复代码 → 验证结果

🔍问题现象

背景:2025年Q2,某城商行核心系统升级Python 3.10 + Selenium 4.x后,登录模块4条用例开始偶发失败

【CI日志 – Jenkins Build #2847】FAILED test_login_password_visible — AssertionError: 密码字段不可见


【CI日志 – Jenkins Build #2849】PASSED (无改动,绿色)


【CI日志 – Jenkins Build #2851】FAILED test_login_password_visible — TimeoutError: 等待密码可见超时


【CI日志 – Jenkins Build #2853】PASSED (无改动,绿色)


【CI日志 – Jenkins Build #2854】FAILED test_login_password_visible — ElementNotInteractableException

失败频率:4条登录用例,在过去14次CI构建中,失败次数分别为:2次、0次、2次、1次、0次、3次……平均失败率约38%

🔬排查过程

步骤1:隔离复现

$ pytest test_login.py::TestPasswordField::test_login_password_visible -v --count=30

结果:30次中失败12次,失败率40%,成功复现

步骤2:隔离其他用例

$ pytest test_login.py::TestPasswordField::test_login_password_visible -v -s

结果:单独跑,30次中失败14次,反而更不稳定了(怀疑环境污染)

步骤3:加日志 + 截图

def test_login_password_visible(browser):    browser.get("https://bank.example.com/login")    time.sleep(1.5)  # 旧代码:固定等待,疑似问题    pwd = browser.find_element(By.ID, "password")    # 截图保存,方便排查    browser.save_screenshot(f"debug_pwd_{time.time()}.png")    assert pwd.is_displayed()

【截图分析】部分截图显示:密码字段DOM已渲染,但CSS opacity仍为0(动画未完成)

步骤4:最小化复现

删除所有其他步骤,只保留:打开页面 → 查密码字段可见性 → 断言。结果:失败率仍然40%

🎯根因定位

经过截图对比分析,发现:

根因:

固定等待1.5秒,遇上CSS动画1.2~2.1秒的不稳定耗时。快的时候通过,慢的时候Flaky。这跟代码质量无关,跟CI机器负载有关

🔧修复代码

# 修复前(旧代码)def test_login_password_visible(browser):    browser.get("https://bank.example.com/login")    time.sleep(1.5)  # ❌ 固定等待,不稳定    pwd = browser.find_element(By.ID, "password")    assert pwd.is_displayed()# 修复后(新代码)from selenium.webdriver.support import expected_conditions as ECdef test_login_password_visible(browser):    browser.get("https://bank.example.com/login")    # ✅ 显式等待:等密码字段opacity=1(CSS动画完成)    pwd = WebDriverWait(browser, 10).until(        EC.visibility_of_element_located((By.ID, "password"))    )    assert pwd.is_displayed()
✅验证结果

$ pytest test_login.py::TestPasswordField -v –count=50

修复后:50次连续运行,50次通过,0次失败


$ pytest test_login.py -v –count=20 # 全模块稳定性测试

全模块20次运行:20次全部绿色通过

微信公众号二维码图片引自微信公众号,扫码关注阅读原文
Fail排查5步法 — 复现→隔离→证据→最小化→修复,循序渐进精准定位根因

第七章 · 防患于未然——5条预防清单

Fail修复的优解,是让它根本不发生。以下5条措施能让你的测试套件稳定一大截

✅ 清单1:统一等待策略

禁止在生产代码里使用time.sleep()(调试临时用可以,提交前必须删)。所有交互统一用显式等待。点击前必须用element_to_be_clickable,不要裸调.click()

✅ 清单2:Fixture优先原则

每条用例的数据必须由fixture创建,禁止跨用例共享硬编码测试数据。工厂模式是优解——每次调用fresh_user()都创建全新的UUID用户,用完自动清理

✅ 清单3:每条用例后清理状态

测试结束前必须清理:Cookie、LocalStorage、SessionStorage、用例创建的临时数据。用yield fixture自动清理。用深度清理函数,不只是调用delete_all_cookies()

✅ 清单4:异步操作必须轮询等待

任何涉及异步任务的断言,在断言前必须轮询等待状态变为完成。禁止在接口返回后立即断言。接口返回 ≠ 数据就绪。记住这个口诀

✅ 清单5:CI设置Flaky告警阈值

CI配置告警:当Flaky用例超过N条时自动通知。避免测试套件带着Flaky跑了好几周没人管,造成”狼来了”效应——真正的问题被忽视

5大根因 → 修复方案对照表(速查版)Flaky根因修复工具修复效果(实测数据)等待机制不当WebDriverWaitelement_to_be_clickable城商行项目:73%→99.2%数据依赖工厂Fixturepytest yield teardown电商项目:顺序依赖→0%环境状态残留JS深度清理localStorage/sessionStorage保险系统:4.3条/周→0.2条异步时序竞争poll_until_done异步轮询等待支付核心:70%→99.8%外部依赖波动重试+熔断rerunfailures+熔断器短信网关:凌晨维护→自动识别跳过所有实测数据来自真实项目:城商行核心系统(2019)、电商系统(2021)、保险核心系统(2022)、支付核心系统(2020)

5大根因修复方案速查表 — 对号入座,快速修复