Pytest+Playwright自动化测试:如何自动生成带截图的HTML报告 1. 项目概述当Pytest遇上Playwright如何打造一份漂亮的测试报告如果你正在用Pytest做自动化测试特别是浏览器自动化那你肯定遇到过这样的场景测试跑完了控制台里一堆绿色的“PASSED”和红色的“FAILED”但你想给团队或者产品经理看一个更直观、更专业的结果时却发现Pytest自带的报告要么太简陋要么需要额外插件配置。更别提当测试用例失败时你只能看到一个错误堆栈却不知道当时浏览器里到底发生了什么——页面卡在哪儿了元素没找到是什么样子这些问题光看日志是看不出来的。这就是pytest-ddreport这个项目要解决的核心痛点。它不是一个全新的测试框架而是一个基于Pytest的强大插件专门为浏览器自动化特别是使用sync_playwright场景量身定制。它的核心价值在于在Pytest执行测试的过程中自动捕获关键信息尤其是失败时的浏览器截图、页面源码并将这些信息与测试结果一起生成一份结构清晰、信息丰富、可直接交付的HTML测试报告。ddreport里的“dd”我理解就是“Detailed Diagnostic”详细与诊断的意味它让测试报告从“结果告知”升级为“问题诊断”。简单来说它把三件事无缝整合到了一起Pytest作为测试组织和执行的骨架提供用例发现、夹具fixture管理、参数化等强大功能。Playwright (sync_playwright)作为浏览器自动化的执行引擎提供稳定、快速的浏览器操控能力。ddreport作为“记录员”和“汇报官”在幕后监听测试过程收集证据最终呈现一份带“图”带“真相”的报告。适合谁来用所有使用PytestPlaywright进行Web UI自动化测试的工程师无论是构建CI/CD流水线中的测试环节还是日常的回归测试当你需要更强大的结果可视化和问题排查能力时pytest-ddreport都是一个值得集成到工具箱中的利器。2. 核心设计思路钩子、夹具与上下文管理要理解pytest-ddreport如何工作我们需要深入Pytest和Playwright的内部协作机制。它的设计非常巧妙核心思路建立在Pytest的插件系统之上通过“钩子函数”hooks和自定义“夹具”fixtures来侵入测试生命周期并在Playwright的页面Page对象上“埋点”来收集数据。2.1 基于Pytest插件的生命周期拦截Pytest允许插件在测试执行的各个关键节点插入自定义逻辑。pytest-ddreport主要利用了以下几个钩子pytest_runtest_makereport: 这是最核心的钩子。它在每个测试用例item执行完毕后立即被调用无论用例通过还是失败。此时插件可以访问到测试用例对象item和调用结果call。ddreport在这里判断测试状态如果测试失败或出错它就会触发证据收集流程。关键在于此时测试用例中定义的夹具如page可能还在作用域内可以访问到浏览器页面对象。pytest_configure/pytest_unconfigure: 分别在Pytest开始配置和全部测试结束时调用。ddreport可以在这里进行全局的初始化如创建报告输出目录和收尾工作如生成最终的HTML报告。pytest_collection_modifyitems: 在收集完所有测试用例后调用。ddreport可以在这里给测试用例打上标记或者根据命令行参数过滤用例。通过拦截这些钩子ddreport获得了在测试执行过程中“旁观”和“记录”的能力。2.2 增强Playwright Page夹具以自动捕获证据单纯有钩子还不够我们还需要能拿到测试失败那一刻的浏览器状态。pytest-ddreport通常通过提供一个增强版的page夹具来实现这一点。这个夹具并不是替换Playwright的page而是对它进行包装。一个常见的实现模式如下# conftest.py import pytest from playwright.sync_api import Page from pytest_ddreport import attach_screenshot, attach_page_source pytest.fixture(scopefunction) def page(playwright_page: Page) - Page: 提供一个增强的page fixture用于自动捕获失败证据。 original_page playwright_page # 这里可以进行一些默认设置比如设置默认超时、视口大小等 original_page.set_viewport_size({width: 1920, height: 1080}) # 关键将原始page对象返回给测试用例使用 # 证据捕获的逻辑由 pytest_runtest_makereport 钩子配合实现 # 它可能需要访问这个page对象。一种方式是将page存储在测试用例的user属性中。 yield original_page # 测试函数执行完毕后如果需要可以在这里做一些清理 # 但注意截图捕获应在makereport钩子中此时page可能还未关闭。而在pytest_runtest_makereport钩子中插件会检查call阶段的执行结果call.excinfo是否不为None。如果测试失败尝试从测试用例item或某个自定义上下文中获取到当前测试使用的page夹具实例。调用page.screenshot()获取全屏或指定区域的截图。调用page.content()获取当前的HTML页面源码。将这些数据图片二进制、HTML文本作为“附件”绑定到测试报告项上。Pytest本身支持通过item.add_report_section或第三方报告插件如pytest-html的接口添加附件。2.3 报告生成策略聚合与呈现收集到所有测试用例的结果和附件后最后一步是生成报告。pytest-ddreport可能会自定义HTML报告完全自己生成一个HTML文件将测试概览通过率、耗时、用例详情名称、状态、错误信息以及对应的截图和源码链接或直接嵌入为Base64整合在一个美观的页面里。这需要前端模板如Jinja2的支持。集成现有报告插件更常见的做法是作为pytest-html等流行报告插件的增强插件。ddreport负责收集证据然后通过pytest-html提供的接口例如在pytest_configure中注册自定义的“额外”内容生成器将截图和源码以额外标签页或章节的形式插入到pytest-html生成的报告中。这种设计的好处是解耦测试执行和证据收集是一部分报告渲染是另一部分两者通过标准接口连接非常灵活。注意pytest-ddreport的具体实现可能是一个开源项目也可能是团队内部的工具。上述设计思路是此类工具的通用原理。在实际寻找或使用类似工具时其API和配置方式可能有所不同但核心思想万变不离其宗。3. 环境搭建与基础配置实战理论讲完了我们动手搭一个环境看看如何将Pytest、Playwright和报告生成整合起来。这里我们假设使用pytest-html作为基础报告插件并编写自定义逻辑来实现ddreport的核心功能。3.1 创建项目与安装依赖首先创建一个新的项目目录并初始化虚拟环境。mkdir pytest-playwright-report-demo cd pytest-playwright-report-demo python -m venv venv # Windows: venv\Scripts\activate # Linux/Mac: source venv/bin/activate安装核心依赖。我们将安装Pytest、Playwright的Python包以及pytest-html。pip install pytest playwright pytest-html安装Playwright所需的浏览器内核。这一步很重要playwright包本身不包含浏览器。playwright install chromium # 我们以Chromium为例也可以安装 firefox, webkit3.2 编写核心配置与夹具conftest.pyconftest.py是Pytest的本地插件文件其中定义的夹具和钩子对该目录及其子目录下的所有测试文件生效。我们将在这里实现证据捕获逻辑。# conftest.py import pytest from playwright.sync_api import Browser, BrowserContext, Page import os from datetime import datetime from pathlib import Path # 配置报告输出目录 REPORT_DIR Path(__file__).parent / test_results SCREENSHOT_DIR REPORT_DIR / screenshots os.makedirs(SCREENSHOT_DIR, exist_okTrue) pytest.fixture(scopesession) def browser(): 启动一个浏览器实例整个测试会话只启动一次。 from playwright.sync_api import sync_playwright with sync_playwright() as p: # 选择chromium可改为 firefox.launch() 或 webkit.launch() # headlessFalse 便于调试在CI环境中应设为True browser p.chromium.launch(headlessFalse, slow_mo500) # slow_mo 让操作变慢方便观察 yield browser # 测试会话结束后关闭浏览器 browser.close() pytest.fixture(scopefunction) def context(browser: Browser): 为每个测试函数创建一个新的浏览器上下文。 上下文相当于一个独立的会话隔离cookie、localStorage等。 context browser.new_context(viewport{width: 1920, height: 1080}) yield context context.close() pytest.fixture(scopefunction) def page(context: BrowserContext) - Page: 为每个测试函数创建一个新的页面。 这是测试直接操作的对象。 page context.new_page() yield page # 注意这里不关闭page因为context关闭时会自动关闭其下的所有page。 pytest.hookimpl(tryfirstTrue, hookwrapperTrue) def pytest_runtest_makereport(item, call): 核心钩子在每个测试用例执行后制作报告。 我们利用这个钩子来捕获测试失败时的截图和页面源码。 # 执行所有其他钩子得到报告对象 outcome yield report outcome.get_result() # 我们只关心测试函数调用call阶段的失败setup/teardown阶段的失败通常与环境有关。 if report.when call and report.failed: # 关键如何获取到当前测试用例使用的page对象 # 我们可以通过item的fixturenames来查找名为page的fixture值。 # 但直接获取比较麻烦。一个更简单的方法是在page fixture中将page对象存到item中。 # 这里我们假设page fixture已经做了这件事见下面的增强版page fixture。 if hasattr(item, _page_for_report): page item._page_for_report if page and not page.is_closed(): try: # 1. 捕获截图 timestamp datetime.now().strftime(%Y%m%d_%H%M%S) screenshot_path SCREENSHOT_DIR / f{item.name}_{timestamp}.png page.screenshot(pathstr(screenshot_path), full_pageTrue) # 将截图路径添加到报告extra中供pytest-html使用 if hasattr(report, extra): # 确保extra是一个列表 from pytest_html import extras report.extra.append(extras.png(str(screenshot_path))) # 也可以直接添加到sections在终端报告里显示路径 report.sections.append((失败截图, f截图已保存至: {screenshot_path})) # 2. 捕获页面源码可选 # page_source page.content() # source_path SCREENSHOT_DIR / f{item.name}_{timestamp}.html # with open(source_path, w, encodingutf-8) as f: # f.write(page_source) # report.sections.append((页面源码, f源码已保存至: {source_path})) except Exception as e: report.sections.append((证据捕获错误, f捕获截图/源码时发生异常: {str(e)}))我们需要修改一下page夹具让它能把自身引用存到测试用例item中供上面的钩子访问。# 接上面的 conftest.py修改 page fixture pytest.fixture(scopefunction) def page(context: BrowserContext) - Page: 增强版page fixture用于自动捕获失败证据。 page context.new_page() # 将page对象临时附加到当前测试item上供 makereport 钩子使用 # 注意需要从请求对象中获取当前的item import inspect frame inspect.currentframe() # 向上查找调用栈找到包含 request 的帧这是pytest注入的 while frame: if request in frame.f_locals: request frame.f_locals[request] request.node._page_for_report page # 存储page引用 break frame frame.f_back yield page # 清理引用避免内存泄漏或干扰其他测试 if hasattr(request.node, _page_for_report): del request.node._page_for_report3.3 配置pytest-html生成报告在项目根目录创建一个pytest.ini配置文件指定pytest-html的选项。# pytest.ini [pytest] addopts -v --htmltest_results/report.html --self-contained-html # 将CSS和图片嵌入HTML生成单个文件 testpaths tests python_files test_*.py python_classes Test* python_functions test_*--self-contained-html选项非常重要它会把截图以Base64格式直接嵌入HTML报告这样报告就是一个独立的文件方便分享。如果不加这个选项截图会以外部文件链接形式存在移动报告时需要连带图片目录一起拷贝。4. 编写测试用例与实战演示环境配置好了我们来写几个实际的测试用例看看效果。4.1 被测页面与测试用例设计假设我们有一个简单的登录页面需要测试。我们先创建一个模拟的登录页面文件仅用于演示。!-- demo_login.html -- !DOCTYPE html html head title演示登录页/title /head body h1系统登录/h1 form idloginForm div label forusername用户名:/label input typetext idusername nameusername required /div div label forpassword密码:/label input typepassword idpassword namepassword required /div div button typesubmit登录/button button typebutton idshowError模拟错误/button /div div idmessage stylemargin-top: 20px; color: green;/div /form script document.getElementById(loginForm).addEventListener(submit, function(e) { e.preventDefault(); const user document.getElementById(username).value; const pwd document.getElementById(password).value; if (user admin pwd 123456) { document.getElementById(message).textContent 登录成功; document.getElementById(message).style.color green; } else { document.getElementById(message).textContent 用户名或密码错误; document.getElementById(message).style.color red; } }); document.getElementById(showError).addEventListener(click, function() { // 这个元素不存在用于触发测试失败 document.getElementById(nonExistentElement).click(); }); /script /body /html接下来在tests目录下创建我们的测试文件。# tests/test_login.py import pytest class TestLoginPage: 登录页面测试类 def test_successful_login(self, page): 测试成功登录流程 # 导航到本地模拟页面 page.goto(file:// str(Path(__file__).parent.parent / demo_login.html)) # 填写用户名密码 page.fill(#username, admin) page.fill(#password, 123456) # 点击登录按钮 page.click(button[typesubmit]) # 断言登录成功消息出现 expect(page.locator(#message)).to_have_text(登录成功) # 额外断言消息颜色为绿色 assert page.locator(#message).evaluate(el getComputedStyle(el).color) rgb(0, 128, 0) # green def test_failed_login(self, page): 测试失败登录流程 page.goto(file:// str(Path(__file__).parent.parent / demo_login.html)) page.fill(#username, wronguser) page.fill(#password, wrongpass) page.click(button[typesubmit]) # 断言错误消息 expect(page.locator(#message)).to_have_text(用户名或密码错误) # 这里我们故意用一个错误的断言来让测试失败以便观察报告 # 实际应该断言颜色为红色但我们断言绿色 assert page.locator(#message).evaluate(el getComputedStyle(el).color) rgb(0, 128, 0) # 这行会失败 def test_element_not_found(self, page): 测试查找不存在的元素预期失败 page.goto(file:// str(Path(__file__).parent.parent / demo_login.html)) # 点击一个不存在的按钮这会引发Playwright的TimeoutError page.click(#nonExistentElement, timeout2000) # 设置较短超时以便快速失败 pytest.mark.parametrize(username, password, expected_message, [ (admin, 123456, 登录成功), (user1, pass1, 用户名或密码错误), (, 123456, ), # 空用户名前端可能提示这里我们简单处理 ]) def test_login_with_parameters(self, page, username, password, expected_message): 参数化登录测试 page.goto(file:// str(Path(__file__).parent.parent / demo_login.html)) page.fill(#username, username) page.fill(#password, password) page.click(button[typesubmit]) if expected_message: expect(page.locator(#message)).to_have_text(expected_message)4.2 执行测试并查看报告在项目根目录下运行测试pytest或者指定详细输出和报告路径pytest -v --htmltest_results/report.html --self-contained-html执行完成后打开test_results/report.html文件。你应该能看到一个HTML报告其中test_successful_login显示为通过绿色。test_failed_login和test_element_not_found显示为失败红色。最关键的是在失败用例的详情部分你应该能看到自动附加的截图截图展示了测试失败瞬间页面的状态。对于test_failed_login截图里会显示红色的错误消息“用户名或密码错误”与我们的错误断言形成直观对比。对于test_element_not_found截图会显示页面停在点击“模拟错误”按钮后的状态。报告还会展示错误堆栈结合截图问题原因一目了然。参数化测试test_login_with_parameters会展开为三条独立的测试记录。5. 高级配置与最佳实践基础的自动截图功能已经实现但在实际项目中我们还需要考虑更多细节让这个框架更健壮、更易用。5.1 配置管理与命令行参数硬编码的配置如截图路径、浏览器类型不够灵活。我们可以通过pytest的addoption钩子来添加自定义命令行参数并在夹具中使用。# conftest.py 新增部分 def pytest_addoption(parser): parser.addoption( --browser, actionstore, defaultchromium, help指定浏览器: chromium, firefox, webkit ) parser.addoption( --headless, actionstore_true, defaultFalse, help以无头模式运行浏览器适用于CI环境 ) parser.addoption( --screenshot-on, actionstore, defaultfailure, choices[always, failure, never], help截图策略: always(始终截图), failure(仅失败时), never(从不) ) pytest.fixture(scopesession) def browser(request): from playwright.sync_api import sync_playwright browser_name request.config.getoption(--browser) is_headless request.config.getoption(--headless) with sync_playwright() as p: if browser_name firefox: browser p.firefox.launch(headlessis_headless) elif browser_name webkit: browser p.webkit.launch(headlessis_headless) else: browser p.chromium.launch(headlessis_headless) yield browser browser.close() # 在 pytest_runtest_makereport 钩子中使用配置 def pytest_runtest_makereport(item, call): outcome yield report outcome.get_result() screenshot_on item.config.getoption(--screenshot-on) should_capture ( (screenshot_on always) or (screenshot_on failure and report.failed and report.when call) ) if should_capture and hasattr(item, _page_for_report): # ... 执行截图捕获逻辑 ...这样我们就可以通过命令行灵活控制了pytest --browserfirefox --headless --screenshot-onalways5.2 夹具作用域优化与资源管理我们的browser是session作用域context和page是function作用域。这是比较合理的默认设置保证了测试之间的隔离性每个测试有自己的上下文和页面。但在某些场景下你可能需要调整context作用域设为session如果你不关心测试间的Cookie、缓存隔离可以提升context的作用域以减少浏览器启动开销。但务必小心一个测试对全局状态的修改可能会影响后续测试。复用page通常不建议将page作用域提升因为页面状态如URL、DOM更容易相互干扰。保持function作用域是最安全的。资源清理在我们的示例中context夹具在yield后执行context.close()这会自动关闭其下的所有page。这是一种清晰的管理方式。确保所有资源都有对应的清理逻辑避免浏览器进程残留。5.3 集成Allure等更强大的报告系统pytest-html简单易用但如果你需要更强大、更专业的报告如历史趋势、用例分类、步骤详情可以集成Allure。pytest-ddreport的思路同样适用。安装依赖pip install allure-pytest在conftest.py的钩子中将截图附加到Allure报告中import allure def pytest_runtest_makereport(item, call): # ... 之前的逻辑 ... if should_capture and hasattr(item, _page_for_report): screenshot_path ... # 保存截图 # 将截图附加到Allure报告 allure.attach.file(screenshot_path, name失败截图, attachment_typeallure.attachment_type.PNG) # 也可以附加页面源码 # allure.attach(page.content(), name页面源码, attachment_typeallure.attachment_type.HTML)运行测试时添加Allure参数pytest --alluredir./allure-results生成并查看报告allure serve ./allure-resultsAllure报告会提供一个独立的“附件”区域来展示截图体验非常棒。6. 常见问题排查与实战技巧在实际使用中你肯定会遇到各种问题。下面是我踩过的一些坑和总结的技巧。6.1 问题排查速查表问题现象可能原因解决方案运行测试时报ModuleNotFoundError: No module named playwright1. 未安装playwrightPython包。2. 在错误的Python环境虚拟环境中运行。1. 确认已激活正确的虚拟环境并执行pip install playwright。2. 检查IDE或终端使用的Python解释器路径。运行测试时报Error: Browser type chromium not foundPlaywright的浏览器内核未安装。执行playwright install chromium或对应的浏览器类型。钩子函数中无法获取到page对象item._page_for_report为None1.page夹具未正确设置_page_for_report属性。2. 测试在setup或teardown阶段失败此时page夹具可能还未生成或已销毁。1. 检查page夹具中存储引用的代码逻辑确保在yield page之前存储。2. 在钩子中判断report.when对于setup/teardown阶段的失败可以尝试从其他上下文获取或只记录错误信息。生成的HTML报告中看不到截图1. 截图未成功保存或路径错误。2.pytest-html未正确配置--self-contained-html截图以外部文件链接形式存在且路径不对。3. 截图捕获逻辑在页面关闭后才执行。1. 检查SCREENSHOT_DIR目录下是否有图片文件生成。2. 确保使用--self-contained-html参数或检查报告HTML中图片的src路径是否正确。3. 确保在page关闭前捕获截图。我们的逻辑在pytest_runtest_makereport的call阶段执行此时page夹具的teardown即yield之后的代码还未运行是安全的。截图是空白或不是预期的页面1. 测试失败发生在页面导航之前。2. 页面是弹窗或新标签页未在当前的page对象中。3. 无头模式下某些渲染可能需要时间。1. 在测试中增加等待或使用Playwright的expect进行自动等待。2. 处理多页面/弹窗场景使用page.context.pages获取所有页面。3. 在截图前增加page.wait_for_load_state(networkidle)或page.wait_for_timeout(500)谨慎使用。测试运行速度很慢1. 浏览器以headlessFalse运行。2. 为每个测试都创建新的浏览器实例。3. 截图策略设置为always。1. 在CI环境或不需要可视化时使用--headless。2. 确保browser夹具是session作用域context和page可以是function作用域以平衡隔离与性能。3. 根据需求调整--screenshot-on为failure。6.2 独家实操心得与技巧给截图和报告加上时间戳和唯一标识像我们示例中那样用datetime和测试用例名组合成文件名可以避免截图被覆盖也便于在历史报告中追溯。在CI/CD中还可以加上构建号如BUILD_ID。谨慎处理page.wait_for_timeout这是Playwright中显式等待的“最后手段”。绝大多数时候你应该使用page.wait_for_selector、page.wait_for_function或者expect(locator).to_be_visible()这类基于条件的等待。显式等待固定时间会让测试变得脆弱且缓慢。在CI/CD中的无头模式配置在GitHub Actions、GitLab CI等环境中需要确保系统依赖已安装。Playwright提供了playwright install --with-deps命令来安装浏览器及其系统依赖。此外无头模式下的截图和测试行为可能与有头模式略有差异建议在CI脚本中明确设置--headless。处理复杂的失败场景有时测试失败不是因为断言而是因为页面JS报错、网络请求失败等。你可以监听页面错误或请求失败事件并将这些信息也捕获到报告中。pytest.fixture(scopefunction) def page(context): page context.new_page() # 收集JS错误 js_errors [] page.on(pageerror, lambda err: js_errors.append(err)) # 收集失败的请求 failed_requests [] page.on(requestfailed, lambda req: failed_requests.append(req.url)) # 存储这些收集器供报告钩子使用 page._js_errors js_errors page._failed_requests failed_requests yield page然后在报告钩子中可以将js_errors和failed_requests也作为额外信息输出到报告里。不要过度依赖截图截图是强大的诊断工具但也会显著增加测试执行时间和报告大小。对于大量稳定的测试套件建议默认使用--screenshot-onfailure。只有在调试特定问题时才临时启用always模式。这套基于Pytest钩子和Playwright夹具的自动化报告方案将测试的“执行”与“诊断”紧密结合。它不仅仅生成了报告更重要的是在测试失败时自动保存了“现场”极大缩短了问题定位的时间。你可以根据这个基础框架扩展出更多功能比如录制测试视频、记录网络跟踪HAR、集成到消息通知如钉钉、飞书等打造一个完全贴合自己团队需求的自动化测试基础设施。