4. 异常捕获机制

在测试脚本中,异常不是“意外”,而是必须被设计的一部分。测试环境的不稳定、接口返回的偶发错误、元素定位的时序竞争、数据准备的缺失,都会以异常的形式暴露出来。如果脚本对异常毫无准备,一次失败就会中断整个用例集,甚至让后续用例全部误报。因此,理解并正确使用异常捕获机制,是构建可恢复测试脚本的第一道防线。

一、核心原理:try-catch-finally 的执行契约

Python 的异常处理由 try、except、else、finally 四个子句构成。它们不是简单的“捕获并忽略”,而是有严格的执行顺序和语义契约:

  • try 块中放置可能抛出异常的测试步骤,一旦某行抛出异常,该行之后的代码不再执行,控制权立即转移。
  • except 按顺序匹配异常类型,只有第一个匹配的分支被执行;可以用元组一次捕获多种异常。
  • else 仅在 try 块没有抛出任何异常时执行,适合放置“成功后才做”的断言或清理。
  • finally 无论是否发生异常、是否被捕获、甚至是否 return,都会执行,是释放资源、关闭连接、截图留证的可靠位置。

关键点在于:finally 的执行优先级高于 return 和异常传播。如果 finally 中再次抛出异常,它会覆盖原始异常,导致真正的失败原因被掩盖。因此,finally 中应只做“不会失败”的清理动作,或对清理动作本身再做一层保护。

def run_step(driver, locator):
    try:
        el = driver.find_element(*locator)
        el.click()
        return True
    except NoSuchElementException as e:
        log.error("元素未找到: %s", locator, exc_info=True)
        return False
    except TimeoutException as e:
        log.error("等待超时: %s", locator, exc_info=True)
        return False
    finally:
        driver.save_screenshot(f"debug_{int(time.time())}.png")

二、异常传播:让异常在正确的层级被处理

异常传播是指异常沿着调用栈向上传递,直到被某个 except 捕获,或到达顶层导致脚本终止。测试脚本通常分为三层:用例层、业务操作层、驱动/工具层。异常应该在“有能力做出决策”的层级被处理,而不是在最底层被草率吞掉。

一个常见的错误是:在工具函数中捕获所有异常并返回 None,导致上层无法区分“元素不存在”和“网络超时”,重试策略也就无从谈起。正确的做法是:底层只负责抛出带有明确类型的异常,业务层根据异常类型决定是否重试,用例层决定是否标记失败并继续下一条。

异常传播的黄金法则:谁有能力恢复,谁负责捕获;谁无法恢复,谁负责抛出。捕获后如果不处理,必须用 raise 原样抛出,保留原始堆栈。

在 Python 中,使用 raise ... from e 可以保留异常链,让日志同时显示“直接原因”和“根本原因”。这对排查测试失败尤其重要:表面上是“点击失败”,根本原因可能是“页面尚未加载完成”。

三、自定义异常:为测试失败建立语义分类

内置异常类型(如 ValueError、TimeoutError)无法表达测试领域的特定失败语义。自定义异常可以让重试策略、报告分类和告警规则变得清晰。建议为测试脚本定义一组基础异常,例如:

  • ElementNotFoundError:元素定位失败,通常可重试。
  • AssertionFailedError:断言不通过,通常不可重试,应直接失败。
  • EnvironmentError:环境不可用,如服务未启动,应跳过或标记阻塞。
  • DataPreparationError:测试数据缺失,需人工介入。
class TestScriptError(Exception):
    """测试脚本异常基类"""
    def __init__(self, message, context=None):
        super().__init__(message)
        self.context = context or {}

class ElementNotFoundError(TestScriptError):
    """元素定位失败,可重试"""
    pass

class AssertionFailedError(TestScriptError):
    """断言失败,不可重试"""
    pass

自定义异常的价值不仅在于类型区分,还在于可以携带上下文信息。context 字典可以存放用例 ID、步骤序号、页面 URL、截图路径等,在日志和报告中直接输出,避免“只看到异常类型,看不到现场”的困境。

四、异常日志记录与上下文信息收集

日志是异常恢复的“黑匣子”。没有上下文的日志,等于没有日志。记录异常时,至少应包含以下信息:

信息类别 具体内容 作用
异常本身 异常类型、消息、完整堆栈 定位代码行和调用链
用例上下文 用例 ID、用例名称、当前步骤 快速关联失败用例
环境上下文 浏览器版本、被测服务地址、测试账号 判断是否环境问题
数据上下文 输入参数、接口响应体、页面 URL 复现问题和数据排查
视觉证据 截图路径、页面源码快照 还原失败瞬间的界面状态

推荐使用结构化日志,将上下文以键值对形式输出,便于后续用 ELK 或日志平台检索。例如:

import logging, json

logger = logging.getLogger("test")

def log_exception(e, case_id, step, extra=None):
    ctx = {
        "case_id": case_id,
        "step": step,
        "error_type": type(e).__name__,
        "error_msg": str(e),
        "extra": extra or {}
    }
    logger.error("TEST_EXCEPTION %s", json.dumps(ctx, ensure_ascii=False), exc_info=True)
在 except 块中使用 exc_info=True 或 logger.exception(),可以自动附带完整堆栈。不要只记录 str(e),否则会丢失最关键的调用链信息。

五、案例场景:一次元素点击失败的完整处理链

假设测试脚本需要点击“提交订单”按钮。该按钮可能因为页面渲染延迟而暂时不可见。一个健壮的处理链如下:

  1. 驱动层等待元素可见,超时后抛出 TimeoutException。
  2. 业务层捕获该异常,转换为 ElementNotFoundError,并附带定位器和页面 URL。
  3. 重试装饰器捕获 ElementNotFoundError,执行最多 3 次重试,每次间隔 1 秒。
  4. 若重试仍失败,用例层捕获异常,记录结构化日志、截图,并标记该用例失败。
  5. finally 块关闭浏览器上下文,释放资源,确保不影响后续用例。

这个链条中,每一层只做自己该做的事:驱动层不决定重试,业务层不决定用例成败,用例层不关心元素如何定位。职责清晰,异常传播路径明确,恢复策略才有落点。

六、误区与复盘

误区一:捕获所有异常。使用 except Exception: pass 会吞掉键盘中断、内存错误等严重问题,也会让真正的失败被隐藏。应尽量捕获具体异常类型。
误区二:在 finally 中写业务逻辑。finally 中如果抛出异常,会覆盖原始异常。清理动作应尽量简单,必要时用嵌套 try 保护。
误区三:日志只记录异常消息,不记录上下文。没有用例 ID、步骤和输入参数的日志,在批量执行中几乎无法定位问题。

复盘时,可以问三个问题:这个异常是否被正确分类?它是否在正确的层级被处理?日志是否足以让另一个人在不询问作者的情况下复现问题?如果答案是否定的,异常捕获机制就还有改进空间。

测试脚本异常捕获与传播链路 用例层(Test Case) 决定用例成败 · 记录结构化日志 · 截图留证 · 标记失败并继续 捕获:AssertionFailedError / ElementNotFoundError(重试耗尽后) 业务操作层(Business Action) 异常类型转换 · 重试决策 · 上下文信息收集(用例ID、步骤、参数) 抛出:ElementNotFoundError / DataPreparationError 驱动/工具层(Driver / Utility) 等待元素 · 执行操作 · 抛出原始异常(TimeoutException、NoSuchElementException) 不吞异常 · 不决定重试 · 保留原始堆栈 异常向上传播 finally 块(始终执行) 关闭浏览器上下文 · 释放数据库连接 · 删除临时文件 · 不抛出新异常 重试循环

掌握异常捕获机制,本质上是为测试脚本建立一套“失败可控”的运行秩序。try-catch-finally 定义了执行边界,异常传播决定了处理层级,自定义异常提供了语义分类,日志与上下文则让每一次失败都可追溯、可复盘。当这四者协同工作时,测试脚本才真正具备从异常中恢复的能力。