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),否则会丢失最关键的调用链信息。
五、案例场景:一次元素点击失败的完整处理链
假设测试脚本需要点击“提交订单”按钮。该按钮可能因为页面渲染延迟而暂时不可见。一个健壮的处理链如下:
- 驱动层等待元素可见,超时后抛出
TimeoutException。 - 业务层捕获该异常,转换为
ElementNotFoundError,并附带定位器和页面 URL。 - 重试装饰器捕获
ElementNotFoundError,执行最多 3 次重试,每次间隔 1 秒。 - 若重试仍失败,用例层捕获异常,记录结构化日志、截图,并标记该用例失败。
finally块关闭浏览器上下文,释放资源,确保不影响后续用例。
这个链条中,每一层只做自己该做的事:驱动层不决定重试,业务层不决定用例成败,用例层不关心元素如何定位。职责清晰,异常传播路径明确,恢复策略才有落点。
六、误区与复盘
except Exception: pass 会吞掉键盘中断、内存错误等严重问题,也会让真正的失败被隐藏。应尽量捕获具体异常类型。
finally 中如果抛出异常,会覆盖原始异常。清理动作应尽量简单,必要时用嵌套 try 保护。
复盘时,可以问三个问题:这个异常是否被正确分类?它是否在正确的层级被处理?日志是否足以让另一个人在不询问作者的情况下复现问题?如果答案是否定的,异常捕获机制就还有改进空间。
掌握异常捕获机制,本质上是为测试脚本建立一套“失败可控”的运行秩序。try-catch-finally 定义了执行边界,异常传播决定了处理层级,自定义异常提供了语义分类,日志与上下文则让每一次失败都可追溯、可复盘。当这四者协同工作时,测试脚本才真正具备从异常中恢复的能力。