配套代码:utils/retry.py、tests/test_login_api.py
说明:本节所有代码示例均来自一个真实的移动端自动化测试项目,已做模糊化处理。
为什么需要重试
移动端测试比 Web 测试更容易出现偶发性失败。以下几种情况在本地和 CI 上反复出现:
- 网络波动 :Appium 调用
find_element时底层发起的 HTTP 请求超时,抛出TimeoutException: An element could not be located on the page using the given search parameters。 - 元素加载慢 :页面渲染被动画或懒加载阻塞,在元素存在前执行了
click(),触发NoSuchElementException: An element could not be located。 - 键盘弹出延迟 :
send_keys()执行时输入框尚未获得焦点,驱动报InvalidElementStateException: Element must be user-editable to sendKeys。 - 动画未结束 :页面切换动画仍在运行时点击下一个元素,抛出
ElementClickInterceptedException: Element is not clickable at point (x, y)。
这些失败不是代码逻辑 bug,而是环境或时序导致的不稳定。重试 2-3 次后,绝大部分情况下用例能正常通过。utils/retry.py 提供了 retry_on_failure 装饰器和 RetryHelper 工具类来解决这类问题。
重试装饰器
utils/retry.py 中的 retry_on_failure 是一个装饰器,直接加在测试函数或页面操作方法上即可启用自动重试。
from utils.retry import retry_on_failure
class TestLogin:
@retry_on_failure(max_attempts=3, delay=2.0)
def test_login_with_retry(self, driver):
"""带重试的登录测试"""
from pages.login_page import LoginPage
login_page = LoginPage(driver)
login_page.input_phone_email("test_user@example.com")
login_page.input_password("test_password")
login_page.click_login_button()
assert login_page.is_on_home_page()
参数说明
max_attempts:最大尝试次数(包括首次执行),默认 3。最后一次失败后不再重试,直接抛出异常。delay:每次重试间隔,单位秒,默认 1.0。重试前通过time.sleep(delay)等待。exceptions:需要捕获并重试的异常类型元组,默认(Exception,)即捕获所有异常。可以精确指定只重试特定异常,例如exceptions=(TimeoutException, NoSuchElementException)。
执行流程
-
首次执行函数,成功则直接返回结果。
-
捕获到
exceptions中指定的异常时,记日志并等待delay秒后重试。 -
第 2~N 次重复上述流程。
-
所有
max_attempts次均失败后,抛出最后一次捕获的异常。精确捕获特定异常,避免因 AssertionError 误重试
@retry_on_failure(max_attempts=3, delay=2.0, exceptions=(TimeoutException, NoSuchElementException))
def test_element_interaction(self, driver):
...
RetryHelper 辅助类
除了装饰器,utils/retry.py 还提供了 RetryHelper 工具类,适用于不能或不想用装饰器的场景------比如在页面对象(Page Object)的某个方法内部对单个操作做重试。
retry_operation
对任意可调用对象执行重试,适合在 Page Object 中精细控制:
from utils.retry import RetryHelper
class LoginPage:
def click_login_with_retry(self):
"""对单次点击操作做重试,不波及整个用例"""
RetryHelper.retry_operation(
self.click_login_button,
max_attempts=3,
delay=1.0
)
签名:retry_operation(operation, max_attempts=3, delay=1.0, exceptions=(Exception,), *args, **kwargs)
retry_until_success
重试直到满足自定义成功条件,适合轮询等待某个状态:
RetryHelper.retry_until_success(
self.click_login_button,
max_attempts=10,
delay=0.5,
success_condition=lambda: self.is_element_displayed("xpath", "//*[@text='首页']")
)
success_condition 是一个可调用对象,返回 True 视为成功。这对于动画未结束、数据尚在加载的场景非常实用------不是单纯等固定时间,而是等到条件成立。
重试策略选择
| 策略 | 适用场景 | 参数建议 |
|---|---|---|
| 固定延迟 | 网络波动、元素加载慢 | delay=2.0 |
| 指数退避 | 服务端限流、资源竞争 | 延迟递增:2s → 4s → 8s |
| 快速重试 | 键盘弹出延迟、动画未结束 | delay=0.5 |
指数退避可以在 retry_on_failure 基础上手动实现:
def retry_with_backoff(max_attempts=3, base_delay=1.0):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
last_exception = e
if attempt < max_attempts - 1:
delay = base_delay * (2 ** attempt)
logger.warning(f"等待{delay}秒后重试: {e}")
time.sleep(delay)
raise last_exception
return wrapper
return decorator
常见坑与报错
以下是在实际项目中踩过的坑,每个都附有具体报错信息。
坑 1:重试覆盖了真正的代码 bug
AssertionError: Expected True but got False
assert login_page.is_on_home_page()
如果你用 @retry_on_failure(max_attempts=5, delay=1.0) 不加 exceptions 参数,默认捕获 Exception,连 AssertionError 也会被兜住重试。测试报告里显示"重试后通过",但实际是业务逻辑有问题。应通过 exceptions 参数限定只重试环境类异常,让断言失败直接暴露。
坑 2:重试间隔太短,重试等于白试
selenium.common.exceptions.TimeoutException: Message: timeout (WARNING: The server did not provide any stacktrace information)
页面上一个接口通常需要 1-3 秒返回数据,如果重试间隔设成 delay=0.3,页面还没加载完就重试,同样会再次超时。推荐至少 1.5-2 秒。
坑 3:重试次数太多,拖慢整个测试集
Test session running in 12m 34s ...
100 个用例,每个重试 3 次 × 2 秒延迟 = 多出 600 秒(10 分钟)。如果每个用例都无脑加 @retry_on_failure,测试集时间膨胀严重。只在确实偶发的用例上加重试,能通过 find_element 加 WebDriverWait 解决的优先用等待。
坑 4:装饰器忘了加 @wraps,pytest 报告中函数名变成 wrapper
tests/test_login.py::test_login_with_retry → wrapper # 函数名被覆盖
utils/retry.py 中已经写了 from functools import wraps 并在内层函数上加 @wraps(func)。如果你自己写重试装饰器时漏了这一行,func.__name__ 会变成 wrapper,pytest 报告中看到的测试名称将是 wrapper 而非原始函数名,导致无法匹配 Allure 报告中的测试用例。
坑 5:exceptions 参数传了错误类型导致异常逃逸
@retry_on_failure(max_attempts=3, exceptions=(TimeoutException))
元组写法少了逗号,(TimeoutException) 会被 Python 解析为单个表达式而非元组。正确的写法是 (TimeoutException,)。漏掉逗号后,重试装饰器不会捕获任何异常,偶发失败直接报错退出。
坑 6:不适合重试的场景强行加重试
AttributeError: 'NoneType' object has no attribute 'click'
driver = None # driver 未初始化
driver 为 None、测试数据错误、配置写错------这些属于一次性失败,重试 100 次结果一样。先定位根因,不要用重试掩盖配置或数据问题。
哪些场景适合用重试
- 元素定位超时(页面加载慢)
- 网络请求超时(网络波动)
- 偶发性断言失败(动画未结束,元素尚未到达预期状态)
哪些场景不适合用重试
- 逻辑错误(代码 bug,重试多少次都一样)
- 数据错误(测试数据不对,重试后仍然一样)
- 配置错误(driver 没初始化、API 地址不对)
总结
重试是应对移动端测试不稳定性的实用手段,但需要搭配正确的参数和策略。utils/retry.py 提供了装饰器和工具类两套方案------装饰器适合整个测试函数级别,RetryHelper 适合细粒度控制单次操作。用好 exceptions 参数避免误吞断言失败,控制 delay 和 max_attempts 防止测试集膨胀。能通过显式等待(WebDriverWait + expected_conditions)解决的问题,优先用等待,重试作为兜底方案。