说真的,绝大多数测试团队用Playwright跑到第三个月,就会开始琢磨一件事:用例是越写越多了,但执行时间也越来越离谱。几十条用例还好说,几百条摆在CI里排队跑,一次全量回归动辄四十分钟往上,每次改完代码等结果等到泡面的水都凉了。不少人第一反应是加机器、加并发,但搞了一阵子发现,并发加上去以后稳定性反而往下掉,各种资源抢占、超时失败像约好了一样组团出现。
我在实际项目里也踩过这趟泥坑。后来把Playwright性能优化从头到尾捋了一遍,从调度策略、代码写法、浏览器配置到CI集成都做了调整,整套回归时间缩短了将近70%。这十个技巧不是什么高深魔法,全是能直接落地的东西,今天一次性整理出来分享给大家。
无论你是刚上手Playwright的小白,还是已经被测试时长折磨到想重构的“老司机”,这篇文章都能给你一些可以直接抄作业的思路。所有技巧我都尽量给出具体的参数、示例代码和背后原理,方便你根据自己项目的实际情况取舍。
1. 先搞清楚时间花在哪里:性能优化的前提
不做定位就动手优化,基本等于闭着眼睛打靶。你得先知道测试时间到底消耗在哪个环节,是等待网络、是页面渲染、是固定等待写多了,还是用例本身就慢。这一步省掉,后面的所有技巧都用不到刀刃上。
1.1 借助Trace Viewer定位瓶颈
Playwright自带trace录制能力,在配置里把trace选项打开,跑完用例后会把整个执行过程打包成一个zip文件,浏览器里打开trace报告,能看到每一步操作的时间线、网络请求、控制台日志和截图。这个工具是我做性能分析时用得最多的,没有之一。
// playwright.config.ts import { defineConfig } from '@playwright/test'; export default defineConfig({ use: { // 建议本地调试开 on-first-retry 或 retain-on-failure trace: 'on-first-retry', }, reporter: [['html', { open: 'never' }]], });实际使用的时候,我习惯把trace配置成几个档位:
| 配置值 | 使用场景 | 说明 |
|---|---|---|
off | 生产CI稳定运行 | 完全不记录,零额外开销 |
on-first-retry | 日常回归 | 首次失败才记录,平衡性能与排障 |
retain-on-failure | 本地调试 | 成功不保留,失败保留,方便排查 |
on | 性能专项分析 | 每条用例都记录,分析成本最高 |
性能优化期间建议直接开on,把所有用例的执行细节数据都收集一遍,拿到时间线之后你会发现很多隐藏问题,比如一个按钮点击操作前后居然空等了3秒,或者某个图片资源阻塞了页面交互。
1.2 从测试报告里找出慢用例
Playwright的HTML Reporter除了展示通过率,还会按用例耗时做列表展示。我一般先把全量用例跑一次,导出最耗时的Top 20用例清单,按总耗时降序排列。
怎么快速导出?可以直接在配置里换一个JSON reporter,跑完之后用脚本处理一下报告数据。
// playwright.config.ts export default defineConfig({ reporter: [ ['line'], ['json', { outputFile: 'test-results/report.json' }], ], });跑完之后,用Node或者Python脚本解析report.json里的suites字段,把每个用例的duration拉出来排序一下,前20条就是重点优化对象。注意不要只看单次耗时,要看多次运行的平均值,因为首跑可能有缓存冷启动,数据不稳定。
1.3 把耗时分类拆解
拿到慢用例之后,我习惯把耗时拆成四类:
- 等待耗时:页面加载等待、selector自动等待超时、固定sleep。
- 网络耗时:接口响应慢、静态资源体积大、第三方请求阻塞。
- 渲染耗时:页面JS执行慢、动画与交互导致操作不稳定。
- 执行耗时:测试代码自身逻辑慢,比如大量复杂断言、频繁截图。
怎么拆?Trace报告里每一步操作都有Action时间,操作前后的视图状态也能看到。还有一种办法:在用例代码里临时打几个console.time,分段测量关键步骤的耗时。
test('模拟分段计时', async ({ page }) => { console.time('goto'); await page.goto('https://example.com/'); console.timeEnd('goto'); console.time('click-login'); await page.getByRole('button', { name: '登录' }).click(); console.timeEnd('click-login'); });这招虽然土,但特别有效。分段计时能直接告诉你时间大头在哪一段,不用瞎猜。
2. 先动调度策略:不改代码也能省时间的思路
说实话,改测试代码需要谨慎,动一行可能要跑全量回归验证,但如果只是调调度策略,风险就小得多。很多时候性能问题不在用例本身,而是执行方式不太对。
2.1 并行执行:用对workers参数
Playwright默认是串行跑的,也就是一条用例跑完,下一条才开始。如果你的机器CPU核数足够,把workers参数调上去就能立刻提速。
export default defineConfig({ workers: process.env.CI ? 4 : undefined, // CI上固定4并发,本地默认CPU核心数 });不过这里有个隐藏问题:并发开太高,每个worker都会启动独立的浏览器实例,内存消耗翻倍。16核机器开16并发,有时候内存先爆了,用例反而挂掉。我的经验是,workers数建议设置为CPU核数的一半到四分之三,然后结合内存大小微调。
补充一个比较实用的技巧:在本地调试的时候,用--workers=1强制串行,避免多个浏览器实例抢焦点导致调试体验差。CI里再放开并发,两边不冲突。
2.2 分片执行:让CI机器各干各的
如果你的CI有多个runner机器,用分片(sharding)比单机并发效果更好。核心思路是把测试集按比例切块,分到不同的机器上跑,每台机器只需要跑自己的那一份。
# 在CI上给Playwright传参 npx playwright test --shard=1/4 npx playwright test --shard=2/4 npx playwright test --shard=3/4 npx playwright test --shard=4/4分片可以让总耗时从“最长的一份耗时”决定,而不是所有用例串行累加。实际项目里,4台机器跑同一个回归集,时间能压缩到单机并发的三分之一左右。
注意分片不是越细越好。分片太碎,每台机器上跑不了几条用例,启动浏览器、加载依赖的开销占比反而变大。一般按用例总数量除以每台机器能力来定,我常用的是每片包含30~50条用例。
2.3 调整执行顺序:让慢用例别堵住队列
Playwright 本身执行顺序是按文件顺序跑的,慢用例如果排在前面,后面的快用例都得排队等。可以在不改变用例逻辑的前提下,把慢用例的文件名调整一下,或者用--grep-invert在CI里先排除慢用例,主力跑快用例,慢用例单独用分片跑。
# CI里的一个思路:快速反馈跑得快的那批 npx playwright test --grep-invert "@slow"然后在慢用例文件顶部加上@slow标签。
test.describe('慢用例分组', () => { test('跨模块业务流程 @slow', async ({ page }) => { // ... }); });这个方法的核心逻辑是让开发花最少的时间等反馈,慢回归单独安排,等主干验证通过后再并发跑全量。虽然听起来简单,实际提速效果非常明显。
3. 代码层面的十技巧(上篇):从写法的底层逻辑提速
调度策略只是第一刀,更持久的优化在用例代码本身。这个部分我拆成十条实操技巧,每条都讲原理,讲完直接给示例。
3.1 技巧1:定位器选对,别让自动重试拖慢你
Playwright的自动等待是双刃剑。定位器执行操作时,如果元素状态不满足,它会自动轮询重试直到超时。定位器越不精确,条件满足需要的时间就越长。
我见过很多人喜欢用page.locator('div.container > div:nth-child(3) > button')这种从外层DOM节点逐级往下写的路径。这种定位器对DOM结构变化极度敏感,而且每次操作前都要走一遍整条链路检查,性能很差。
更合理的做法是优先用角色、文本、表单标签等用户可见语义来定位。
// 慢:嵌套层级深,且依赖DOM结构 await page.locator('.content-wrapper .main-area .item-list button.btn-submit').click(); // 快:直接按角色和名字定位 await page.getByRole('button', { name: '提 交' }).click();语义定位器之所以快,是因为Playwright可以直接执行浏览器内置的角色计算,几乎不需要遍历整个DOM树。同时它更稳健,省去重试次数,间接节省时间。
3.2 技巧2:用自动等待取代固定等待
“固定sleep”是测试用时膨胀的最大凶手,没有之一。很多人写着写着就习惯await page.waitForTimeout(3000),仿佛浏览器需要睡三秒才会响应。
自动等待是Playwright的内置机制,所有定位器执行操作前,都会自动等待元素可见、稳定、可操作。真正需要的等待,应该用显式的状态等待来表达。
// 慢:无论多快都得等3秒 await page.waitForTimeout(3000); await page.getByRole('button', { name: '登录' }).click(); // 快:元素可交互时立即点击 await page.getByRole('button', { name: '登录' }).click(); // 需要等待特定状态时,用显式expect await expect(page.getByText('加载完成')).toBeVisible();你可能觉得waitForTimeout改成自动等待没什么,但全项目几百条用例,每条少等一两秒,累计起来就是几分钟到十几分钟的执行时间。这也是我每次做性能审查时最先扫的东西——全文搜索waitForTimeout,一个都不留。
3.3 技巧3:复用登录状态与全局上下文
每一条用例都走一遍完整登录流程,等于把同一次UI操作重复了N遍。如果登录环节需要等短信验证码、等待跳转、等待接口返回,这部分开销全被重复计算了。
Playwright提供了storageState机制,可以把登录成功后的cookie、token等状态保存下来,后续用例直接复用,完全跳过UI登录步骤。
// 第一步:专门跑一次登录并保存状态 test('登录并保存状态', async ({ page }) => { await page.goto('/login'); await page.getByPlaceholder('用户名').fill('tester'); await page.getByPlaceholder('密码').fill('password'); await page.getByRole('button', { name: '登录' }).click(); await expect(page.getByText('工作台')).toBeVisible(); await page.context().storageState({ path: 'state.json' }); });// 第二步:其他用例直接复用 export default defineConfig({ use: { storageState: 'state.json', }, });执行时只有第一条用例做完整登录,其他用例启动会话后直接带着登录身份进入页面。如果测试系统的登录态有效期比较长,甚至可以让专门的“登录状态维护”任务夜里跑一次,白天所有用例都复用同一份状态,大幅减少重复登录耗时。
3.4 技巧4:把UI操作挪到API层
这是我在项目里收获最大的一条。很多用例的核心目标其实是“验证某功能在特定数据下的表现”,而不是“验证用户从录入页面提交表单的操作过程”。如果是后者才需要走UI,前者完全可以直接通过API接口准备测试数据。
以“购物车结算流程”为例,如果每一条用例都用UI去往购物车加商品、调整数量、选择收货地址,至少多出十几秒。换成API请求直接构造购物车状态,再到UI上执行结算验证,执行效率完全不在一个量级。
test('购物车结算', async ({ page, request }) => { // 通过API直接向购物车写入测试商品 const api = await request.post('/api/cart/items', { data: { productId: 123, quantity: 2 }, }); expect(api.ok()).toBeTruthy(); // 打开UI页面时,购物车数据已经就绪 await page.goto('/checkout'); await page.getByRole('button', { name: '提交订单' }).click(); // 只验证UI流程本身 });不过这条要谨慎使用。如果一个页面操作本身就是被测对象,那绝对不能绕开UI。我的判断标准是:这条用例的目的是“验证业务流程”还是“验证页面交互”。前者优先用API前置,后者保留UI操作。
3.5 技巧5:请求拦截与资源精简
页面里加载的大图片、第三方统计脚本、非核心字体,在执行用例时往往会拖慢页面加载速度,尤其是外部资源。Playwright支持路由拦截,你可以把没必要的请求直接拦截掉。
// 拦截图片、字体和统计脚本,让页面快速进入可操作状态 await page.route('**/*.{png,jpg,jpeg,gif,webp,svg}', route => route.abort()); await page.route('**/*.{woff,woff2,ttf}', route => route.abort()); await page.route('**/analytics.js', route => route.abort());就这一条,很多页面加载时间能压缩20%到40%。但要注意,不是所有页面都能一刀切拦截。比如做页面样式回归测试时,CSS和字体就得保留;涉及文件上传预览的场景,图片请求也不能拦。建议从trace报告里看时间线,找到加载耗时前三的外部资源再定向优化。
4. 代码层面的十技巧(下篇):从生命周期管控压缩开销
上面五条偏“少做事”,下面这五条更偏“减少重复性开销和无效执行”。
4.1 技巧6:合理管理浏览器与上下文的开与关
Playwright每次启动浏览器、创建上下文都会有一定开销。如果你每个文件都写一个beforeEach,里面启动一个新的浏览器实例,那几十个文件的启动开销就不是小数目。
更高效的做法是用fixture管理浏览器生命周期,让同一条worker线程内的用例复用浏览器,只对每个测试场景创建新的上下文(Context)。上下文本身是隔离的,不影响用例独立性。
import { test as base, expect } from '@playwright/test'; // 自定义fixture,复用浏览器实例 export const test = base.extend({ context: async ({ browser }, use) => { const context = await browser.newContext(); await use(context); await context.close(); }, });通过这种方式,同worker线程内浏览器只启动一次,后续用例都在这一个浏览器实例里创建新标签页或新上下文。执行时间能省下一大截。实际场景下,浏览器冷启动开销在某些系统上能到两三秒,一百个文件就是两百多秒的纯启动耗损。
4.2 技巧7:视频录制与截图策略要克制
Playwright支持自动录屏、自动截图,排查问题时确实香,但这些功能非常耗资源。录制视频会把整个页面动画、交互都编码进文件,截图每一步也会增加IO开销。
在CI稳定运行的阶段,我用的是“平战分离”策略:
| 模式 | 视频 | 截图 | trace | 适用阶段 |
|---|---|---|---|---|
| 本地开发 | 关闭 | 失败截图 | retain-on-failure | 快速调式 |
| CI日常 | 关闭 | 失败截图 | on-first-retry | 回归验证 |
| 发布前全量 | 失败录屏 | 失败截图 | on | 重点保障 |
具体到配置写法:
export default defineConfig({ use: { screenshot: 'only-on-failure', // 只在失败时截图 video: 'off', // 稳定期不录屏 }, });稳定性优先的团队可以保留失败录屏,用于定位偶现问题;但稳定运行的高频CI建议全关,能省下可观的IO和编码开销。
4.3 技巧8:把重复操作做成自定义Fixture
Fixtures除了管理生命周期,还可以把重复性操作编排成可复用的组件。比如测试后台上大量用例都需要创建某类项目,都先去打开新建页,填表单,点保存。重复逻辑如果散落在每条用例里,执行时重复跑同样步骤,非常浪费。
更好的方式是做成一个fixture函数,把“创建项目”封装成动作,内部按需走API或快速UI路径。
type CreateProjectOptions = { name: string; owner: string; }; export const test = base.extend<{ createProject: (opts: CreateProjectOptions) => Promise<void> }>({ createProject: async ({ page }, use) => { const createProject = async (opts: CreateProjectOptions) => { // 跳转到创建页 await page.goto('/projects/new'); await page.getByLabel('项目名称').fill(opts.name); await page.getByLabel('负责人').fill(opts.owner); await page.getByRole('button', { name: '保存' }).click(); await expect(page.getByText('创建成功')).toBeVisible(); }; await use(createProject); }, });用的时候只需在用例参数里声明createProject,直接调用。这样每个测试文件的重复代码没了,执行效率也能通过fixture内部逻辑统一优化。
4.4 技巧9:超时与重试策略要精细配置
超时配置太长,用例失败时会卡很久;超时太短,慢网络环境又容易误报。Playwright默认的expect超时是5秒,action超时用的是timeout选项,而整个测试的超时由test.setTimeout控制。
我给常用配置的建议值:
| 配置项 | 数值 | 理由 |
|---|---|---|
| expect超时 | 5000ms | 断言轮询等待的兜底,够用且不至于太长 |
| action超时 | 15000ms | 页面操作在慢环境下也能留出余量 |
| 单个测试超时 | 30000ms | 普通用例30秒跑不完多半是静态等待写多了 |
| 重试次数 | CI: 1次 / 本地: 0次 | 避免偶现失败阻塞本地开发 |
export default defineConfig({ expect: { timeout: 5000, }, timeout: 30000, retries: process.env.CI ? 1 : 0, });重试也不能无限堆,重试次数越多,整体耗时翻倍风险越大。一条失败用例重试一次,整体耗时只增加很少;但每条用例都重试两次,等于执行时间接近三倍。
4.5 技巧10:给性能装上“预算”护栏
前面九条优化完,如果不设护栏,时间会随着用例增长悄悄反弹。Playwright的report里能拿到每个用例时长,我建议在CI上加一个简单脚本,对总执行时间做阈值判断。超了就报警。
小型测试集比较简单,直接在CI命令后接一个时长检查命令:
# 假设全量回归时长超过900秒就标记失败(示例环境) npx playwright test --reporter=json | tee report.json python3 check_duration.py report.json 900check_duration.py里就做一件事:读取JSON报告,把各用例耗时汇总,和阈值比较,超了就非零退出。这样团队在MR阶段就能感知性能趋势,而不是等到全量回归越来越慢才后知后觉。
从我的经验来看,持续的性能监控比一次性优化更重要。用例会增长,功能会复杂化,资源请求会变多,如果没有护栏,半年后你又会回到“泡面等结果”的状态。
5. 实测案例:一个慢用例的优化全过程
理论说再多,不如看一个实际优化案例。这是我项目里一个真实的慢用例,优化过程完全可复现。
5.1 优化前:问题表象
被测场景是“后台用户管理页面的搜索与导出”,原始用例大概长这样:
test('用户列表搜索与导出', async ({ page }) => { await page.goto('/admin/users'); await page.waitForTimeout(3000); await page.fill('#search-keyword', 'testuser'); await page.click('#search-btn'); await page.waitForTimeout(5000); await page.click('.export-btn'); await page.waitForTimeout(3000); // 更多断言与清理... });这条用例单跑耗时约28秒。三条固定等待加起来就11秒。页面加载本身可能只要2秒,却等了3秒;搜索请求可能1秒就返回,却等了5秒。
5.2 优化操作步骤
第一步,先把固定等待全部换成自动等待和显式断言。
test('用户列表搜索与导出', async ({ page }) => { await page.goto('/admin/users'); await expect(page.getByText('用户管理')).toBeVisible(); await page.getByPlaceholder('搜索关键字').fill('testuser'); await page.getByRole('button', { name: '搜 索' }).click(); await expect(page.getByText('搜索结果共1条')).toBeVisible(); await page.getByRole('button', { name: '导 出' }).click(); await expect(page.getByText('导出任务已创建')).toBeVisible(); });仅仅去掉三个waitForTimeout,单条时间从28秒降到了13秒。
第二步,开启路由拦截,把页面顶部与本次用例无关的统计脚本、头像图片挡住。
test.beforeEach(async ({ page }) => { await page.route('**/analytics/*', route => route.abort()); await page.route('**/avatar/*', route => route.abort()); });这一条直接把首屏加载时间进一步压缩。优化后单条用例耗时约8秒。
第三步,把用例中的导出动作前置数据改造成API准备。
test('用户列表搜索与导出', async ({ page, request }) => { // API预置测试用户 await request.post('/api/users/testuser', { data: { name: 'testuser', role: 'admin' }, }); await page.goto('/admin/users'); // 搜索结果直接有数据,省去等待 await page.getByPlaceholder('搜索关键字').fill('testuser'); await page.getByRole('button', { name: '搜 索' }).click(); await expect(page.getByText('搜索结果共1条')).toBeVisible(); // ... });最终单条用例跑下来稳定在5秒左右,优化前的28秒压缩了超过80%。
5.3 优化后的对比数据
| 指标 | 优化前 | 优化后 | 收益 |
|---|---|---|---|
| 单条用例耗时 | 28s | 5s | 节省82% |
| 固定等待 | 11s | 0s | 完全消除 |
| 外部资源请求 | 42个 | 8个 | 减少80% |
| 失败率 | 千分之几 | 稳定 | 稳定性提升 |
这个案例里,最惊人的收益来自第二步路由拦截。功能页面本身很多资源并非业务必需,跑测试时拦掉不仅加快了速度,也减少了浏览器渲染压力,连带着内存占用都有所下降。
6. 常见问题与排查技巧实录
优化过程中不会一路顺风,我把自己踩过的一些坑和对应的排查方法整理出来,方便你对照着查。
6.1 并行执行后资源竞争怎么办
症状:开了并发之后,用例原本能过,现在偶现失败;失败信息多为“页面无响应”“元素超时”等。
排查思路:
- 先看是不是后端测试环境撑不住了。并发变多,对后端接口QPS的压力翻倍,如果测试环境本身配置不高,接口响应时间会拉长。
- 检查用例之间是否共享了某些本地文件、数据库记录、临时目录。并发执行时,如果两条用例同时写同一个文件,就会互相干扰。
- 降低
workers值试一次。如果降到一个比较低的值就稳定下来,说明瓶颈大概率在资源竞争而不是用例本身。
我的建议是:先保证用例绝对独立,再谈并发。共享状态越多,并发带来的问题就越严重。
6.2 隐藏的慢请求怎么找
症状:页面操作没有明显等待,但用例整体耗时还是长。打开trace时间线全是加载过程。
排查技巧:在page.on('requestfinished')和page.on('requestfailed')事件里记录每个请求的URL和耗时,打印出来排序,你不费什么功夫就能找到耗时大户。
test('查找慢请求', async ({ page }) => { const requests: Array<{ url: string; duration: number }> = []; page.on('requestfinished', async request => { const response = await request.response(); const timing = response?.timing(); if (timing) { const duration = timing.responseEnd - timing.startTime; requests.push({ url: request.url(), duration }); } }); await page.goto('/admin/users'); // 按耗时排序打印 const topSlow = requests.sort((a, b) => b.duration - a.duration).slice(0, 10); console.table(topSlow); });一般常见的问题是第三方的统计脚本、广告SDK、埋点脚本,这类请求在测试环境完全没必要加载,用路由拦截处理掉即可。
6.3 webServer启动机制导致的重复执行
症状:配置了webServer之后,每次执行测试前都重复启动服务,等待时间非常长,尤其是多分片场景,每个分片都启动一遍服务。
Playwright的webServer有reuseExistingServer参数,如果端口服务已启动,就可以直接复用。
export default defineConfig({ webServer: { command: 'npm run start:test', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, timeout: 120000, }, });本地开发设置为true,可以让测试直接复用本地已启动的服务;CI里设置为false,保证每次跑的都是干净环境。这样既保证本地调试不重复起服务浪费时间,也不影响CI的隔离性。
6.4 其他容易忽略的隐藏损耗
- 第三方cookie与缓存未处理:有些场景下,页面每次加载都重复请求较多静态资源,配置
serviceWorkers: 'block'可以阻止service worker干涉,减少额外开销。 - 长用例拆短:一条用例如果你写了两百行断言,且串行依赖很重,一旦中间失败后面全跳过,还占用大量执行窗口。建议拆成多条独立用例,并行度更高,失败影响面也更小。
- CDN资源不可达:测试网络环境访问外网CDN不通时,会一直等到超时才失败,这一类的等待是隐藏性能杀手,优先路由拦截或本地mock。
最后再分享一点个人体会
我从一开始觉得“性能优化就是多开几个并发”,到后来慢慢体会到:真正的提速,不是让浏览器跑得更快,而是让不必要的事情完全不发生。去掉多余等待,拦掉无关请求,复用已有状态,把UI操作改到API层——每一条都是在做减法。做得越久越发现,一套高效稳定的测试集,不光是为省几分钟,更是为了让每次代码合并前,大家有信心去点那个“跑全量”的按钮。如果CI快到你几乎感觉不到它的存在,那团队自然会更愿意频繁回归,测试的价值也会真正发挥出来。
这些技巧里,最值得马上动手的,就是先把所有waitForTimeout搜出来清掉。别小看这一步,它带来的变化会让你在第一次跑完优化后,就明显感觉到整个测试管线轻快了一大截。至于后面的路由拦截、API前置、分片调度,完全可以等项目稳定下来以后,一条一条地推进。