最近把组里的端到端测试整套迁移到了 Playwright,折腾了一个多月,跑通了核心流程,也踩了不少坑。回头看看这个选择,其实很值得聊一聊。标题叫“Playwright 自动化测试学习”,但我想写的不是官方文档翻译,而是一个实际搞过项目的人,从选型到落地、从踩坑到稳定的完整路径。如果你正准备引入一个新框架,或者正在犹豫要不要把老的 Selenium 脚本重写一遍,这篇文章应该能帮你少走不少弯路。
先说结论:Playwright 确实值得学,而且它的设计思路对测试开发这件事本身就有很大影响。我自己从它身上学到的不只是 API 怎么调,更多的是“怎么把低价值、高脆弱的脚本,变成稳定、可维护、真正能守住质量的资产”。
1. 为什么选 Playwright:框架选型背后的逻辑
1.1 经历过 Selenium 的人都懂
我自己是 Selenium 老用户,从 WebDriver 时代开始写,项目里维护了几百条用例。说实话,Selenium 能用,但用得很辛苦。最大的问题不是功能不够,而是“等待”这件事需要手动管理。你要么到处塞Thread.sleep(),要么自己封装一个 WebDriverWait,不断试错显式等待条件。到了 CI 上,最常见的结果就是——昨晚上还好好的用例,今天早上红了一片,点开日志大概率是“element not interactable”或者超时。
这不是脚本写错了,而是页面渲染变慢了、某个接口延迟了、或者弹窗遮挡了元素。Selenium 的模型是“你告诉浏览器做什么”,至于做的时候元素在不在、能不能点,它不管,一旦操作失败就抛异常。所以开发者需要自己去解决时序问题,这其实是把框架的短板转嫁给了使用者。
1.2 Cypress 的进步与局限
后来社区里 Cypress 火过一阵,它的设计理念确实先进:自动重试、内置断言、开发者体验好,还提供了时间旅行式的调试体验。如果你完全在 Node 生态内、只测浏览器内的行为,Cypress 是很舒服的。
但我在实际项目里遇到了几道坎。第一,Cypress 建立在它自己的命令队列机制上,很多现代前端框架的非标准交互,尤其是多标签页、新开窗口、跨域 iframe 这类场景,支持起来很别扭。第二,它的架构是运行在浏览器内,很多功能受限于同源策略,做接口 mock 和网络层控制不如预期灵活。第三,也是最重要的——当我同时要跑 WebKit 内核的兼容性回归时,Cypress 的支持一直比较弱。
1.3 Playwright 的破局点在哪
Playwright 的底层是直接基于浏览器调试协议(CDP)工作的,这意味着它跟浏览器之间是“原生”的服务关系,而不是像 Selenium 那样通过中间协议转发指令。它对浏览器内部控制力强得多,等待机制、网络事件、渲染状态都能感知。
它把 Selenium 最难的显式等待变成了默认行为。比如click()之前,Playwright 会自己去确认元素“已附加到 DOM、可见、稳定、可接收事件”,满足条件才真正点击,否则一直等待直到超时。这不是简单的sleep,而是一套有状态的智能等待策略。
用一句话总结:Playwright 把“尽力而为”的自动化,变成了“面向状态”的自动化。这也是我最终选择它的核心原因。
2. 环境准备与第一份测试代码
2.1 安装和浏览器内核管理
我项目用的是 Node.js 生态,所以下面的命令基本是npm体系。Python 版的pytest-playwright原理类似,选择哪种取决于团队技术栈。
npm init playwright@latest这个命令会帮你初始化测试项目结构,默认生成一个tests目录和playwright.config.ts配置文件。注意它不会自动下载浏览器,需要单独执行:
npx playwright install这条命令会下载 Chromium、Firefox、WebKit 三个浏览器内核,体积不小。如果你只想跑 Chromium,可以加参数:
npx playwright install chromium在 Linux CI 环境上,还要装系统依赖:
npx playwright install-deps很多人在 Docker 或 Jenkins 里跑不起来,八成是漏了这一步。系统缺了底层动态库,浏览器起不来,报错信息又往往很隐晦。
2.2 配置文件的几个关键项
playwright.config.ts是测试的“总闸”,理解和调好它比写测试代码更重要。我最常调整的几项:
import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests', timeout: 30000, expect: { timeout: 5000 }, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 4 : undefined, use: { baseURL: 'https://example.test.com', headless: true, viewport: { width: 1280, height: 720 }, screenshot: 'only-on-failure', trace: 'retain-on-failure' }, projects: [ { name: 'chromium', use: { browserName: 'chromium' } }, { name: 'firefox', use: { browserName: 'firefox' } } ] });几个容易被忽略的点:
expect.timeout和全局timeout是两回事。全局超时管的是整个测试用例的执行时间,断言超时管的是单个expect的等待时间。我习惯把断言超时调小一点,这样元素迟迟不出现时能更快失败、更快暴露问题。trace是 Playwright 的杀手级功能,建议始终打开,失败自动保留。调试时相当于有了一台“时空机”,后面会细说。retries在本地关掉,在 CI 开 2 次,避免偶发网络抖动把整晚的回归全染红。
2.3 先用 Codegen 写出第一个用例
很多人一上来就手写选择器,其实没必要。Playwright 提供了codegen模式,可以在图形界面里操作浏览器,同时自动生成脚本。这是学习门槛最低的入口。
npx playwright codegen https://example.test.com/login执行后,浏览器窗口和一个脚本录制面板会同时出现。你在页面上输入账号、密码、点登录,代码就一行行生成。生成完复制到测试文件里,稍微整理一下就是一条用例。
我用这个功能带过不少刚接触自动化测试的同事,基本 10 分钟就能跑通第一个用例。先理解框架怎么“理解”你的操作,再动手去写复杂逻辑,会顺畅很多。
3. 核心 API 的理解与实战
3.1 三个级别:Browser、Context、Page
Playwright 里最核心的三个对象是Browser、BrowserContext、Page。技术文档写得很清楚,但很多人没意识到“隔离”这件事的价值。
Browser就是浏览器进程实例,比如chromium.launch()得到的对象。BrowserContext是一个独立的浏览器会话,相当于一个隐身窗口。在这个 context 里设置的 cookie、localStorage、权限,不会影响其他 context。Page则是 context 里的一张标签页。
这种分层设计带来的直接好处是:测试天然隔离。我跑一个涉及登录态的用例时,只需要在 beforeEach 里创建一个新 context,再在新 page 上操作。不需要清 cookie、不需要 reload,每个用例都是“白纸状态”。
import { test, chromium } from '@playwright/test'; test('独立的 context 互不干扰', async () => { const browser = await chromium.launch(); const contextA = await browser.newContext(); const contextB = await browser.newContext(); const pageA = await contextA.newPage(); const pageB = await contextB.newPage(); await pageA.goto('https://example.test.com'); await contextA.addCookies([{ name: 'token', value: 'A', url: 'https://example.test.com' }]); // pageB 拿不到 pageA 的 cookie,因为 context 是隔离的 const cookiesB = await contextB.cookies(); });如果从 Selenium 迁移过来,你会明显感觉“隔离性”这件事不再需要自己操心了。以前多个测试类之间搞不好就串了 session,现在 context 一开,物理隔离。
3.2 Locator 与自动等待:脚本变稳的核心
Locator是 Playwright 里最重要的概念,但很多新手理解成“就是一个查找元素的 API”,其实不止。
Locator 是惰性求值的,它解析选择器的动作不会立刻发生,而是等到你调用.click()、.fill()、.textContent()这些动作时才真正去 DOM 里找。加上所有动作都有自动等待,所以在脚本里写page.locator('button').click()时,Playwright 会轮询直到按钮满足可点击条件。
自动等待的具体条件很有意思,它是组合式的:
- 元素在 DOM 里存在
- 元素可见(有非零尺寸且没有
visibility: hidden) - 元素稳定(连续两次动画帧位置不变)
- 元素可接收事件(没有被遮挡)
- 元素可编辑(对输入类操作适用)
正因为有这套机制,我重写 Selenium 用例时删掉了 80% 以上的等待逻辑。这里想提醒的是:不要在任何地方主动加waitForTimeout。除非你在验证极特殊场景,否则主动 sleep 反而会破坏 smart waiting 的自动判断。你 sleep 一秒钟,页面可能两秒后才加载完;但你 sleep 三秒钟,又等于把不稳定的锅甩给了等待时间本身。相信我,删掉所有 sleep 后,用例稳定性是上升的。
3.3 选择器策略:从脆弱到稳健
选择器是测试稳定性的大头。XPath 写起来方便,但页面前端加一个 div、改一层结构就挂。我的经验是,优先用用户可感知的属性定位,而不是结构。
Playwright 内置了丰富的定位语义:
// 推荐:用 Role 定位,接近用户视角 await page.getByRole('button', { name: '提交' }).click(); // 推荐:用 Label 定位表单 await page.getByLabel('用户名').fill('test'); // 推荐:用 TestId 定位非语义化元素 await page.getByTestId('submit-btn').click(); // 不推荐:XPath 依赖 DOM 结构 await page.locator('//div[@class="form"]/div[2]/button').click();用getByRole有个额外的好处,它顺带校验了可访问性。某个按钮明明叫“提交”,但页面上却有两个同名的,脚本就会报“strict mode violation”提示你定位不唯一。这能反过来帮你发现前端的语义问题。
如果元素既没有可读文本,也没有合适的 role,那就要求前端在代码里埋>import { test as base, expect } from '@playwright/test'; // 扩展一个带登录状态的 fixture export const test = base.extend({ loggedInPage: async ({ page }, use) => { // 通用登录逻辑 await page.goto('https://example.test.com/login'); await page.getByLabel('用户名').fill('tester'); await page.getByLabel('密码').fill('password'); await page.getByRole('button', { name: '登录' }).click(); await page.waitForURL('**/dashboard'); // 把已登录的 page 传给测试用例 await use(page); } }); test('已登录状态下的个人中心', async ({ loggedInPage }) => { await loggedInPage.goto('https://example.test.com/profile'); await expect(loggedInPage.getByText('我的订单')).toBeVisible(); });
还有一个更高效的做法,就是先用storageState保存登录态。第一次登录后,Playwright 会把 cookie 和 localStorage 存成一个 JSON 文件,后面所有测试直接加载这个状态,不需要再走一遍登录流程。
// 第一次运行:登录并保存状态 await page.context().storageState({ path: 'auth.json' }); // 后续运行:直接复用 const context = await browser.newContext({ storageState: 'auth.json' });这个方案在跑大型回归时提速非常明显。但我踩过坑:如果后端 session 有过期时间,比如 2 小时,那保存的存储态在 CI 长任务里会失效。解决方案是写一个自动刷新逻辑,检测到跳转登录页就重新走一遍登录流程,刷新存储状态再继续。
4.2 并行执行与 CI 集成
Playwright 默认就可以多 worker 并发跑。配置项workers控制并发数,每个 worker 跑自己的浏览器实例。我本机 16 核,跑 300 条用例大概 6 分钟;如果串行,接近 40 分钟。差距就是这么大。
但并行也有代价——你要确保测试之间没有资源冲突。比如多个用例同时创建同名订单,数据库就会撞。我的建议是:核心业务数据要在用例内部构造,不要依赖外部预置数据,用 API 调用在测试开始前去创建一份属于这条用例自己的数据。这样并行天然安全。
CI 集成方面,我用的是很常见的 GitLab CI + Docker 镜像方案。Dockerfile 里提前装好浏览器依赖,跑任务时直接执行:
npx playwright test --reporter=html --reporter=dot报告上传为 CI artifact,失败时可以点开 HTML 报告直接看录制视频和失败截图。这里有个细节:如果镜像里不缓存浏览器,每次 CI 都重新下载,耗时会非常难看。我把npx playwright install放进镜像构建阶段,而不是每次跑测试时再执行。
4.3 覆盖几个高频场景:上传下载、多标签页、iframe
实际项目里除了点击输入,还有不少“绕不过去”的场景,这里把我验证过的方案写下来。
文件上传:Playwright 处理得比很多框架直接。直接用setInputFiles就能喂文件,不需要打开系统文件选择框。
await page.getByLabel('上传附件').setInputFiles({ name: 'report.pdf', mimeType: 'application/pdf', buffer: Buffer.from('fake pdf content') });也可以不传 buffer,直接传本地文件路径:
await page.getByLabel('上传附件').setInputFiles('tests/fixtures/sample.pdf');文件下载:重点在于download事件可能要过一会儿才触发。正确姿势是同时等待事件和点击动作,用Promise.all。
const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: '导出数据' }).click(); const download = await downloadPromise; const path = await download.path();多标签页:点击“在新标签页打开”这类操作后,页面跳到了一个全新的 Page 对象。最稳的处理方式不是context.pages()去猜,而是等待事件。
const pagePromise = context.waitForEvent('page'); await page.getByRole('link', { name: '新窗口打开' }).click(); const newPage = await pagePromise; await newPage.getByText('新页面内容').waitFor();iframe:登录流程最容易遇到 iframe。Playwright 的 frame locator 写起来直接:
const frame = page.frameLocator('#login-iframe'); await frame.getByLabel('手机号').fill('13800000000'); await frame.getByRole('button', { name: '获取验证码' }).click();这里有一个从 Selenium 迁过来的人常见误区:frame 一旦被页面重新渲染,原来的frameLocator会失效,需要重新获取。不要在循环里复用同一个 frameLocator 对象,每次操作前重新定位一次最保险。
5. 网络拦截、Trace 与调试:进阶三板斧
5.1 拦截与 Mock:前端不再等后端
E2E 测试最大的痛点之一,是依赖后端接口稳定。后端一抖,前端测试就跟着红。Playwright 的路由拦截能把这个依赖从根上切掉。
await page.route('**/api/order/list', route => { // 攔截请求,伪造想要的响应 route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 0, data: { list: [] } }) }); });也可以把请求重定向到本地 mock 服务:
await page.route('**/api/user/**', route => route.continue({ url: 'https://localhost:8080/mock/' + route.request().url().split('/').pop() }));这个能力解放了我很大一部分工作。前端联调后端之前,我就可以先把所有接口 mock 成预期值,把所有 UI 流程跑通。等后端真的联调时,再把路由拦截摘掉,回归一遍真实链路,定位问题会快很多。
5.2 Trace Viewer:测试界的时空机
Trace Viewer 是我向所有同事推荐的第一功能。启动 trace 后,Playwright 会把测试过程中的每一步操作、DOM 快照、网络请求、控制台日志、截图全部记录下来。
在配置里开启:
use: { trace: 'retain-on-failure' }也可以手工指定某一条用例完整记录:
test('需要重点排查的用例', async ({ page }, testInfo) => { await page.context().tracing.start({ screenshots: true, snapshots: true }); // ... 测试逻辑 ... await page.context().tracing.stop({ path: `trace-${testInfo.title}.zip` }); });跑完后,用命令行打开:
npx playwright show-trace trace-file.zip在界面上你可以拖动时间轴,看到每一步之前元素是什么状态,点击动作发生的那一刻 DOM 长什么样。有一次排查“偶发点击失效”,我通过 trace 发现,在点击按钮的前一步,页面弹出了一个不在预期里的浮层,把按钮盖住了。这在普通日志里完全看不出原因,但 trace 一眼就锁定了问题。
5.3 CI 里的稳定性工程化手段
测试上 CI 之后,稳定性不完全是“脚本写得好不好”的问题,还涉及很多环境因素。我沉淀了几个非常有效的实操手段。
第一,失败自动重试,但别一个用例无限重试。我在 CI 开retries: 2,这个次数能滤掉偶发的资源加载延迟,又不会因为重试掩盖真实的逻辑缺陷。
第二,失败自动产物收集。配置screenshot: 'only-on-failure'和trace: 'retain-on-failure',再配合视频录制:
use: { video: 'retain-on-failure' }这样每次 CI 失败,我都有一整套“案发现场”:截图、视频、DOM 快照、网络面板。解决问题时间从小时级下降到分钟级。
第三,串行执行特定敏感用例。有些业务场景对并发不友好,比如支付回调、金额校验。这时可以给这些用例打上特殊 tag,在 CI pipeline 里分两条任务:普通用例并行跑,敏感用例单独串行跑。这是性价比很高的取舍。
6. 常见问题与排查速查表
6.1 超时类问题
现象:TimeoutError: page.goto: timeout 30000ms exceeded
排查步骤:
- 先确认是不是网络问题——打开 trace,看请求状态码,DNS 解析是否正常
- 确认页面是否有重定向地狱——短链跳长链再跳登录页,goto 会一直等
- 检查是否需要服务端渲染——如果页面 JS 在
headless模式执行有问题,同样会超时
常见解法:
- 合理放大
timeout - 用
waitUntil: 'domcontentloaded'替代默认的load - 把
goto和后续断言的等待职责分开,让 goto 只负责导航完成
6.2 环境与运行时报错
现象:Error: browserType.launch: Executable doesn't exist at ...
这是浏览器没装好。本地跑npx playwright install,CI 检查 Docker 镜像构建日志。
现象:Target page, context or browser has been closed
这是操作一个已经关闭的页面对象。常见于多标签页管理,原页面点了链接后newPage出现了,但原 page 被服务端重定向或被前端关闭。排查时不盯着报错行,而是顺着 trace 看页面生命周期。
现象:Element is not attached to the DOM
这是页面 DOM 被前端框架更新了。元素之前定位到了,执行动作前被重新渲染,导致引用失效。解法是不要缓存长时间存活的 locator,每次操作前重新定位,或者用locator.click()自带的自动等待重新发现元素。
6.3 稳定性排查流程
我内部整理了一套排查“flaky test”的流程,自己也一直在用:
第一步,看有没有主动 sleep。有就先全删掉。第二步,开 trace,观察失败发生在哪一步操作,前一步页面状态是什么。第三步,检查网络请求——失败前是否有超时、慢响应、非预期重定向。第四步,检查选择器——是否命中了多个元素,命中的元素是否会被动画影响位置。第五步,确认测试数据是否被并行测试污染。
快 80% 的“偶发失败”跑完这五步都有答案。剩下的 20%,大概率是后端偶发 5xx、第三方服务抖动,这类可以在 CI 上重试消化,不用过度投入人力去追。
7. 一点个人感受
正式把 Playwright 落地到项目之后,我最大的体会是:自动化测试的维护成本,主要不取决于你用哪个框架,而取决于定位元素的方式和等待策略的合理性。Playwright 把等待这件事从“开发者的责任”变成了“框架的默认能力”,这个转变直接降低了团队的写作门槛。组里第 3 天就有新人提交了第一条像样的 E2E 用例,放在 Selenium 时代,这个周期至少是两周。
我还会建议大家从现有业务里挑一个“频繁回归、每次都要花半小时手工点”的流程,比如订单发货、发票申请这种,用 Codegen 先跑通一遍。你会在第一个用例跑通的时候,直观感受到“手点十分钟、脚本点十秒”那种爽感。然后你会和我一样,把越来越多的核心流程交给它去守。
测试代码也是代码,需要 review、需要重构、需要关注长期维护成本。Playwright 给了我们一套很好的基础工具,真正能让它发挥价值的,还是你对业务的理解和对“什么值得测”的判断力。