
Playwright 这几年在自动化测试圈里火得很快尤其是配合 Python 用的时候很多团队从 Selenium 迁移过来图的就是它那套自动等待和 anti-bot 机制。但说实话我在实际项目里接手过好几套“看起来写得挺漂亮”的 Playwright 代码跑起来照样三天两头红一片最后排查一圈问题基本都出在几个固定的地方选择器写得不够稳、等待策略用错了、fixture 作用域没搞明白还有就是浏览器上下文没清理干净。这篇东西就是来聊聊这些我在实战中踩过、也帮别人填过的坑以及对应的处理办法。这篇文章适合谁看只要你在用 Python Playwright 写 Web 自动化测试不管你是刚把环境跑起来的新手还是已经写了上千条用例的老手里面提到的这些问题你大概率在某个深夜也碰到过。我说的不是那种泛泛的“注意事项”而是会给出具体的代码片段、设计思路和排查路径你看完能直接对着改。1. 为什么选 Playwright技术选型背后的逻辑1.1 Playwright 对比 Selenium 的核心优势先说个我在团队里反复解释过的问题既然 Selenium 存在了这么多年为什么还要换 Playwright答案不仅仅是“快”或者“新”。最核心的差异在于 Playwright 采用的是浏览器协议层面的控制而不是像 Selenium 那样走 WebDriver 的老路子。这意味着 Playwright 能拿到更多浏览器内部状态最典型的就是网络请求、WebSocket、下载事件这些Selenium 想拿到就得靠额外工具去抓很啰嗦。另外一个让我非常有体感的地方是自动等待。Selenium 里你得显式写 WebDriverWait 配 Expected Conditions或者干脆加 sleep否则页面渲染慢一点用例就挂。Playwright 提供了一套内置的 actionability 检查比如点击一个按钮前会自动等待元素可见、可操作、不被遮挡、不再动画。这套机制真的能省掉很多不必要的等待代码也让用例的稳定性上了一个台阶。我印象很深的一个例子是之前某个系统里有个表格加载完以后还要再渲染一帧才会绑定事件用 Selenium 写的时候每个人都要在关键步骤前加一个time.sleep(1)代码丑还容易偶发失败。用 Playwright 重写以后因为click()会自动等待元素“稳定”这种问题基本绝迹了。1.2 适用场景与不适用场景不过 Playwright 也不是万能药。我个人的判断是如果你的项目主要跑的是 UI 流程验证、跨浏览器兼容性检查、端到端回归Playwright 是很好的选择但如果你需要测试的是非常底层的、跟浏览器引擎强相关的东西或者你有大量需要直接操作浏览器配置文件、插件体系的老旧扩展可能还是要回归 Selenium。另外Playwright 对多标签页、多上下文context的管理能力特别强因为每个 browser context 都是完全隔离的独立会话。这个特性对做多用户场景的测试来说很香比如模拟两个不同权限的用户同时操作同一套系统你不需要再开两台机器只要创建两个 context 就行。但这也就引出了第一个坑很多人拿到 Playwright 就开始写代码但忽略了它“上下文隔离”这个底层逻辑结果用例之间串数据、登录态互相干扰这就是下面要展开讲的环境管理问题。2. 环境搭建与依赖管理中的常见坑2.1 安装时最容易踩的版本坑很多人一上来就pip install playwright然后跑playwright install chromium一把梭哈成功觉得挺简单。但等到项目一复杂问题就来了你本地用的是 Playwright 1.40同事 pull 代码以后装依赖却装成了最新版 1.48然后浏览器版本也跟着变了结果同一个用例在你机器上过得去在同事机器上就挂。这事的根子在于 Playwright 的浏览器驱动和库版本是绑定的。每次升级库都会要求你重新执行playwright install来下载匹配的浏览器版本否则就会报类似 “Executable doesnt exist” 的错误。就算你 CI 里每次都重新安装不同时刻拉到的库版本也可能不同等于测试环境一直在漂移。我建议的做法是在项目里把依赖版本彻底锁死。requirements.txt里直接写死playwright1.48.0然后再配合哈希校验那套就更稳了。同时在执行环境里固定跑playwright install --with-deps chromium下面会说这个--with-deps并且把浏览器下载到 CI 缓存里避免每次重复下载。注意playwright install下载的浏览器默认放在用户目录缓存下CI 里如果用的容器是无状态的最好把PLAYWRIGHT_BROWSERS_PATH环境变量指向一个可持久化的目录这样能大幅减少流水线耗时。2.2 浏览器驱动与系统依赖问题还有一个特别容易在 Linux 上踩的坑浏览器二进制装好了但启动时缺系统库。你会看到一整屏的libX11.so.6: cannot open shared object file之类的报错。第一次遇到的时候我还以为是环境坏了其实是这台机器缺了一堆图形界面相关的依赖库。官方提供了一个省事命令playwright install --with-deps chromium。这个--with-deps会自动帮你把系统层依赖也装上。如果你用 Docker那就更推荐基于官方提供的mcr.microsoft.com/playwright镜像里面已经预装了所有依赖和浏览器直接用就行。我个人实际项目里是这么处理的本地开发用 macOS 或 Windows 不怎么会遇到缺库问题但 CI 用的是镜像所以一定要保证 Dockerfile 里执行的是FROM mcr.microsoft.com/playwright:v1.48.0-jammy WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt这里有个细节镜像 tag 里的v1.48.0跟 Python 库版本必须保持一致否则镜像里预装的浏览器版本可能和库期望的版本不匹配运行时会报版本协议不兼容的错误。这也是版本漂移的一个变种。2.3 虚拟环境与 CI 环境里的依赖隔离这个坑说起来不大但足够坑一整天。很多团队会用 system Python 直接跑测试脚本一旦机器上同时存在多个项目依赖互相打架就会变得非常酸爽。我见过有人因为全局环境里装了一个老版本的greenlet导致 Playwright 的异步 API 莫名其妙报错查了半天才定位到是依赖冲突。我的建议是不管项目多小都开一个虚拟环境。Python 3.3 以后自带venv用起来很简单python -m venv .venv source .venv/bin/activate pip install -r requirements.txt在 CI 里也是一样的道理最好用poetry或pip-tools管理依赖锁定不要把pip install裸跑在系统环境上。还有一个偏门但很常见的问题有些 CI 平台默认的 Python 版本很低比如 3.7而 Playwright 新版要求 Python 3.8 以上。你可能看到的是语法报错或者asyncAPI 不可用实际上就是版本不够。所以 CI 配置文件里一定要显式指定 Python 版本别用平台默认的。3. 编写用例时的核心避坑点选择器、等待与状态管理3.1 选择器策略别再用 XPath 硬扛开始写用例以后第一个决定测试稳定性的就是元素定位方式。很多从 Selenium 转过来的人习惯继续用 XPath比如//*[idapp]/div[2]/div[1]/form/input[3]。这种绝对路径在页面稍微改一下就必挂属于最脆弱的定位方式。Playwright 官方推荐的选择器体系里我最常用的是角色选择器、文本选择器和 CSS 选择器。角色选择器最大的好处是贴近用户感知你说“页面上叫‘提交’的按钮”它就会去找那个可交互的 button用户能看到什么它就能定位什么。page.get_by_role(button, name提交).click()用文本来定位会比较适合那些没有固定 id、但是文案稳定的元素page.get_by_text(操作成功).wait_for()如果你确实需要用 XPath也尽量用相对路径并且加上有意义的锚点。比如//section[data-testidresult-panel]//button[contains(text(),确认)]这样即使前面的结构变了只要>from playwright.sync_api import expect expect(page.locator(.data-table)).to_be_visible(timeout10000)这个to_be_visible会不断重试直到元素可见或超时。如果 10 秒还没出来就说明页面真的有问题此时报错是有意义的而不是被 sleep 掩盖过去。还有一类异步请求完成以后页面上的某个区域会更新。此时可以用expect来断言目标状态而不是等一个固定的进度条消失。举个例子# 等待某个请求返回后列表出现指定行 expect(page.locator(tr, has_text订单号20240101)).to_be_visible()这种做法把“等待”的语义从“磨时间”变成了“验证状态”测试意图更清晰稳定性也好得多。3.3 页面状态与异步渲染的处理如果说 sleep 是显性坑那异步渲染就是隐形坑。页面元数据已经加载完成、DOM 里也出现了元素但绑定的事件还没生效或者数据还没填充完。这种时候你点击一个按钮按钮看起来在实际上监听还是空的于是用例失败还很难复现。Playwright 的 actionability 检查能解决一部分问题因为它会等元素“稳定”包括不再发生动画、尺寸不再变化、可见且可点击。但如果你要等一个接口返回后再操作Playwright 是感知不到的。这时候有两个思路一是用page.expect_response()配合点击操作精准等待接口返回。比如提交一个表单以后等待后端的保存接口完成再触发下一个断言with page.expect_response(lambda response: response.url.endswith(/api/save) and response.status 200) as response_info: page.get_by_role(button, name保存).click() response response_info.value二是直接轮询某个关键状态比如后端保存成功后会出现一个“已保存”标签。我建议优先用接口等待因为它的语义更可靠而且能顺带验证后端返回的状态码。3.4 iframe 与多页面切换的坑很多管理系统里嵌了 iframe这是测试很容易翻车的地方。Selenium 里你得来回切换 driver 的 frame 焦点Playwright 的话iframe 里的内容跟主页面是隔离的要拿到 iframe 里的元素必须先frame_locatorframe page.frame_locator(#main-iframe) frame.get_by_text(详情).click()这里有一个坑如果你用普通的page.locator去定位 iframe 内部元素大概率会超时找不到因为 Playwright 不会帮你穿透 iframe。这个设计其实是一种保护防止你无意识地去操作被隔离的内容。多页面切换也是类似逻辑。新开一个标签页以后你需要监听新增的 page 事件或者用context.expect_page()来处理。我写过这样的代码with page.context.expect_page() as new_page_info: page.get_by_text(在新窗口打开).click() new_page new_page_info.value new_page.wait_for_load_state()这里常见的错误是直接拿原来的page对象去操作新标签页结果元素定位不到。千万记住每个 page 是独立的对象page之间不是自动共享的。拿到新 page 对象以后操作完要记得自己关掉它不然 context 里一直挂着多余的标签页影响后续用例。4. 数据驱动与用例组织可维护性的关键4.1 fixture 的正确用法Pytest Playwright 是现在最主流的组合而 pytest 的 fixture 机制用好以后用例会简洁很多。但我也见过 fixture 被用得乱七八糟的有人在scopesession的 fixture 里启动了浏览器结果一组用例跑完以后浏览器状态整个乱掉后面所有用例都依赖前一个用例的残留状态这是大忌。正确的模型是browser级别的 fixture 通常是 session 级的因为启动浏览器的成本很高而context和page应该是 function 级的每个用例都重新创建确保隔离。我一般在conftest.py里这样组织import pytest from playwright.sync_api import sync_playwright pytest.fixture(scopesession) def browser_fixture(): with sync_playwright() as p: browser p.chromium.launch(headlessTrue) yield browser browser.close() pytest.fixture() def page(browser_fixture): context browser_fixture.new_context() page context.new_page() yield page context.close()这样做的好处是每个用例都在全新的 context 中运行cookie、localStorage、路由记录都是干净的。如果你要测试登录流程完全可以在这个 fixture 里做登录并把登录状态通过context.storage_state()保存下来供后面用例复用能省不少时间。但要注意复用时一定要带上账号归属防止把不同权限的账号混在一起。4.2 数据驱动测试的参数化写自动化测试最怕的就是把一条用例复制十遍只改几个输入数据。正确的姿势是使用 pytest 的参数化。比如登录功能你可以准备多种输入组合import pytest pytest.mark.parametrize(username,password,expected_tip, [ (user001, wrong, 用户名或密码错误), (nonexist, any, 用户不存在), (, , 请输入用户名), ]) def test_invalid_login(page, username, password, expected_tip): page.goto(https://example.com/login) page.get_by_label(用户名).fill(username) page.get_by_label(密码).fill(password) page.get_by_role(button, name登录).click() expect(page.get_by_text(expected_tip)).to_be_visible()参数化以后一条用例就变成了多条有效用例。但是要注意不要为了参数化而参数化如果每个输入需要不同的前置操作强行塞进一个用例里反而会让代码变复杂。这时候更适合用fixture来分场景组织。另外一个实用小技巧把测试数据放到独立的 JSON/YAML 文件里用pytest_generate_tests动态生成测试用例。这样做的好处是业务人员可以直接改数据文件不需要动 Python 代码适合用于需要频繁增删测试数据的业务回归。4.3 用例之间的隔离与清理这一节属于经验之谈。很多莫名其妙的测试失败都是因为用例之间相互影响。比如 A 用例创建了一个订单B 用例假设列表里只有自己的数据结果就匹配错了。我们的目标应该是“用例可以任意顺序执行也可以单独执行”。要达到这个目标前提是每个用例的初始状态完全一致。我的做法是在 fixture 层面保证隔离每个用例都用新的 context并且清理浏览器缓存。同时在数据库层面如果有测试数据残留最好通过 API 或数据库脚本在 setup 阶段清理干净而不是依靠用例结束时的 teardown。因为用例一旦中途失败teardown 可能根本不会执行残留数据就会影响下一次运行。如果你实在需要依赖某些前置数据我建议把依赖关系显式写出来而不是让用例自己去“猜”。比如pytest.fixture() def created_order(page): # 创建一个订单并返回订单号 ... def test_order_detail(created_order): # 直接使用 created_order 这个 fixture 提供的数据 ...这样用例之间的依赖关系是显式的不依赖于执行顺序可维护性好很多。5. 调试与排查技巧再也不用盯着浏览器发呆5.1 trace viewer 与录屏用例挂了以后第一步不是去猜原因而是把现场还原出来。Playwright 自带一个非常强大的东西叫 trace运行的时候开启 trace 以后它会记录整个会话里的所有操作、DOM 快照、网络请求和控制台日志之后可以在playwright show-trace里打开查看。这个真的是救命功能。开启 trace 的姿势建议放在 fixture 里只在失败时保存pytest.fixture() def page(browser_fixture, request): context browser_fixture.new_context() page context.new_page() if request.config.getoption(--headed): pass # 始终开启 trace但保留最末一次 context.tracing.start(screenshotsTrue, snapshotsTrue, sourcesTrue) yield page if request.node.rep_call.failed: context.tracing.stop(pathftrace-{request.node.name}.zip) else: context.tracing.stop() context.close()要注意的一点是trace 文件其实是个 zip 包里面包含了大量快照数据体积可能不小。如果每次都保留所有成功用例的 trace磁盘很容易爆。所以我一般只在失败时保留成功时直接丢弃。5.2 日志与断言信息优化测试报错信息一看就知道问题在哪是减少排查时间的关键。默认的 Playwright 断言信息有时候不够明确比如Timeout 30000ms exceeded waiting for locator(...)它告诉了你哪个 locator 超时但不一定告诉你当时页面上的状态是什么。这时候我们需要在断言或者 fixture 里补充上下文信息。我在关键的断言前喜欢做一个“状态打印”# 断言失败前的快照方便定位 try: expect(page.get_by_text(保存成功)).to_be_visible(timeout5000) except AssertionError: print(当前页面 URL:, page.url) print(当前页面内容片段, page.content()[:1000]) raise这个不算最优解但在没有接入 trace 的时候能快速看到现场。还有一个办法用page.on(console)和page.on(pageerror)在 fixture 中全局收集浏览器日志测试失败时把这些日志附带进测试报告里。很多前端 JS 报错是不会直接影响页面操作的但它们往往是后续异常的源头。提前捕获这些信息你会在排查时省力很多。5.3 常见报错的排查速查表这里我整理一个表格全是实际工作中高频出现的报错和对应的处置方向报错现象可能原因处理建议page.click: Timeout exceeded元素存在但不可点击或页面 JS 报错导致事件未绑定打开 trace 看 DOM 快照确认元素是否被遮挡/置灰检查 console 是否有 JS 异常Executable doesnt existPlaywright 库版本与浏览器版本不匹配重新执行playwright install chromium并检查PLAYWRIGHT_BROWSERS_PATHTarget page, context or browser has been closed用例内手动关闭了 page/context后面又继续操作检查 window/dialog 关闭逻辑不要在一个 page 关闭后继续调用它Error: page.goto: net::ERR_CONNECTION_REFUSED被测服务未启动或地址错误确认 base_url 是否可用CI 里确认服务是否已启动Locator ... not found元素未渲染或者选择器过期先用 trace 定位页面状态如果页面存在但没渲染考虑加 expect wait而不是纯 sleepProtocol error (Runtime.callFunctionOn): Target closed跳转页面后仍持有旧 page 引用重新获取 page 对象并等待新页面加载完成这个表不是让你死记硬背而是给你提供一个排查思路先看报错发生在哪一步然后去 trace 里看当时的页面快照再判断是时序问题、选择器问题还是环境问题。6. 执行策略与 CI 集成的实战经验6.1 并行执行与 sharding用例一旦多起来单线程跑完可能要一两个小时这时候就必须上并行。pytest 里常用pytest-xdist实现并行执行但pytest-xdist默认是多进程模式每个 worker 都独立创建浏览器环境。要注意的是 pytest 的 fixture scope 如果设置成 session 级在多进程模式下每个 worker 会单独跑一遍 session fixture这没问题但你要确保 session fixture 没有太重的初始化成本否则并行优势就被抵消了。Playwright 官方还提供了一个--shard参数可以在 CI 上分配多台机器每台跑一部分用例pytest tests --shard1/3 pytest tests --shard2/3 pytest tests --shard3/3这适合用例总量非常大、单机并行已到瓶颈的情况。部署多台机器跑同样的代码各自跑一部分测试报告统一汇总到看板。这里一定注意每台机器跑完以后把 trace 或失败截图上传到同一个存储方便统一查看。我的习惯是失败产物统一命名成shard-1-case_name.zip这样一眼就知道哪个分片出了问题。6.2 重试机制与稳定性的平衡UI 自动化测试很难做到 100% 稳定偶发的网络抖动、第三方服务波动都会导致用例失败。这时候很多团队会想失败就重试呗。重试这个事得辩证看。对于纯环境因素导致的失败重试能白赚稳定但如果你盲目重试一方面测试时间翻倍另一方面掩盖了真实的产品缺陷。我通常在 pytest 里加一个插件或者用pytest-rerunfailures只对“疑似环境问题”的失败重试。怎么判断一个简单规则断言超时但 trace 里能看到元素其实存在、只是加载慢这种可以重试如果 trace 里显示页面直接跳错、接口返回 500这种重试没用应该立刻失败。如果你要细粒度控制建议只对 flaky 标记的用例重试# pytest.ini addopts --reruns 2 --reruns-delay 5 --only-rerunTimeout这个配置只重试超时类错误其他错误直接失败能有效避免重试掩盖太多问题。我个人的体会是重试次数不要超过 2 次再多基本就是无效重试还会让日志变得混乱。6.3 容器化执行时的注意事项容器化以后测试环境的一致性确实上来了但也会遇到一些只在容器里出现的特殊问题。首先是headless模式下的 GPU 和沙箱问题。我这里说的沙箱是指 Chromium 在 root 用户下无法启动的那类限制不是网上说的那些东西。解决方法是启动 chromium 时加上--no-sandbox参数这在容器环境下基本是必须的p.chromium.launch(headlessTrue, args[--no-sandbox])另一个容器里的坑是资源限制。默认容器里 /dev/shm 可能比较小Chromium 进程多了以后会把共享内存撑爆表现就是标签页崩溃。解决办法是把/dev/shm扩容或者在启动参数里加--disable-dev-shm-usage。实测下来加这个参数比扩容简单有效。在 Docker 里还需要确保网络代理配置正确。如果你的测试环境需要访问内网资源记得把宿主机的代理设置透传到容器里否则页面加载超时你都不知道是网络问题还是容器网络配置问题。另外容器里跑测试不要轻易挂hostNetwork因为那样会丧失端口隔离性多套测试并行跑可能互相干扰。7. 数据与断言设计别让脆弱断言毁掉一切7.1 动态数据的断言策略很多时候用例失败不是因为功能坏了而是因为断言写得太死。比如你要验证页面上一共显示 10 条数据但这条数据本身是动态产生的可能这次是 10 条下次就是 11 条。这种用例跑不了几次就开始红。我的建议是不要断言一个绝对数量而是断言一个相对关系或者断言某个关键元素是否出现。比如分页器你可以断言“每页显示条数 10”这个选项存在并且点击下一页以后 URL 参数变化。而不是硬编码“总共 10 条记录”这种容易波动的文本。对于动态生成的时间戳、随机数尽量用正则匹配或者直接断言格式。expect(page.get_by_text(re.compile(r创建时间\d{4}-\d{2}-\d{2}))).to_be_visible()这样既能验证内容存在又不会因为时间变化而失败。7.2 合理使用 to_contain_text 而不是 exact文本匹配的粒度也很重要。我见过有人用get_by_text(订单管理)但页面上既有“订单管理”菜单又有“订单管理”的标题就会报严格模式冲突strict mode violation。这时候要么用exactTrue精确匹配要么用to_contain_text来降低匹配粒度。当然优先还是给你的目标元素加测试 id这是最稳妥的。对于动态加载列表我常用to_contain_text来确认某一行的关键信息存在而不是校验整行文本的完全相等。整行相等太脆弱只要后端在前端加了一个空格或者一个新的 label用例就挂了。7.3 把断言看成业务验证的一部分断言不是用来炫耀技巧的它实际上是测试用例的“验收标准”。如果你的断言只写了“元素存在”那这个用例其实没有多少价值。更有效的方式是结合业务状态去验证。比如登录成功以后可以断言右上角出现用户的真实姓名而不是只断言跳转到了首页。这个判断背后体现了对业务逻辑的理解。我在指导团队时经常强调一个好的 UI 断言至少要验证两样东西——元素可见以及它的状态符合预期。可见可以通过 locator 等待状态可以用属性断言。比如一个确定按钮在某种条件下是 disabled 的那么在测试中就应该有对应的断言expect(page.get_by_role(button, name确定)).to_be_disabled()不要只测“按钮在”还要测“按钮的行为对不对”。8. 最终的一点个人体会写到这整个人像是把过去几年踩过的坑又滚了一遍。坦白说Playwright 确实是目前我用过的 Web 自动化测试工具里“智商”最高的一个但工具再聪明也架不住测试代码本身写得随意。真正让测试套件稳定下来的不是某个神乎其技的 API而是你对运行时状态的把控、对用例原子性的理解以及对失败现场的敬畏之心。在实际项目里我最大的收获是两个习惯一是每个用例都必须可以在任何顺序、任何单独执行的情况下通过二是每次失败都必须保留完整的 trace 和浏览器日志绝不在没有证据的情况下重试。这些东西听起来简单但能做到的人不多。希望这篇避坑策略能帮你绕开我当初撞过的墙让你的自动化测试从“能跑”走向“稳跑”。