简介《playwright自动化测试》是一份面向软件测试工程师、Python自动化测试初学者及需要落地跨浏览器测试的开发者的技术文档聚焦Playwright从环境搭建到脚本实战的完整知识链路。文档以单个docx文件交付约17.16MB内容覆盖Playwright概述、Python环境与PyCharm配置、pytest-playwright及Chromium/Firefox/WebKit内置浏览器安装、首条自动化脚本、百度首页输入点击与Web断言、命令行参数、录制用例和base_url使用并延伸到click、元素定位与断言、go_to导航、hover悬浮、下拉框操作等方法实践。内容还涉及跨浏览器兼容、与传统测试框架对比、调试与性能优化、自动化测试最佳实践及社区资源目录按章节组织便于按环境准备、脚本编写、方法查阅和问题排查定位。已有389人学习适合希望系统建立Playwright知识框架并对照实践案例查漏补缺的读者。1. 换台机器就红的脚本多半不是脚本的问题接手一份已经跑通的 playwright 自动化测试脚本最费时间的不是从零写而是它在原作者机器上全部通过、到了 CI 上十分钟红一半。翻日志会发现失败点集中在环境、定位、等待、并发三处浏览器内核版本和 playwright 库对不上、get_by_text撞上一模一样的两个按钮、click被 loading 遮罩拦住、xdist 四个 worker 抢同一个登录态文件。这份文档从头到尾按「装环境 → 写第一条脚本 → 元素定位 → 方法实践 → pytest 分层 → PageObject → 项目实战 → Allure 与失败重试」推了一遍覆盖 Python 环境搭建、12 类控件交互、表格与表单封装、免登录与并发。适合正在做 Web UI 自动化、想把脚本从「能跑」做到「能长期跑」的测试开发和业务测试同学。2. 环境搭建pytest-playwright 装完之后的三道验证关环境这一章看起来是最没技术含量的但实际排障里有相当比例的「玄学失败」都能追到这一步。常见现象是pip install pytest-playwright成功了pytest一跑就报Executable doesnt exist at .../ms-playwright/chromium-xxxx或者报Host system is missing dependencies。前者是浏览器二进制没下载或路径被改过后者是 Linux 容器里缺系统库。所以装环境这件事不能以 pip 返回成功为准要以「三个内核都能起来」为准。2.1 版本矩阵先钉死Python 小版本、系统与浏览器内核的对应关系Playwright 每个发布版本都会绑定一组固定的浏览器内核版本库升级后如果没有重新执行浏览器下载本地留下的还是旧内核目录代码里launch()就会去找一个不存在的可执行文件。所以第一件事是把版本关系写进项目说明别靠记忆。组件文档给出的底线要求实操建议Python3.8 或更高统一到 3.10 / 3.11团队内一致WindowsWindows 10、Server 2016 或 WSL有 WSL 优先用 WSL路径和权限问题少一半macOS13 Ventura / 14 Sonoma注意 Apple Silicon 与 Intel 的架构差异LinuxDebian 11/12、Ubuntu 20.04/22.04/24.04容器镜像优先选 22.04x86-64 与 arm64 都支持这张表里最容易被忽略的是架构。Windows 侧有 AMD64 和 ARM64 两种内核下载 IDE 或解释器时选错架构表现是「装是装上了跑起来很慢或者直接崩」排查时几乎不会往这个方向想。命令行里echo %PROCESSOR_ARCHITECTURE%一条命令就能确认比看系统信息面板快。Linux 容器里如果是 arm64 基础镜像playwright install-deps拉的系统包名和 x86 不完全一样遇到装不上的情况先确认架构再怀疑镜像源。2.2 从 pip 到浏览器二进制两条命令分别装了什么这一步是整个环境章节里唯一必须抄作业的部分。把下面五条命令按顺序跑完基本能覆盖 Windows、macOS 和主流 Linux 发行版。# 1. 建独立虚拟环境避免和系统里已有的 playwright 版本打架 python -m venv .venv # 2. 激活虚拟环境Windows 与 macOS/Linux 二选一 .venv\Scripts\activate # Windows source .venv/bin/activate # macOS / Linux # 3. 先把 pip 升到最新老 pip 在解析依赖时可能挑到旧版 playwright python -m pip install --upgrade pip # 4. 安装 pytest 插件它会自动带上 playwright 本体依赖 pip install pytest-playwright # 5. 下载 chromium / firefox / webkit 三个内置浏览器内核 playwright install # 6. 仅 Linux 无桌面环境需要补齐系统级依赖库 playwright install-deps逻辑说明第 4 步和第 5 步是两件独立的事。pip install pytest-playwright只装 Python 侧代码浏览器内核是单独的二进制包必须靠playwright install下载两者版本由 playwright 自己对齐。这就是为什么只升级 pip 包不重跑第 5 步会出问题。参数说明playwright install chromium只装一个内核适合 CI 里明确只跑 Chromium 的场景能省一半构建时间playwright install --with-deps把下载内核和装系统依赖合并成一条容器 Dockerfile 里更常用PLAYWRIGHT_BROWSERS_PATH/opt/pw-browsers这个环境变量可以把内核目录挪到镜像的固定层多个项目共用一份不必每次重建都重新下载。再补一个 IDE 相关的点。文档里按操作系统分别写了 Windows、macOS、Linux 三套 IDE 安装流程社区版对写自动化脚本完全够用。真正需要注意的是解释器选择虚拟环境建完之后IDE 里必须把项目解释器指到.venv下的 python否则终端里装好的包编辑器里全是黄色波浪线跑起来又提示模块找不到。这个坑新手踩得最多检查方式就是看 IDE 底部状态栏显示的解释器路径里有没有.venv。2.3 三条命令验证环境跑不通先看这三处装完不要直接开始写业务用例先跑一个只验证环境的最小脚本。这个脚本刻意不进 pytest 体系就是为了把「环境问题」和「用例问题」分开。# smoke_env.py只验证三个内核能否启动、页面能否渲染 from playwright.sync_api import sync_playwright with sync_playwright() as p: for name in (chromium, firefox, webkit): browser getattr(p, name).launch(headlessTrue) # 无头模式CI 里也这么跑 page browser.new_page() page.set_content(h1 idtenv ok/h1) # 不依赖网络排除站点因素 assert page.inner_text(#t) env ok browser.close() print(name, ok)逻辑说明用set_content而不是goto打开真实站点是因为这一步只想验证浏览器能不能起来把网络、证书、DNS 这些干扰因素全部排除掉。三个内核全打印 ok说明 Python 依赖和浏览器二进制这一层是通的。环境通了之后再验证 pytest 插件是否接管成功这一步同时把base_url的用法带出来# test_smoke.py验证 pytest-playwright 的 page fixture 与 base_url 是否生效 from playwright.sync_api import Page, expect def test_login_page_open(page: Page, base_url: str): page.goto(f{base_url}/login) # 相对路径由 base_url 拼装 expect(page.get_by_role(button, name登录)).to_be_visible() # 自动等待最多等默认超时配套的pytest.ini只需要一行[pytest] base_url http://127.0.0.1:8080逻辑说明page是 pytest-playwright 内置的 fixture不用自己写sync_playwright()的上下文base_url也是插件注入的 fixture和 ini 里的配置同名用例里直接当参数用。这样同一套脚本切测试环境只改 ini不动代码。参数说明pytest --headed打开有头模式调试--browser chromium --browser firefox一次跑两个内核--slowmo 500每个动作后停 500 毫秒用于肉眼确认步骤--tracing on生成 trace 文件失败后用playwright show-trace逐帧回放。这几个参数在排查阶段比 print 好用得多。跑不通的时候按顺序看三处第一看报错里的可执行文件路径存不存在不存在就是浏览器没装或路径变量被改第二看 IDE 解释器是不是指到了虚拟环境第三看 Linux 下有没有执行playwright install-deps缺库的报错通常是error while loading shared libraries。注意团队协作时把playwright install写进项目初始化脚本或 CI 的构建步骤不要指望每个人本地手动执行一遍。3. 元素定位与交互get_by_ 五件套与 12 类控件的落地写法定位是 UI 自动化里返工率最高的部分。同一个按钮用 XPath 写出来的路径长这样//div[classbtn-wrap]/button[2]前端加一层 div、换个 class 名脚本全红。Playwright 提供了一套语义定位贴着页面结构和无障碍属性来写前端重构时相对稳。但语义定位也不是万能的文案一改照样失效所以选型的关键是知道每种写法的失效条件。3.1 优先 get_by_role 的取舍可读性、稳定性与重构成本get_by_role()依据的是页面的角色树比如按钮是button、输入框是textbox、复选框是checkbox。这个角色不是靠 class 猜出来的而是浏览器根据标签和 ARIA 属性推出来的所以前端把div onclick改成button、或者把 class 从btn-primary改成btn-main角色定位都不受影响。它的失效条件很明确文案变了就失效。比如「提交」改成「保存」name提交就匹配不到。工程上的应对办法是约定关键按钮的文案在重构时不轻易改或者干脆让前端加>from playwright.sync_api import Page, expect def test_create_task(page: Page, base_url: str): page.goto(f{base_url}/tasks) # 角色定位按钮的可访问名就是可见文案 page.get_by_role(button, name新建任务).click() # 标签定位表单字段关联了 label 时优先用它 page.get_by_label(任务名称).fill(回归任务-001) # placeholder 定位没有 label 的搜索框用它 page.get_by_placeholder(搜索任务).fill(回归) page.get_by_placeholder(搜索任务).press(Enter) # 断言用 expect自带重试等待 expect(page.get_by_role(cell, name回归任务-001)).to_be_visible()逻辑说明这一整段没有一处sleep。expect()是带轮询的断言元素没出现会一直查到超时比assert locator.is_visible()可靠后者只查一次元素晚出现一秒就误判为失败。参数说明name支持正则namere.compile(提交|保存)能同时匹配两种文案exactFalse是默认值做子串匹配文案唯一时建议显式写exactTrue避免「提交」和「提交并关闭」互相干扰。3.2 五种语义定位与 CSS/XPath 的适用边界文档里把自带定位分了五类再加上 CSS、XPath、filter 过滤器和索引选择。实际项目里这几类各有各的坑位用表格对一下更清楚。定位方式典型写法适合场景什么时候别用角色get_by_role(button, name保存)按钮、链接、单元格、开关同一个可访问名出现多个时需加层级限定文本get_by_text(操作成功)提示信息、静态文案断言列表里文本重复容易命中多个元素标签get_by_label(所属团队)有 label 关联的表单字段用 div 手写的伪 label 匹配不到占位符get_by_placeholder(请输入)搜索框、无 label 的输入框同一页面多个框共用一句提示语标题get_by_title(刷新)图标按钮、悬浮提示移动端 hover 不触发 title 展示CSSlocator(css.ant-table-row)结构稳定的组件容器依赖自动生成的哈希 classXPathlocator(xpath//tr[last()])按轴定位、兄弟节点关系全路径长 XPath前端一改就断真正需要写 XPath 的场合不多主要是「相对某个元素找它的同行兄弟」这类场景比如拿到表格某行后取同一行的状态列。除此之外优先用语义定位加过滤器。# 过滤器在一组同类元素里按特征缩小范围 row page.locator(tbody tr).filter(has_text回归任务-001) expect(row.get_by_role(button, name删除)).to_be_visible() # and / or一个定位器同时满足两个条件或命中两类元素之一 submit page.get_by_role(button).and_(page.locator([typesubmit])) tip page.get_by_text(操作失败).or_(page.get_by_text(保存失败)) # 索引first / last / nth(0) 只在确知顺序稳定时用 first_row page.locator(tbody tr).first third_row page.locator(tbody tr).nth(2)逻辑说明filter(has_text...)是在已有一组匹配结果里做二次筛选不会改变原始定位器的作用域.and_()要求同时满足两个条件.or_()只要满足其一后者的典型用途是兼容两种文案的提示信息。参数说明has传的是另一个 locator用来表达「包含某个子元素」比has_text更精确.nth()下标从 0 开始.first等价于.nth(0)。索引定位一定要配断言先断言行数再取第 N 行否则排序一变脚本就错位。iframe 是另一个高频卡点写法上必须先进入 frame 再定位元素不能直接page.get_by_...。嵌套 iframe 就嵌套调用frame_locator。# 进入 iframe 后再定位支持多层嵌套 inner page.frame_locator(#main-frame).frame_locator(#editor-frame) inner.get_by_role(button, name保存).click()逻辑说明frame_locator是懒加载的它并不等待 frame 立即就绪真正的等待发生在后续操作上。所以它比page.frame(name...)更稳后者在 frame 还没创建时会直接返回 None。3.3 表单控件、上传下载与拖拽的可复现写法文档第四章把方法实践拆成了十几类控件这里合并成两段代码覆盖日常八成以上的操作。第一段是表单类。from playwright.sync_api import Page, expect def fill_task_form(page: Page): page.get_by_label(任务名称).fill(回归任务-002) # 文本框 page.get_by_label(任务描述).fill(第一行\n第二行) # 文本域fill 支持换行 page.get_by_role(radio, name公开).check() # 单选框 page.get_by_role(checkbox, name启用通知).check() # 复选框 page.get_by_role(switch, name灰度发布).set_checked(True) # 开关幂等写法 page.get_by_label(所属团队).select_option(label测试组) # 下拉框按可见文本选 page.get_by_label(开始日期).fill(2025-01-01) # 日期组件是输入框就直接 fill逻辑说明check()和set_checked(True)的区别在幂等性前者已经选中时会抛异常后者会先判断当前状态再决定是否点击。开关类控件建议统一用set_checked因为它的初始状态往往跟环境有关。参数说明select_option有三种传参方式label按可见文本、value按 option 的 value、index按下标优先用label或valueindex在选项排序变化时会错。fill只对 input、textarea、contenteditable 生效富文本编辑器要改用type或直接操作编辑器内部元素。第二段是上传、下载和拖拽。# 文件上传一种是原生 input直接 set_input_files一种是按钮触发文件选择框 page.get_by_label(附件).set_input_files(data/附件.xlsx) with page.expect_file_chooser() as fc: page.get_by_role(button, name上传附件).click() fc.value.set_files([data/附件1.xlsx, data/附件2.xlsx]) # 支持多选 # 文件下载监听必须包住触发点击的那一行顺序反了就拿不到 with page.expect_download() as dl: page.get_by_role(button, name导出).click() download dl.value download.save_as(fdownloads/{download.suggested_filename}) # 拖拽同一 frame 内直接 drag_to跨容器或需要中间停顿就分步做 page.get_by_test_id(card-1).drag_to(page.get_by_test_id(lane-done))逻辑说明expect_file_chooser和expect_download都是上下文管理器进入时开始监听退出时取值。写在click()之后会永远拿不到因为事件已经发生完了。这是新手最常见的顺序错误。参数说明set_files支持传列表做多文件上传save_as的路径目录必须事先存在Playwright 不会自动创建drag_to内部是 mouse down、move、up 三步页面如果对移动速度敏感比如有拖拽动画可以改用hover加mouse.down()/move()/up()手动控制步长。鼠标悬浮的写法简单但容易被低估page.get_by_role(menuitem, name设置).hover()之后二级菜单可能还有几十毫秒的展开动画紧接着的点击会点到空处。稳妥写法是 hover 之后先expect二级菜单可见再点击。3.4 自动等待不背锅三种 timeout 该在哪一层设Playwright 的自动等待指的是click、fill这类操作会先等元素出现、可见、稳定、可接收事件再执行。很多人以为有了自动等待就不需要设超时结果默认 30 秒一到就报错日志里也看不出等的是什么。三种超时要分清。超时类型设置方式作用范围常见取值操作超时page.set_default_timeout(15000)click、fill、wait_for 等动作1020 秒导航超时page.set_default_navigation_timeout(30000)goto、reload、等待跳转3060 秒断言超时expect(locator).to_be_visible(timeout5000)单个 expect310 秒逻辑说明操作超时管的是「找一个元素并操作它」导航超时管的是「整页跳转完成」断言超时是每次断言单独传。三者互不覆盖所以只调其中一个是没用的。参数说明断言超时建议显式写在关键断言上别依赖全局因为负向断言等待元素消失通常要比正向断言短等 30 秒才判定消失会让整条用例变慢。expect(...).to_be_hidden(timeout3000)这种写法在等 loading 遮罩消失时特别常用。4. 分层与封装把 300 行脚本拆成 fixture、PageObject 与数据类一个页面写一个.py文件、用例里全篇page.locator(...)这种脚本写到三十条用例就开始失控同一个按钮的定位器在八个文件里各写一遍前端一改文案要改八处。分层不是为了好看是为了把「改一处」变成真的只改一处。文档给出的路径是四层结构加 PageObject再往上一层用数据类描述表单最后用 fixture 管生命周期。4.1 四层结构怎么切用例层、页面层、组件层、数据层四层的边界建议这样定用例层只写业务动作和断言不出现任何选择器页面层描述一个页面上有哪些元素和动作组件层抽跨页面复用的东西最典型的是表格和表单数据层放测试数据和表单数据类。# pages/login_page.py —— 页面层元素定义 动作封装 from playwright.sync_api import Page, expect class LoginPage: def __init__(self, page: Page): self.page page self.username page.get_by_label(账号) self.password page.get_by_label(密码) self.submit page.get_by_role(button, name登录) self.tip page.locator([rolealert]) # 统一用角色定位提示容器 def open(self, base_url: str): self.page.goto(f{base_url}/login) return self def login(self, user: str, pwd: str): self.username.fill(user) self.password.fill(pwd) self.submit.click() return self def expect_login_failed(self, message: str): expect(self.tip).to_have_text(message)逻辑说明元素在__init__里只做定义不做操作locator 是惰性的定义时不查 DOM真正使用时才去找。动作方法返回self用例里可以链式写LoginPage(page).open(base_url).login(u, p)读起来接近自然语言。参数说明base_url之所以作为方法参数传进去而不是在页面类里写死域名是为了同一套页面类能跑不同环境。也可以走 fixture 注入看团队习惯但不要在页面类里import os读环境变量会让页面类依赖运行环境。用例层随之变得很薄# tests/test_login.py —— 用例层只表达业务语义 from pages.login_page import LoginPage def test_login_success(page, base_url, account): LoginPage(page).open(base_url).login(account.user, account.pwd) page.get_by_role(heading, name我的任务).wait_for()4.2 conftest.py 里该放什么、不该放什么conftest.py是 pytest 找 fixture 的入口容易被当成什么都往里塞的杂物间。我的划分标准是跨用例共享的资源放这里单个页面相关的放页面类或模块级 fixture。# conftest.py import pytest pytest.fixture(scopesession) def browser_context_args(browser_context_args, base_url): # 统一视口和忽略证书错误避免有头/无头下布局断点不同 return {**browser_context_args, viewport: {width: 1440, height: 900}, ignore_https_errors: True, base_url: base_url} pytest.fixture def task_page(page, base_url): page.goto(f{base_url}/tasks) page.set_default_timeout(15_000) # 页面级统一动作超时 return page逻辑说明browser_context_args是插件提供的 fixture覆盖它等于全局改上下文参数比在每个用例里new_context干净。task_page用默认的 function 作用域每条用例一个干净上下文互不干扰。参数说明scopesession的 fixture 整个 pytest 进程只执行一次适合放登录态、浏览器实例scopefunction每条用例执行一次适合放页面初始化和数据清理。把有副作用的操作写成 session 级是多进程并发下数据串味的常见来源。4.3 表格与表单组件一次封装全站复用后台系统里表格出现的频率最高值得单独封装。封装的目标不是把所有操作都写全而是把「表头索引 → 行列定位 → 整行字典」这条链路固定下来。# components/table.py class Table: def __init__(self, page, root): self.page page self.root root # 表格容器的 locator self.headers root.locator(thead th) self.rows root.locator(tbody tr) def header_index(self, name: str) - int: return self.headers.all_inner_texts().index(name) def cell(self, row: int, col: int): return self.rows.nth(row).locator(td).nth(col) def row_values(self, row: int) - dict: texts self.rows.nth(row).locator(td).all_inner_texts() return dict(zip(self.headers.all_inner_texts(), texts)) def column_values(self, name: str) - list: idx self.header_index(name) return [self.row_values(i)[list(self.row_values(i))[idx]] for i in range(self.rows.count())]逻辑说明header_index把列名映射成下标用例里就不用写魔法数字row_values返回整行字典断言时直接assert row[状态] 已完成比按下标取 td 可读得多。参数说明all_inner_texts()返回的是列表元素数量必须和表头列数一致如果表格有合并单元格或者操作列带 checkbox需要先过滤掉多余列否则字典的键值会错位。这一条在真实项目里几乎必踩处理办法是在header_index里先做一次列名白名单过滤。表单封装再往前一步是用数据类描述「要填什么」让填写方法和具体字段解耦。from dataclasses import dataclass, field dataclass class TaskForm: name: str desc: str visibility: str 公开 notify: bool True team: str 测试组 def fill_task_form(page, form: TaskForm): page.get_by_label(任务名称).fill(form.name) if form.desc: page.get_by_label(任务描述).fill(form.desc) page.get_by_role(radio, nameform.visibility).check() page.get_by_role(switch, name启用通知).set_checked(form.notify) page.get_by_label(所属团队).select_option(labelform.team)逻辑说明数据类把测试数据从操作逻辑里剥出来新增一个字段只需要改数据类和这一处填写方法。字段有默认值时用例只传关心的那几个剩下的走默认用例可读性会明显提升。参数说明dataclass的field(default_factorylist)用于可变默认值别直接写 []数据类实例建议由 fixture 生成方便按用例覆盖单个字段。4.4 命令行参数与 base_url让同一套脚本跑三套环境base_url放在 ini 里是写死的切环境靠命令行覆盖更灵活。pytest 的--base-url参数优先级高于 ini配合--browser可以一条命令跑多环境多内核。# 本地调试有头 慢动作 只跑登录相关用例 pytest tests/test_login.py --headed --slowmo 300 -k login # CI 上跑双内核指定测试环境地址 pytest --browser chromium --browser firefox --base-url https://task-test.internal -q # 生成 trace 便于失败回放 pytest tests/test_task.py --tracing retain-on-failure逻辑说明-k按用例名过滤调试时比注释代码快--tracing retain-on-failure只在失败时保留 trace不会把成功用例的录像也存下来占空间。参数说明--slowmo单位是毫秒只在有头模式下有意义--base-url后面跟的地址必须以http://或https://开头只写域名会拼出错误路径。CI 里建议把这几条命令固化成 Makefile 目标或脚本避免每个人手敲的参数不一致。5. 排查与避坑五个反复出现、每次都要查半天的现场这一章是整份文档里价值最高的部分因为前四章照着做基本都能过真正耗时间的是那些「昨天还好、今天突然红」的问题。下面五条是按出现频率排的每条都给出现象、原因和改法。5.1 报 intercepted元素在但点不到现象报错信息里出现subtree intercepts pointer events或者element is outside of the viewport但截图里元素明明就在页面上手动点也没问题。原因三类情况最多。一是页面切换或提交后有全屏 loading 遮罩遮罩层盖在按钮上二是按钮在滚动容器底部需要先滚动进视口三是固定顶栏盖住了列表第一行。Playwright 的点击前检查会判断点击坐标上是不是目标元素被遮罩盖住就直接拒绝执行。改法是等遮罩消失不要用forceTrue绕过检查。forceTrue只是跳过了可见性和命中检测点击事件照样可能落到遮罩上问题会变成更隐蔽的「点了没反应」。from playwright.sync_api import expect mask page.locator(.loading-mask) expect(mask).to_be_hidden(timeout10_000) # 先等遮罩消失最多 10 秒 page.get_by_role(button, name提交).click()如果是被固定顶栏遮挡用scroll_into_view_if_needed()把元素滚到视口中间或者直接对容器加scroll-margin-top的前端处理。这类问题排查时看一眼失败截图最快所以截图留存在这一章之后会单独说。5.2 xdist 并发下登录态文件被写坏现象本地单进程全绿CI 开-n 4之后每次随机一两条用例报「未登录」或者跳到了登录页。日志里看不出异常重跑又能过。原因多个 worker 同时读写同一个storage_state文件一个进程在写、另一个在读读到半截 JSON 就解析失败或者状态不完整。Playwright 写文件不是原子操作。改法分三步session 级只生成一次、生成时加文件锁、每个 worker 用独立路径。import pytest from filelock import FileLock pytest.fixture(scopesession) def auth_state(browser, base_url, tmp_path_factory): path tmp_path_factory.getbasetemp().parent / auth_state.json with FileLock(str(path) .lock): # 只让一个进程生成 if not path.exists(): ctx browser.new_context() page ctx.new_page() page.goto(f{base_url}/login) page.get_by_label(账号).fill(auto_user) page.get_by_label(密码).fill(auto_pwd) page.get_by_role(button, name登录).click() page.get_by_role(heading, name我的任务).wait_for() ctx.storage_state(pathstr(path)) # 保存 cookie 与 localStorage ctx.close() return str(path)逻辑说明FileLock保证同一时刻只有一个进程进入临界区其余进程阻塞等待等第一个进程写完后面的直接读到现成文件。参数说明tmp_path_factory是 session 级临时目录getbasetemp().parent拿到的是所有 worker 共享的父目录用它放登录态最合适。文件锁的.lock文件要和目标文件同目录跨机器共享目录下文件锁行为可能不一致CI 里不要放在网络盘上。5.3 有头能过、无头就红现象本地--headed跑得好好的CI 无头模式必挂报元素找不到或者文案不一致。原因视口尺寸不同导致响应式布局切换横向菜单被折叠进「更多」图片和字体在无头下加载时序不同元素高度变化导致点击坐标偏移依赖hover才展开的菜单在无头下鼠标事件路径不同。改法是把视口固定下来并在browser_context_args里统一设置同时避免依赖 hover 展开的交互路径。pytest.fixture(scopesession) def browser_context_args(browser_context_args): return {**browser_context_args, viewport: {width: 1440, height: 900}, device_scale_factor: 1}逻辑说明视口固定后响应式断点始终落在同一侧元素的存在性和位置就稳定了。device_scale_factor统一成 1 是为了让截图尺寸一致方便做视觉比对。注意不要在无头模式下用wait_for_timeout硬等图片加载改成等具体元素可见否则只是把失败概率降低不是解决。5.4 iframe 与下载事件的两个时间窗现象frame_locator明明写对了还是定位不到元素或者expect_download拿到的download对象是空的save_as报错。原因iframe 有两层外层 frame 加载完不代表内层加载完定位器在错误的 frame 上下文里找下载问题是监听顺序反了先click再进expect_download事件已经过去了。改法iframe 先等内层文档出现再定位元素下载监听必须包住触发动作。新窗口打开的下载用context.expect_page()。# iframe先等内层 body 出现再做具体定位 inner page.frame_locator(#main-frame).frame_locator(#editor) inner.locator(body).wait_for(stateattached) inner.get_by_role(button, name保存).click() # 下载监听包住点击 with page.expect_download() as dl: page.get_by_role(button, name导出).click() dl.value.save_as(fdownloads/{dl.value.suggested_filename}) # 新窗口用 context 级监听不要用 page 级 with page.context.expect_page() as new_page: page.get_by_role(link, name查看详情).click()逻辑说明wait_for(stateattached)只要求元素挂到 DOM 上不要求可见适合等待 iframe 内部的根节点。expect_page挂在 context 上而不是 page 上因为新页面不属于当前 page 对象。5.5 重试把真 Bug 盖住了Allure 里还看不到截图现象加了--reruns 2之后构建长期全绿线上仍然出问题或者失败用例在 Allure 报告里只有一条报错文本没有截图没法定位。原因失败重试不区分失败类型业务断言失败和环境抖动被一视同仁重试两次就掩盖了真实缺陷截图逻辑写在用例结尾用例中途抛异常直接跳过自然没有截图。改法重试只针对环境类异常用 marker 区分截图挂在 pytest 的失败钩子里而不是写在用例体里。# conftest.py失败时自动截图并挂到 Allure import allure import pytest pytest.hookimpl(hookwrapperTrue) def pytest_runtest_makereport(item, call): outcome yield report outcome.get_result() if report.when call and report.failed: page item.funcargs.get(page) if page: allure.attach(page.screenshot(full_pageTrue), name失败截图, attachment_typeallure.attachment_type.PNG)逻辑说明hookwrapper拿到的是测试三个阶段setup、call、teardown的报告只在call阶段失败时截图避免 setup 失败时 page 还没创建导致二次异常。参数说明full_pageTrue截整页长页面文件会比较大可以把full_page关掉只截视口。重试次数用--reruns 2 --reruns-delay 1控制--reruns-delay单位是秒间隔一秒能避开部分瞬时抖动。6. 收口技巧免登录、并发与重试三件事怎么串成一条流水线把前面几章的东西拼起来最难处理的是「几百条用例、每条都要登录」这个现实问题。最笨的写法是每条用例开头都走一遍 UI 登录一次登录五秒三百条用例就是二十五分钟还不算并发写文件的风险。我的做法是登录态只生成一次落盘保存其余用例直接复用这一招在文档里叫storage_state。# 生成一次鉴权文件后续所有上下文加载它 ctx browser.new_context() page ctx.new_page() page.goto(f{base_url}/login) page.get_by_label(账号).fill(user) page.get_by_label(密码).fill(pwd) page.get_by_role(button, name登录).click() page.get_by_role(heading, name我的任务).wait_for() ctx.storage_state(pathauth/state.json) ctx.close() # 需要新上下文时加载鉴权文件跳过登录页 ctx browser.new_context(storage_stateauth/state.json)这里的关键是wait_for那一行。登录接口返回成功不等于前端拿到 token 并写进 localStorage 了如果点完登录立刻storage_state存下来的可能是空 cookie。等首页的关键元素出现说明前端已经把状态写完了这时候保存才有效。这个细节文档里没强调但它是免登录方案能不能用的分水岭。第二个技巧是参数化上下文。有些用例需要验证「多角色同时在线」比如管理员和普通用户看到同一列表的差异这时候不能共用上下文。import pytest pytest.mark.browser_context_args(storage_stateNone) # 该用例用干净上下文 def test_guest_cannot_see_admin_menu(page, base_url): page.goto(f{base_url}/tasks) assert page.get_by_role(menuitem, name系统设置).count() 0pytest.mark.browser_context_args只影响当前用例的上下文构造参数其他用例照常用全局配置。多角色场景就同时开两个 context各自storage_state指向不同角色的文件。第三个技巧是重试策略分层。我的习惯是环境类失败网络中断、服务 502、元素等待超时允许重试两轮业务断言失败直接失败不重试因为断言失败往往就是真 Bug。pytest --reruns 2 --reruns-delay 1 -m not business_assert配合 marker 把纯业务断言的用例标出来重试只作用于剩余部分。这样既压住了环境抖动带来的噪音又不会把真实缺陷重试成绿。Allure 那边则要保证每一轮重试都留独立附件方法是用allure.dynamic.title加上轮次后缀或者在钩子里读report.rerun做区分。表格对比一下三种策略的取舍选哪条取决于团队的容忍度策略构建耗时漏报风险适用阶段不重试最短环境抖动导致误报多冒烟、主干回归全局重试 2 轮增加约 5%真 Bug 可能被掩盖小团队、环境不稳按 marker 分类重试增加约 3%风险可控稳定期团队推荐我在这块吃过一次亏早期图省事直接全局--reruns 3结果一个「保存后列表不刷新」的缺陷被重试硬生生盖了两周直到用户反馈才暴露。从那以后我每次加重试参数前都会先确认 marker 分类和 Allure 的轮次记录都到位了否则宁可让它红。这份文档里从环境安装到并发重试的路径是完整的拿到之后建议从第二章开始按顺序对着自己项目走一遍边跑边把定位器和页面类替换成自己的比通读一遍有用得多。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?