
1. 项目概述为什么UI自动化测试必须关注异常截图和page_source做UI自动化测试的朋友肯定都经历过这种场景半夜收到CI/CD流水线发来的邮件标题赫然写着“测试用例执行失败”。你点开一看报告里只有一行冰冷的“ElementNotVisibleException”或者“NoSuchElementException”。然后呢然后就没有然后了。你对着这行错误信息脑子里一片空白当时页面上到底是个什么鬼样子元素是真的没加载出来还是定位方式写错了页面结构是不是变了你只能凭记忆和猜测手动去复现效率低得让人抓狂。这就是我们今天要聊的核心异常截图和page_source的捕获与保存。这绝不仅仅是“在出错时截个图”那么简单它是UI自动化测试从“玩具”走向“工程化”的关键一步是测试脚本具备“自述能力”和“现场还原能力”的标志。一个只会报错不会留下任何现场证据的自动化脚本就像一个只会喊“出事了”却说不清在哪、发生了什么的路人价值大打折扣。我干了十多年测试带过不少团队发现很多新手甚至一些有经验的工程师都把精力花在了怎么写更复杂的定位、怎么设计更炫酷的数据驱动上却忽略了最基础、也最关键的“异常现场保全”。等到真出了问题排查成本高得吓人。所以这篇文章我就结合自己踩过的无数坑把异常截图和page_source这件事从为什么做、怎么做、到怎么做得更好给你掰开揉碎了讲清楚。这不仅是面试常问的“UI自动化测试面试题”更是你日常工作中提升效率、降低维护成本的实打实技能。2. 核心价值解析截图与源码不只是“留个证据”2.1 异常截图可视化的问题快照截图的价值在于它提供了最直观、最无歧义的问题现场。文字描述再详细也不如一张图来得直接。定位失败分析元素找不到是页面根本没加载还是元素被遮挡、样式隐藏display: none或visibility: hidden截图一看便知。有时候你会发现页面其实弹出了一个你没预料到的模态框Modal把后面的按钮盖住了。样式/渲染问题自动化测试通常只关注功能逻辑但有时样式错误如CSS加载失败导致布局错乱也会引发功能性问题。比如一个按钮因为z-index问题被压在其他元素下面无法点击截图能帮你快速识别这不是逻辑bug而是前端bug。动态内容验证对于一些动态生成的内容比如图表、验证码虽然自动化通常绕过、或特定状态下的UI截图可以作为辅助验证手段。虽然不建议用像素对比做精确断言维护成本高但人工复查截图能快速发现明显异常。实操心得别只截全屏。对于某些特定区域的问题可以结合Selenium的get_screenshot_as_png()配合元素的location和size属性实现局部截图这样图片更小重点更突出。2.2 Page Source结构化的现场“尸检报告”如果说截图是“照片”那么driver.page_source获取到的页面源码就是现场的“DNA”和“指纹”。它的价值更为深层精准定位分析当你的find_element(By.XPATH, “//button[id‘submit’]”)失败时立刻保存当时的page_source。你可以打开这个HTML文件直接搜索id“submit”的button。你会发现可能ID被开发改了或者这个button根本不在你预期的DOM位置而是被包在了某个动态生成的iframe里。排查数据问题页面显示异常可能是后端返回的数据不对。查看保存的源码可以看到绑定在DOM元素上的原始数据尤其是Vue/React等框架生成的>import os import time import logging from selenium.webdriver.remote.webdriver import WebDriver class BasePage: def __init__(self, driver: WebDriver): self.driver driver self.logger logging.getLogger(__name__) def _get_screenshot(self) - str: 截取当前浏览器窗口全屏。 返回: 截图文件的绝对路径 # 1. 生成唯一文件名避免覆盖。时间戳是最简单有效的方式。 timestamp time.strftime(%Y%m%d_%H%M%S) screenshot_dir os.path.join(os.getcwd(), test_output, screenshots) # 确保目录存在 os.makedirs(screenshot_dir, exist_okTrue) filename fexception_{timestamp}.png filepath os.path.join(screenshot_dir, filename) # 2. 执行截图 try: self.driver.save_screenshot(filepath) self.logger.info(f截图已保存至: {filepath}) except Exception as e: self.logger.error(f截图失败: {e}) filepath # 返回空路径表示失败 return filepath def _save_page_source(self) - str: 保存当前页面的HTML源码。 返回: 源码文件的绝对路径 timestamp time.strftime(%Y%m%d_%H%M%S) source_dir os.path.join(os.getcwd(), test_output, page_sources) os.makedirs(source_dir, exist_okTrue) filename fsource_{timestamp}.html filepath os.path.join(source_dir, filename) try: page_source self.driver.page_source # 注意编码确保中文等字符正确保存 with open(filepath, w, encodingutf-8) as f: f.write(page_source) self.logger.info(f页面源码已保存至: {filepath}) except Exception as e: self.logger.error(f保存页面源码失败: {e}) filepath return filepath为什么这么设计私有方法_开头这两个方法是内部工具不应该被页面对象直接调用仅供异常处理逻辑使用。返回文件路径返回路径是为了方便后续处理比如附加到测试报告。目录分离将截图和源码放在test_output下不同的子目录结构清晰便于归档和清理。异常处理即使保存失败也不应导致主流程崩溃记录错误日志后继续。3.2 异常捕获与自动触发装饰器与Hook的妙用最理想的状态是测试用例中任何未被捕获的异常都能自动触发现场保全。我们有几种实现模式模式一在基类关键操作中嵌入推荐给初学者在BasePage的find_element、click、send_keys等方法里加入try...except。class BasePage: # ... 其他代码 ... def find_element(self, by, value, timeout10): 查找元素失败时自动截图并保存源码 info_msg f定位元素: {by} {value} self.logger.info(info_msg) try: # 这里可以加入显式等待提高健壮性 from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC element WebDriverWait(self.driver, timeout).until( EC.presence_of_element_located((by, value)) ) return element except Exception as e: self.logger.error(f元素定位失败: {info_msg}. 错误: {e}) # 触发现场保全 screenshot_path self._get_screenshot() source_path self._save_page_source() # 可以选择将路径记录到某个全局上下文或抛出包含路径的自定义异常 raise ElementNotFoundException( f定位失败: {info_msg}. 截图: {screenshot_path}, 源码: {source_path} ) from e模式二使用Pytest的钩子函数Hook更解耦推荐这是更工程化的做法。利用Pytest的pytest_runtest_makereport钩子在测试用例执行失败时拿到当前的driver对象进行操作。创建一个conftest.py文件Pytest会自动发现:# conftest.py import pytest from selenium.webdriver.remote.webdriver import WebDriver import allure pytest.hookimpl(tryfirstTrue, hookwrapperTrue) def pytest_runtest_makereport(item, call): 获取测试用例的执行结果并在失败时进行处理。 outcome yield report outcome.get_result() # 只关注用例调用阶段setup, call, teardown中的失败尤其是call测试执行本身 if report.when call and report.failed: # 尝试从测试用例的fixture中获取driver对象 for name, fixture_value in item.funcargs.items(): if isinstance(fixture_value, WebDriver): driver fixture_value _capture_evidence_on_failure(driver, item.name) break def _capture_evidence_on_failure(driver: WebDriver, test_name: str): 执行截图和保存源码并附加到Allure报告 try: # 截图 screenshot_path f./test_output/screenshots/{test_name}_failure.png driver.save_screenshot(screenshot_path) # 保存源码 source_path f./test_output/page_sources/{test_name}_failure.html with open(source_path, w, encodingutf-8) as f: f.write(driver.page_source) # 附加到Allure报告如果使用Allure allure.attach.file(screenshot_path, namef{test_name}_失败截图, attachment_typeallure.attachment_type.PNG) allure.attach.file(source_path, namef{test_name}_失败页面源码, attachment_typeallure.attachment_type.HTML) print(f测试失败证据已保存: {screenshot_path}, {source_path}) except Exception as e: print(f保存失败证据时发生错误: {e})模式三自定义装饰器灵活控制如果你希望对哪些方法进行现场保全有更精细的控制可以定义一个装饰器。def capture_on_failure(func): 装饰器当被装饰的方法抛出异常时自动截图并保存源码 def wrapper(self, *args, **kwargs): try: return func(self, *args, **kwargs) except Exception as e: # 假设self是BasePage或其子类且有driver属性 if hasattr(self, driver): self._get_screenshot() self._save_page_source() self.logger.exception(f方法 {func.__name__} 执行失败已保存现场。参数: {args}, {kwargs}) raise # 重新抛出异常不干扰原有错误传播 return wrapper # 在页面对象方法上使用 class LoginPage(BasePage): capture_on_failure def login(self, username, password): self.find_element(By.ID, username).send_keys(username) self.find_element(By.ID, password).send_keys(password) # 假设这里可能失败 self.find_element(By.XPATH, //button[typesubmit]).click()注意事项模式二Pytest Hook是最推荐的方式因为它与测试框架深度集成对测试代码无侵入性所有用例自动生效。模式一适合快速改造旧框架。模式三适合需要对特定复杂操作进行监控的场景。4. 高级优化与集成让证据链更完整基础功能有了接下来我们让它变得更强大、更好用。4.1 与日志系统整合保存了文件还得在日志里留下记录形成可追溯的证据链。我们改造一下_get_screenshot和_save_page_source方法或者在使用它们的上层方法中将文件路径记录到日志。# 在BasePage的异常处理方法或Pytest Hook中 logger.error(f测试用例执行失败异常信息: {str(e)}) logger.error(f异常现场截图已保存: {screenshot_path}) logger.error(f异常现场页面源码已保存: {source_path}) # 甚至可以记录当前URL方便直接打开 logger.error(f失败时浏览器URL: {driver.current_url})配置日志时使用RotatingFileHandler防止日志文件无限增大。4.2 与Allure等测试报告工具集成这是提升报告可读性的杀手锏。Allure报告支持附加各种类型的文件。集成后测试人员查看报告时可以直接在失败用例的详情里看到截图和HTML源码无需再去文件系统里翻找。集成方式正如我们在Pytest Hook示例中所示使用allure.attach.file。import allure # ... 在捕获到异常并保存文件后 ... allure.attach.file(screenshot_path, name失败截图, attachment_typeallure.attachment_type.PNG) # 对于HTML源码使用TEXT或HTML类型。HTML类型在报告中可以直接渲染查看更直观。 allure.attach.file(source_path, name失败时页面源码, attachment_typeallure.attachment_type.HTML)更佳实践在报告中为截图和源码分配合适的命名比如包含用例名和时间戳避免混淆。4.3 文件管理与清理策略自动化每天跑截图和HTML文件会堆积如山。必须有一套管理策略。按构建/执行批次归档在test_output目录下创建以时间戳或构建ID如Jenkins的BUILD_ID命名的子文件夹。每次执行的结果都放在独立的文件夹里。import os from datetime import datetime build_id os.getenv(BUILD_ID, datetime.now().strftime(%Y%m%d_%H%M%S)) output_root os.path.join(os.getcwd(), test_output, build_id) screenshot_dir os.path.join(output_root, screenshots)设置保留策略在CI/CD的流水线中添加后置清理步骤。例如只保留最近10次的运行结果。可以用简单的Shell脚本或Python脚本实现。# 示例Shell脚本保留最近10个构建文件夹 cd test_output ls -t | tail -n 11 | xargs rm -rf失败文件优先保留可以设计逻辑只永久保存失败用例的证据成功的用例证据在几次构建后自动清理。4.4 PageSource的增强分析保存下来的HTML是静态的我们可以进一步分析它提取更有价值的信息。提取关键数据用BeautifulSoup或lxml解析保存的HTML自动提取错误信息、关键数据用于断言或记录。from bs4 import BeautifulSoup def analyze_saved_source(filepath): with open(filepath, r, encodingutf-8) as f: soup BeautifulSoup(f.read(), html.parser) # 例如查找所有错误提示的div error_divs soup.find_all(div, class_error-message) return [div.get_text(stripTrue) for div in error_divs]DOM结构对比对于某些核心页面可以保存一份“黄金副本”Golden Source的HTML结构。测试失败时将当前的page_source与黄金副本进行简单的DOM标签结构对比忽略文本和动态ID快速定位结构发生了哪些意外变化。5. 常见问题排查与实战技巧实录理论讲完了来看看实战中你会遇到哪些坑以及我的解决办法。5.1 问题一截图是空白或者不全现象保存的截图是全黑的、纯白的或者只截到了浏览器窗口的一部分。原因与排查页面未加载完/处于后台标签页Selenium的截图是同步操作但如果页面还在加载动画或者测试跑到了另一个标签页当前页面的渲染可能不完整。确保截图前页面状态稳定。窗口大小问题如果浏览器窗口没有最大化或者设置了特定尺寸截图可能只截取当前视口。对于长页面需要滚动save_screenshot默认只截取可视区域。解决方案# 方案1截图前确保窗口最大化但可能影响某些响应式布局测试 driver.maximize_window() time.sleep(0.5) # 给窗口调整一点时间 driver.save_screenshot(path) # 方案2截取整个页面的长图需要JavaScript执行 # 适用于Selenium 4及以上 original_size driver.get_window_size() total_height driver.execute_script(return document.body.scrollHeight) driver.set_window_size(original_size[width], total_height) # 临时调整窗口高度 driver.save_screenshot(path) driver.set_window_size(original_size[width], original_size[height]) # 恢复原状 # 方案3使用第三方库如selenium-screenshot它提供了更健壮的全页截图功能。5.2 问题二PageSource不是“所见”的页面现象保存的HTML源码里找不到你在浏览器开发者工具里看到的元素。原因与排查iframe/Shadow DOM你的目标元素可能嵌套在iframe里。driver.page_source获取的是顶层文档的源码。你需要先driver.switch_to.frame(...)切换到对应的iframe再获取其page_source。动态渲染对于Vue、React等框架初始HTML可能只是一个壳内容由JavaScript动态生成。page_source获取的是初始HTML。你需要确保在页面完全渲染、数据加载完成后通过等待特定元素出现再获取源码。浏览器开发者工具显示的是处理后的DOM开发者工具中的“Elements”面板显示的是当前实时的、经过JavaScript修改后的DOM树。而driver.page_source获取的更多是初始的响应内容尽管Selenium会等待基本加载完成但对于复杂的SPA可能仍需显式等待。解决方案# 针对iframe driver.switch_to.frame(frame_name_or_id) iframe_source driver.page_source driver.switch_to.default_content() # 切回来很重要 # 针对动态渲染加强等待 from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC # 等待某个代表页面加载完成的关键元素出现 WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.ID, dynamic-content-loaded)) ) source driver.page_source5.3 问题三异常处理导致原始错误信息被掩盖现象捕获异常后抛出的自定义异常只说了“定位失败已截图”但原始的NoSuchElementException的详细堆栈信息丢了不利于定位代码行。解决方案使用raise ... from ...语法或在自定义异常中保存原始异常。try: element driver.find_element(...) except NoSuchElementException as original_exc: self._capture_evidence() # 方法一使用 from 关键字保留原始异常链 raise MyCustomException(元素未找到已保存现场) from original_exc # 方法二在自定义异常中存储原始异常 # raise MyCustomException(元素未找到, original_excoriginal_exc)这样在最终的日志或报告中你既能看到友好的提示也能追溯到最根本的错误堆栈。5.4 问题四大量截图和源码文件占用磁盘空间解决方案如前所述实施归档和清理策略。在CI/CD中可以将成功的用例证据定期清理只长期保留失败的证据。也可以考虑将证据文件上传到云存储如S3、OSS或文件服务器并提供链接记录在测试报告中本地不留存。5.5 一个完整的实战配置示例最后给出一份我项目中常用的conftest.py配置片段它结合了Pytest、Allure和异常捕获# conftest.py import pytest import allure from selenium import webdriver from selenium.webdriver.remote.webdriver import WebDriver import os import datetime pytest.fixture(scopefunction) def driver(): 为每个测试用例提供一个独立的driver实例 options webdriver.ChromeOptions() options.add_argument(--headless) # 无头模式适合CI环境 options.add_argument(--window-size1920,1080) driver webdriver.Chrome(optionsoptions) driver.implicitly_wait(10) yield driver driver.quit() pytest.hookimpl(hookwrapperTrue) def pytest_runtest_makereport(item, call): pytest_html item.config.pluginmanager.getplugin(html) outcome yield report outcome.get_result() extra getattr(report, extra, []) if report.when call: xfail hasattr(report, wasxfail) if (report.skipped and xfail) or (report.failed and not xfail): # 只在用例失败且不是预期失败时执行 driver_fixture _get_driver_from_item(item) if driver_fixture: # 创建本次测试专用的证据目录 test_name item.name.replace([, _).replace(], _).replace(/, _) timestamp datetime.datetime.now().strftime(%H%M%S) evidence_dir os.path.join(test_evidence, f{test_name}_{timestamp}) os.makedirs(evidence_dir, exist_okTrue) screenshot_path os.path.join(evidence_dir, failure.png) driver_fixture.save_screenshot(screenshot_path) allure.attach.file(screenshot_path, name失败截图, attachment_typeallure.attachment_type.PNG) source_path os.path.join(evidence_dir, page_source.html) with open(source_path, w, encodingutf-8) as f: f.write(driver_fixture.page_source) allure.attach.file(source_path, name失败时页面源码, attachment_typeallure.attachment_type.HTML) # 也记录当前URL url driver_fixture.current_url allure.attach.text(url, name失败时URL) def _get_driver_from_item(item): 尝试从测试用例的fixture中获取driver对象 for name, fixture_value in item.funcargs.items(): if isinstance(fixture_value, WebDriver): return fixture_value return None这套组合拳下来你的UI自动化测试就具备了强大的“事后调查”能力。当CI红灯亮起时你不再焦虑而是从容地打开Allure报告查看附带的截图和页面源码像侦探一样快速定位问题根源。这不仅仅是技术的提升更是测试思维从“验证”到“洞察”的转变。