
团队最近把前端端到端测试套件从 45 分钟压到了 12 分钟核心就是用 Playwright 性能优化那一套方法论重新梳理了一遍用例。说实话做自动化测试的人都有同感用例写出来不难难得是跑得又快又稳。尤其是当用例数量上了三位数之后每次跑完看一眼耗时表心里那叫一个复杂。这篇文章整理的就是这些实测下来真正有用的干货一共 10 个实用技巧从并行执行、资源拦截、等待策略到数据预置基本覆盖了 Playwright 缩短测试时间的主要方向。适合正在被测试耗时折磨的测试开发、前端工程化同学也适合刚上手 Playwright 但不想一上来就踩坑的新手。新手最需要知道的一点是Playwright 并不是一个慢工具它慢在“默认配置”。浏览器启动、图片加载、动画等待、trace 录制、串行执行这些默认行为叠加在一起测一个页面动辄十几秒。优化思路其实就一句话——不加载的资源不加载不必要的时间不等待能并行的用例不串行。1. 先弄清瓶颈测试慢在哪优化才有方向1.1 我踩过的性能坑与常见瓶颈分布在做优化之前我先花了一个下午给整个测试套件做了耗时画像。这一步非常重要因为“感觉慢”和“真正慢在哪”往往是两回事。我当时的测试套件里单用例耗时有高有低高的能到 40 秒低的也要 6 秒平均下来跑一轮要 45 分钟。耗时画像统计下来瓶颈主要是这几个启动和关闭浏览器实例的时间占了大约 15%。这个问题在测试文件数量多的时候尤其明显因为每个测试文件默认都会启动自己的浏览器实例。页面加载时图片、字体、视频、第三方统计脚本全部加载多了不少等待时间这部分占了大概 20%。用例里大量使用sleep(3)或者固定等待以及一些 Web 应用交互动画要等 400ms 或 1s这类硬编码等待叠加起来占到了 25%。数据准备全靠 UI 操作一个创建流程跑一遍要 8 到 12 秒三个用例就快半分钟了这占了 10%。CI 上还是单机串行执行CPU 和内存资源实际只用了四分之一。这个分布其实很典型。网络带宽越差、测试环境响应越慢的环境里浏览器启动和资源加载的占比还会更高。所以优化后我立刻就能看到测试套件单跑执行速度提升了大概 2 倍再叠加并行执行才达到最终 12 分钟的效果。1.2 十个技巧总览与优先级划分我建议不要一开始就把十个技巧全部铺开而是先把收益最大的几个落地。下面这张表是我自己整理的一个优先级参考后文会逐个展开。技巧核心动作预估收益实施成本1. 并行执行Pytest 多 Worker 并发跑测试文件接近线性提升低2. CI 分片按 CI 机器分片并行接近线性提升低3. 资源拦截拦截图片、字体、媒体请求减少 20% ~ 40% 加载等待低4. 请求监听与超时监听请求合理硬超时快速失败减少单用例无效等待低5. 去掉固定 sleep改为自动等待和轮询断言减少 20% ~ 30% 等待中6. 选择器策略用 getByRole、getByText 替代脆弱选择器提高定位稳定性间接提速低7. 浏览器启动参数headless、禁用 GPU、禁用沙箱、合屏渲染单用例节省 1~3 秒低8. 复用上下文与 API 预置数据用 request 上下文预置测试数据减少 30% 以上的 UI 前置操作中9. 诊断功能按需开启关闭 trace、video只在失败时录制节省数秒到数十秒极低10. 缓存与语义化用例fixture 级缓存AI 语义自动生成用例长期维护收益高较高这十个技巧不是孤立使用的很多是配合关系。比如第 3 个资源拦截和第 4 个请求监听通常一起用第 8 个 API 预置数据可以为第 1 个并行执行减少很多不稳定因素。下面我按章节把每个技巧的“为什么”和“怎么做”讲透。2. 并行执行与浏览器实例策略第一笔大收益2.1 Pytest 插件与多 Worker 配置如果你的测试框架用的是 Pytest那一定不要错过pytest-xdist。它在不改变用例代码的前提下让多个 Worker 进程并行跑不同的测试文件这是 Playwright 缩短测试时间最立竿见影的一招。配置起来很简单。先在项目里安装依赖pip install pytest-xdist然后在 pytest.ini 里加一行或者命令行直接指定-n auto[pytest] addopts -n autoauto会根据机器的 CPU 逻辑核数自动决定 Worker 数量。我的开发机是 8 核 16 线程实测 4 个 Worker 时性价比最高因为浏览器进程本身也要吃内存开 8 个 Worker 会导致内存吃紧。在 CI 上我反而推荐先定一个保守数值比如-n 4再根据内存余量往上调。一个重要前提是并行意味着每个 Worker 进程都有自己的浏览器实例如果你的用例之间共享了本地数据库文件、共享了同一份静态临时文件或者依赖执行顺序那要先处理好这些隔离问题。pytest-xdist 的设计原则是“测试文件级并行”所以同一个文件里的用例默认还是串行执行的这就很安全。2.2 分片策略与 CI 资源利用如果你的 CI 机器不止一台那还可以用 Playwright 官方的分片能力。分片比多 Worker 更彻底它是把整个测试集合按指定数量切成多个片每台机器跑其中一部分。常见的做法是 GitHub Actions 里用matrix配一个并发矩阵strategy: fail-fast: false matrix: shardIndex: [1, 2, 3, 4]然后在执行脚本里加npx playwright test --shard${{ matrix.shardIndex }}/4这样 4 台机器并行跑每一台跑的用例不会重复。要注意的是分片并不保证每片用时完全均匀因为每片里的用例有大有小。如果你有特别重的用例可以手动给它们标上test.describe.configure({ mode: serial })或者放到独立文件里避免一个重用例拖垮整片。我个人在 CI 上的组合拳是先把用例按业务模块分成几个测试目录再把每个目录拆成独立 CI JobJob 内再用-n 4做二级并行。两层并行下来整个流水线从原来的 45 分钟降到 15 分钟以内而且单个 Job 的资源使用率非常平稳。2.3 浏览器启动参数与复用上下文默认配置下 Playwright 在无头模式会做很多防御性工作比如启用 GPU 进程、沙箱、硬件加速判断。对于测试来说这些大部分可以关掉。下面是运行速度影响最大的几个启动参数browser await playwright.chromium.launch( headlessTrue, args[ --disable-gpu, --disable-dev-shm-usage, --no-sandbox, --disable-extensions, --disable-background-timer-throttling, --disable-backgrounding-occluded-windows, --disable-renderer-backgrounding, --disable-ipc-flooding-protection, ] )这里解释一下关键参数的作用--disable-gpu无头模式本来就不需要 GPU 渲染加速关掉可避免 GPU 进程崩溃导致的重试。--disable-dev-shm-usage在 Docker 容器里非常关键否则/dev/shm太小可能直接让浏览器闪退。--disable-background-timer-throttling页面切到后台时浏览器会降低定时器频率关掉后能保证前端轮询逻辑在并发下正常工作。--no-sandbox仅在信任的测试环境里用核心原因是容器环境默认没有权限创建 sandbox。再想进一步降开销可以复用browser_context。多组用例共用同一个浏览器实例但是每个用例独立context这样能省掉每次启动浏览器的几秒钟。简单理解就是浏览器进程只启动一次页面之间互不干扰的隔离由 context 保证。3. 网络层拦截与资源瘦身少加载就是快3.1 用 route 拦截非关键资源网页里总有那么一类资源对 UI 测试没有任何影响但下载起来特别浪费时间大图、背景图、字体文件、视频、埋点统计脚本。Playwright 提供的route拦截能力可以让你在请求发出前就把它直接中止或改写为空响应。最常见做法是拦截掉图片和字体。以一个后台管理系统为例页面加载 80% 的流量都来自图片和图标字体拦截之后单页面加载时间从 6 秒降到 2.8 秒。代码如下import re async def block_unused_resources(route): url route.request.url if route.request.resource_type in [image, font, media]: await route.abort() elif re.search(r\.(png|jpg|jpeg|gif|svg|woff|woff2|ttf)$, url): await route.abort() else: await route.continue_() await page.route(**/*, block_unused_resources)如果你希望图片不加载但页面不报错可以不用abort()而是返回一个空图片的 base64 响应await route.fulfill( status200, content_typeimage/png, bodybase64.b64decode(EMPTY_IMAGE_BASE64) )这样对页面逻辑的影响最小。尤其是某些前端组件会用图片的onload事件做后续渲染这时候直接 abort 可能导致按钮一直处于 loading 状态而 fulfill 一个 1x1 像素图就不会触发这类问题。对于第三方统计脚本、广告 SDK 这类你根本不需要的资源类型直接按域名拦截更干净import re async def block_third_party(route): url route.request.url if any(domain in url for domain in [google-analytics.com, googletagmanager.com, doubleclick.net]): await route.abort() else: await route.continue_()拦截后有两个细节值得注意。第一个是某些站点的接口会校验资源完整性图片缺失可能导致前端接口调用被阈值限制第二个是 Electron 或内嵌浏览器环境下字体缺失时中文会直接变成方块。所以建议在本地开发模式下不要启用全局拦截只在 CI 或者测试环境里开。3.2 请求监听与超时治理除了拦截你还可以通过监听请求来判断页面是否真的加载完成。很多前端框架在首屏渲染时会同时发起多个接口请求但页面的load事件触发得比接口返回更早导致你一进页面去点击按钮结果接口还没回来数据是空的按钮点了没反应——这是测试不稳定的最大来源之一。我的做法是把“关键接口请求完成”当成一个更可靠的页面就绪信号。先用page.on(request)记录下接口请求再用page.wait_for_response等待对应接口返回async with page.expect_response(lambda response: /api/v1/order/list in response.url and response.status 200) as resp_info: await page.goto(https://example.com/orders) response await resp_info.value配合超时治理我会给每个这种等待都设置一个明确的时间上限。默认的 30 秒全局超时对慢用例太宽容了很多无效等待就是这么浪费掉的。在 Playwright 的 config 里可以这样收敛# playwright.config.py config { timeout: 15_000, expect: {timeout: 5_000}, }expect里的 5 秒是每个expect轮询的默认超时。这样某个按钮 5 秒内不出现就直接失败而不是傻傻地等满 30 秒。我在实际项目里还见过不少接口 10 秒才返回的场景这类“真慢”要单独在测试数据层处理而不是把全局超时拉高否则所有用例都会为它买单。3.3 拦截策略的边界与注意事项资源拦截虽然收益大但也不是拦得越多越好。我踩过最深的坑是拦截某个品牌官网的字体文件之后页面文字全部变成系统默认字体结果某个按钮的宽度变化导致后续点击偏移用例反复失败。排查了一个多小时才意识到是字体文件被拦掉了。所以做资源拦截要有一个基本判断原则只拦截那些“不影响行为逻辑、只影响观感”的资源。图片、视频、字体、埋点脚本通常是安全的但接口请求千万不能随便拦截因为那是业务逻辑的命脉。还要注意route的匹配规则是按注册顺序执行的。如果你先注册了某个 API 路径的route.continue_()又注册了全局资源的拦截那 API 路径的 rule 会先匹配到就不会被误伤。建议把精确路径的拦截放在前面把资源类型的拦截放在最后这样规则更可控。另外如果你同时用page.route(**/*, handler)监听所有请求并在 handler 里做了大量逻辑那也会带来轻微的性能损耗。实测下来一个 handler 里有字符串正则匹配还好但如果每次请求都做异步 IO比如查数据库判断是否拦截请求并发高时会产生明显排队。所以我建议 handler 保持纯函数风格不要在里面做耗时操作。4. 等待与选择器策略把隐性等待变成精准等待4.1 自动等待机制与过期固定等待Playwright 和 Selenium 最大的不同就是它内置了自动等待。每次执行click、fill、press这些操作之前Playwright 会先去检查元素是否“可操作”比如是否可见、是否稳定、是否接收入口事件。这个机制本身不慢慢的是大家拿到 Playwright 后仍然习惯性加上time.sleep()。这里要重点说清楚一个反直觉的点在 Playwright 里无脑加time.sleep(2)不仅不会让用例更稳定反而会掩盖真实前端状态变化导致测试和开发之间反馈变慢。更好的方式是依赖 Playwright 的 actionability 检查再用expect去做状态轮询。比如等待某个 loading 状态消失await page.goto(https://example.com/order) await expect(page.locator(.loading)).to_be_hidden(timeout10_000) await page.get_by_role(button, name提交订单).click()它会在 10 秒内持续轮询元素隐藏的瞬间就继续往下执行平均等待时间可能只需要几百毫秒而不是固定的 2 秒。相比sleep这种方式的耗时是动态的、真实的。我见过一个有意思的极端案例某个用例里写了四处sleep(3)优化之后改成自动等待单用例从 16 秒直接降到 7 秒。原因很简单前端在本地环境渲染只需要 500ms但测试脚本固定睡了 3 秒白白浪费了 2.5 秒。4.2 用 getBy 系列选择器替代脆弱选择器选择器本身不直接决定测试快慢但它决定了用例的一次通过率。一次通过率低就意味着要反复重试跑整体耗时自然就上去了。Playwright 官方推荐的getByRole、getByText、getByLabel这些基于可访问性语义的选择器比 CSS 类名选择器稳定得多也更贴近真实用户操作。举个例子。老的 CSS 写法await page.locator(.btn-primary.btn-large).click()只要前端把类名从btn-primary改成btn-main用例就挂了。换成await page.get_by_role(button, name立即支付).click()前端怎么改样式类名都不影响这个按钮的定位。再比如输入框用await page.get_by_label(用户名).fill(tester)它会自动匹配 label 关联的 input比硬写 CSS 路径健壮很多。如果项目对可访问性支持不好很多元素没有role或者 label那至少也要避免使用多层嵌套的长链 CSS。我的原则是“能不用 XPath 就不用 XPath”XPath 里一旦包含下标比如//div[3]/div[2]/buttonDOM 结构一变就直接失效。顺手提一下playwright codegen。你可以在浏览器里像普通用户一样操作一遍它会自动生成对应的选择器代码省去手工写选择器的时间。生成的代码不一定最优但可以作为起点对快速搭用例很有帮助。4.3 网络空闲与元素可见性等待的取舍Playwright 还提供了page.wait_for_load_state(networkidle)意思是一直等到 500ms 内没有网络连接才继续。这个概念听着很理想实际操作里要慎用因为很多页面会有轮询接口、websocket 长连接或者第三方统计脚本每几秒发一次请求导致networkidle永远等不到。我的经验是只有在你明确知道页面没有后台轮询且所有关键接口都在首屏触发完后才使用networkidle。否则优先用expect(response)等待关键接口再用expect(locator).to_be_visible()等待关键元素。还有一种“边操作边轮询”的思路。比如打开一个列表页你需要等列表数据渲染出来以后再搜索。可以这样await page.goto(https://example.com/list) await expect(page.get_by_text(共 1000 条记录)).to_be_visible()等待具体内容出现远比等待“网络空闲”要更贴近业务语义。换成白话就是你不要等整个厨房都安静下来才知道饭做好了你只需要看到菜端上桌就可以了。5. 前置数据与诊断开销减少 UI 操作和日志成本5.1 用 API 预置数据替代 UI 前置流程很多用例其实存在大量重复的前置操作。比如测一个“订单列表搜索”功能正好需要先创建一个订单测“订单详情”时又需要一个已支付订单作为前置条件。传统做法是让脚本从登录开始一路点按钮、填表单、提交订单整个流程下来光数据准备就 10 秒。我推荐把数据准备这层操作从 UI 剥离开来直接用 Playwright 的request上下文在后台调用接口创建数据。它的原理是模拟一个登录态复用同一个storage_state直接向接口发送创建请求比 UI 操作快很多而且稳定。具体步骤是先用 UI 登录一次把登录态保存下来context await browser.new_context() page await context.new_page() await page.goto(https://example.com/login) await page.get_by_label(用户名).fill(tester) await page.get_by_label(密码).fill(123456) await page.get_by_role(button, name登录).click() await context.storage_state(pathstate.json)后续用例里直接用这个 state 创建 API 请求request_context await playwright.request.new_context( base_urlhttps://example.com, storage_statestate.json, ) resp await request_context.post(/api/v1/order, data{ amount: 1000, pay_type: alipay, }) order_id (await resp.json())[id]拿到order_id之后再去跑 UI 层面真正要验证的操作。你会发现用例的执行时间从原来的 15 秒降到了 5 秒以内并且不再受前端表单控件变化的影响。如果后端还没有提供完整的数据制造接口也可以直接操作数据库预置数据。但这条路要小心Browser 会话和数据库之间可能有时序问题比如缓存、统计字段、状态机最好封装成一个独立的数据工厂模块方便统一维护和清理。5.2 关闭或分级配置 trace 和视频录制Playwright 默认在失败时会保存 trace、截图甚至视频这些诊断信息对定位问题非常有用但打开它们会让每个用例都慢上不少。原理不复杂trace 会把所有页面操作、DOM 快照、网络请求、控制台日志全部记录进一个 zip 文件视频录制则需要每一帧画面编码。这些 IO 操作都在用例执行的主路径上。playwright.config.ts里最省事的方式是分环境配置// 本地调试 use: { trace: on, video: retain-on-failure, screenshot: only-on-failure, }, // CI 回归 use: { trace: retain-on-failure, video: false, screenshot: only-on-failure, },如果某个用例特别难定位你还可以只针对它临时开 tracecontext await browser.new_context( record_video_dirvideos/, traceon, )等定位完再把trace关掉。这个方案的最大收益是保持平时执行的轻量同时保留了排查问题的途径。这里有个容易被忽略的点retain-on-failure并不是零成本即使最终没有失败它也要在内存里暂存这些数据直到用例结束才能决定是保留还是丢弃。如果你明确不需要视频我建议直接把 video 设为false而不是retain-on-failure。5.3 pytest fixture 缓存与 AI 语义结合的架构思路在 Python pytest Playwright AI 语义的自动化框架里性能优化和数据准备可以结合得很紧密。一个是利用 fixture 的作用域来控制数据生命周期一个是利用 AI 语义生成更精准的用例、定位和断言减少人工维护成本。先说 fixture 缓存。比如你希望“登录状态”只创建一次多个用例共用pytest.fixture(scopesession) def login_state(): context browser.new_context() page context.new_page() page.goto(https://example.com/login) page.get_by_label(用户名).fill(tester) page.get_by_label(密码).fill(123456) page.get_by_role(button, name登录).click() page.context.storage_state(pathstate.json) context.close() return state.jsonscopesession表示整个测试会话只执行一次后续所有使用该 fixture 的测试都复用这个登录态能省下大量登录耗时。再往下走一步你可以把某个业务对象比如一个“已支付订单”作为 session 级 fixture用一次 API 创建把它缓存到变量或 conftest 的模块变量里其他用例拿过来直接用。那“AI 语义”在性能优化里能做什么我目前的使用场景是辅助生成用例描述和选择器。比如用 AI 把自然语言操作步骤翻译成 Playwright 语义代码或者用语义模型判断某个按钮用get_by_role还是get_by_text更稳定。这个不会直接让单条用例变快但它减少了用例调试和重写的次数从总产出看大幅节省了时间。scrapy-playwright那套做动态页面抓取的思路也值得参考。因为爬虫场景同样被页面等待和资源加载拖慢很多人会在 Playwright 里拦截无用资源、动态等待 iframe 内容出现。这些技巧和纯测试场景的优化是相通的。如果你对 Playwright 监听页面请求已经很熟那这部分基本可以无缝迁移。6. 性能测量与 CI 落地优化要可量化、可回归6.1 建立基线测试时间与预算没有测量就没有优化。我的习惯是在优化前先记录一个基线总用例数、总耗时、单用例平均耗时、失败重试次数、CI 执行耗时。然后每改动一项优化再跑一轮全量测试对比基线看收益。这里推荐一个轻量做法在 CI 的产物里每月留存一份测试报告包含 Playwright 自带的耗时瀑布图。瀑布图能直接看出每个阶段loading、click、expect、type各占了多少毫秒定位到具体瓶颈会非常直观。还需要给项目设置“时间预算”。比如线上核心用户路径的端到端用例单条预算 5 秒涉及多接口的复杂业务单条预算 12 秒。一旦新增用例超过预算就说明需要从设计上拆分或者用 API 预置数据来降低 UI 操作成本。时间预算的本质不是限制测试行为而是倒逼团队思考哪些步骤可以通过后端预置数据解决哪些步骤可以通过拦截资源减少加载哪些步骤压根不需要端到端覆盖用接口测试取代就好。6.2 在 CI 中落地并行分片与资源限制CI 里的并行分片有一个很现实的坑多台 runner 同时跑测试如果测试环境后端只有一套性能瓶颈就从脚本端转移到了服务端。你有 4 台机器并行跑后端接口如果被压垮失败率反而飙升。针对这个问题我建议在 CI 脚本里设置并发度上限并且给测试环境做容量评估。日常回归中我会控制总并发浏览器实例数不超过后端服务能承受的峰值比如整个 CI 同时最多跑 16 个浏览器进程可以用workers和分片数量相乘估算出来。另一个容易被忽视的资源是测试环境的数据库。并行跑测试时多个用例可能同时去创建订单、创建用户如果数据库有唯一性约束就会出现偶发失败。解决办法是给每条用例都打上唯一标识比如在创建订单时把order_no拼上uuid4().hex[:8]从根源上避免数据冲突。最后CI 上建议把失败重试策略也收敛一下。个别用例偶尔失败并不是每次都需要立即重跑把重试次数从默认的 2 次降到 1 次并配合失败截图、trace 做异步分析能显著缩短反馈循环。6.3 常见问题速查表问题排查思路解决方案页面加载特别慢单用例 15 秒看瀑布图里哪个阶段耗时最高拦截图片、字体、媒体资源并行后内存不足浏览器闪退Worker 开太多或 container shm 太小减少 worker 数加--disable-dev-shm-usage固定 sleep 导致用例又慢又不稳搜索代码里的sleep、waitForTimeout改为自动等待和expect轮询用例间存在数据依赖并行后失败共享了数据库或文件状态用 API 预置数据给数据加唯一标识networkidle永远等不到页面有轮询或 websocket改用等待关键接口返回或关键元素可见拦截图片后按钮点不动图片 onload 影响组件渲染返回空图片 base64而不是直接 aborttrace 开着导致执行很慢trace 录制 IO 开销大改retain-on-failure平时关闭复用存储状态后接口 401Cookie 过期或包含跨域限制重新登录并导出新的 storage_state排查问题有一个总原则先看瀑布图再查网络请求最后再怀疑等待逻辑。90% 的“慢”都能落到这三类原因之一。最后再分享一个小经验性能优化不是一次性工作而是持续维护的动作。每次新增测试用例之前先问自己一句——这一步能不能在 API 层完成这个等待能不能用事件触发替代这个页面资源是不是必须加载。多问几次你的测试套件就能一直保持轻快。