
这几年做自动化测试工具换了一茬又一茬但Playwright算是少数几个让我愿意把整个测试团队带过去的框架。今天起因是团队里几个新人看我给的资料总在“编写测试”这一步卡住——不是不会装环境而是一上手写用例就各种别扭。我索性把playwright/test这套测试编写的基本功系统梳理一遍从用例组织、定位器、断言到常见调试坑一次性讲透。如果你正准备入坑Playwright或者已经装了环境但写测试时总觉得不顺手这篇应该能帮上忙。我在这篇里会把核心概念讲清楚也会把我在实际项目中踩过的坑直接写在对应位置。内容偏向Playwright官方文档“编写测试”这一部分的本地化解读但不会逐段翻译而是结合真实场景把逻辑讲明白。1. 为什么选Playwright写自动化测试先说个背景。Playwright是微软开源的一套跨浏览器自动化框架底层通过CDP协议DevTools Protocol控制Chromium同时自家实现了对Firefox和WebKit的驱动。和Selenium最大的区别在于Playwright默认就是“面向现代Web应用”设计的对SPA、Shadow DOM、弹窗、多标签页这些场景的支持好得多。1.1 从Selenium时代到Playwright时代核心思想变了早年间用Selenium写测试最头疼的是什么同步。页面加载快了慢了测试就挂。要么硬sleep要么整一堆显式等待代码又臭又长。Playwright的思路直接换了个赛道一切操作都是“自动等待”你点一个按钮它不会傻傻地点而是会等按钮可点击、可见、不再动画、没有被遮挡然后才真的点下去。这个机制叫actionability检查。这带来的直接好处是测试代码里基本可以去掉所有自定义的sleep和等待逻辑。我见过不少从Selenium转过来的同事写了两行就下意识想加time.sleep(1)在Playwright里这基本是多余的而且往往会拖慢整个测试套件。另一个变化是运行模型。Playwright Test也就是playwright/test不只是个库而是一套完整的测试运行器。它内置了断言库、测试夹具Fixture、并行执行、重试机制、HTML Report、Trace Viewer调试工具。也就是说你不需要再拼装JUnit Selenium TestNG或者pytest selenium allure这种全家桶开箱就能用。1.2 Playwright与同类框架的对比选择思路我知道肯定有人会问那和Cypress、Appium这些比呢简单说下我的选型判断维度PlaywrightCypressSelenium多浏览器Chromium/Firefox/WebKit仅Chromium为主所有主流多标签页/多上下文原生支持支持弱支持弱移动端模拟内置设备模拟有限依赖Appium测试运行器自带完整Runner自带Runner需组装调试工具Trace Viewer Inspector时间旅行快照靠第三方网络拦截/Mock原生强大原生强大需代理方案如果团队要写的是纯Web端E2E测试Playwright是当前综合体验最好的选择之一。如果涉及移动App原生控件那还是得上Appium但Web端完全可以用Playwright覆盖。2. 写测试前必须想清楚的事很多新手拿到Playwright就开始写test()但写着写着就乱了。测试代码比业务代码更需要结构感因为测试天然会越写越多没有组织方式就会变成一坨不可维护的“脚本堆”。2.1 测试文件、用例、断言的基本组织方式在playwright/test里一个测试文件里可以放多个test()每个test就是一条独立的测试用例。一个项目里可以有很多个测试文件通常按业务模块或页面维度来拆分。tests/ login.spec.js user-center.spec.js order-flow.spec.js每个文件里可以继续按功能点分组用test.describe()把相关的用例包在一起。比如订单模块可以有“创建订单”“取消订单”“订单列表筛选”几个describe块。举个最基本的例子const { test, expect } require(playwright/test); test(首页可以正常打开, async ({ page }) { await page.goto(https://example.com); await expect(page).toHaveTitle(Example Domain); });这里test接收一个异步函数参数里的page是内置夹具每个测试都会自动分配一个独立页面测试结束自动关闭。你不用手动管浏览器实例也不用担心用例之间互相污染。2.2 夹具Fixtures是Playwright测试的灵魂夹具fixture这个词听着玄乎其实就是“每个测试用例的公共资源”。Playwright内置了好几个常用夹具browser、context、page。大多数情况下你只需要page但如果要测多用户或多页面交互你可以在测试里同时拿到browser和contexttest(两个页面之间通信, async ({ browser }) { const context1 await browser.newContext(); const context2 await browser.newContext(); const page1 await context1.newPage(); const page2 await context2.newPage(); // ...业务逻辑 });更关键的是Playwright允许你自定义夹具把“登录态”“测试数据准备”“权限设置”这些重复逻辑抽成夹具。比如做一个“已登录用户”的夹具const { test: base } require(playwright/test); exports.test base.extend({ loggedInPage: async ({ page }, use) { // 每个用例开始前先登录 await page.goto(https://example.com/login); await page.getByLabel(用户名).fill(tester); await page.getByLabel(密码).fill(password); await page.getByRole(button, { name: 登录 }).click(); await expect(page).toHaveURL(/dashboard/); // 把准备好的page交给测试用例 await use(page); } });然后在用例里直接用const { test } require(./my-fixtures); test(登录后可以访问个人信息, async ({ loggedInPage }) { await loggedInPage.goto(https://example.com/profile); await expect(loggedInPage.getByText(张三)).toBeVisible(); });这样做的好处是登录逻辑只写一次几十个用例都能复用而且每个用例依然是独立的互不影响。2.3 测试钩子beforeEach / afterEach怎么用如果你不想动夹具也可以用钩子函数做前置和后置操作。Playwright提供了beforeEach、afterEach、beforeAll、afterAll四个钩子在describe块里使用。我比较推荐的做法是单元级别的数据准备放beforeEach全局只跑一次的公共操作放beforeAll。举个例子test.describe(订单模块, () { test.beforeEach(async ({ page }) { // 每个用例前都先登录 await page.goto(https://example.com/login); await page.getByLabel(用户名).fill(tester); await page.getByLabel(密码).fill(password); await page.getByRole(button, { name: 登录 }).click(); }); test(创建订单, async ({ page }) { // ... }); test(取消订单, async ({ page }) { // ... }); });这里有个细节要提醒beforeEach里如果操作了page那么每个用例开始时的page就是“已经处于登录状态”的页面。但要注意不要把所有准备都塞进钩子否则用例本身的可读性会变差。我在团队里定的规矩是前置操作尽量收敛到一行以内比如“登录系统”至于登录细节封装到夹具或函数里。3. 编写第一个Playwright测试从定位元素到断言讲完了组织方式可以动手写了。这一节我会从头过一遍你写第一个测试时的完整过程顺便把定位器和断言的常用姿势理清楚。3.1 安装与初始化按官方文档走就行两条命令npm init playwrightlatest这个命令会帮你创建配置文件playwright.config.js、tests目录还会装好浏览器内核。如果你已经装好了但浏览器还没下载可以单独执行npx playwright install装完之后你的package.json里会有playwright/test依赖。注意项目里同时出现的playwright和playwright/test这两个包是有区别的playwright/test是测试运行器写测试用例用的playwright是无头浏览器驱动库如果你要写“普通脚本”而不是“测试用例”才会直接用它。大部分场景下写测试只需要依赖playwright/test。3.2 页面操作goto、click、fill、截图Playwright的元素操作几乎都是Locator对象上的方法。先看最常用的几个动作test(在搜索框输入关键词并搜索, async ({ page }) { await page.goto(https://example.com); await page.getByPlaceholder(请输入关键词).fill(playwright); await page.getByRole(button, { name: 搜索 }).click(); await page.waitForURL(**/search?qplaywright); await page.screenshot({ path: screenshot.png }); });这段代码里做了四件事打开页面、输入关键词、点击搜索、等待URL变化。最后还截了个图方便失败时排查。需要注意fill这个动作会先清空输入框再输入。如果你只是想追加内容可以用pressSequentially或者直接page.keyboard.type。但日常场景fill最方便。3.3 断言expect的两种模式和常用匹配器Playwright的断言和Jest很像但针对Web场景做了大量封装。最核心的一点是Playwright的expect断言会自动重试直到超时为止。也就是说你断言某个元素可见时不用先waitFor断言本身就带等待功能。常用的几个匹配器const { test, expect } require(playwright/test); test(断言示例, async ({ page }) { await page.goto(https://example.com); // 元素可见 await expect(page.getByText(更多内容)).toBeVisible(); // 元素包含某段文本 await expect(page.locator(.header)).toContainText(欢迎); // 页面标题 await expect(page).toHaveTitle(/Example/); // URL匹配 await expect(page).toHaveURL(/login/); // 输入框的值 await expect(page.getByLabel(用户名)).toHaveValue(tester); // 下拉框选中项 await expect(page.getByLabel(城市)).toHaveValue(beijing); });另外一个重要玩法是expect.poll和expect.soft。poll可以轮询任意函数直到满足条件适合接口没有直接返回页面值时做判断。soft则让断言失败时不中断用例适用于一个页面里要同时检查多项细节希望全部跑完再统一看失败项。3.4 定位器Locator最佳实践与选择器优先级定位器是Playwright里最值得花时间的部分。选错了选择器测试就天天在跑红灯选对了整个套件稳如老狗。我个人总结的优先级如下用户可见的角色和文本getByRole、getByText、getByLabel结构化属性getByPlaceholder、getByAltText、getByTitle测试专用标记getByTestIdCSS选择器慎用但仍可用XPath能不用就不用这个顺序背后的逻辑是越贴近“用户怎么看待页面”的选择器越不容易因为样式改动或DOM结构调整而失效。比如getByRole(button, { name: 提交 })用户看得到按钮上的字“提交”选择器就找那个字比CSS类名稳定得多。如果团队规模大、页面复杂我非常推荐在关键交互元素上加上data-testid属性然后用getByTestId定位。这样前端改样式、改文案都不影响测试。button>await page.getByTestId(submit-btn).click();这看起来多写了一行HTML但能在几个月后帮你省下几百条用例的维护时间。4. 实际场景下的测试组织与复用单个测试好写难的是几十个测试怎么组织、怎么复用、怎么并行不冲突。这一节聊几个我在真实项目里的做法。4.1 多页面流转测试怎么拆一个完整的业务链路往往跨多个页面比如“注册-登录-下单-支付”。如果把这四个环节全写进一个test里用例会很长而且中间任何一步挂了后面全废排查成本也高。我的建议是拆成多个test但用一个“业务步骤函数”把公共链路抽出来。比如async function registerUser(page, userInfo) { await page.goto(https://example.com/register); await page.getByLabel(用户名).fill(userInfo.username); await page.getByLabel(密码).fill(userInfo.password); await page.getByRole(button, { name: 注册 }).click(); await expect(page).toHaveURL(/welcome/); } test(注册后进入欢迎页, async ({ page }) { await registerUser(page, { username: alice, password: pass123 }); await expect(page.getByText(欢迎alice)).toBeVisible(); });拆出来的registerUser函数能被多个用例调用同时保证每个用例的独立性。4.2 利用describe和标签管理测试规模测试数量超过几十条后你必须考虑“这条用例属于哪个模块”“CI上能不能只跑冒烟用例”。Playwright提供了test.describe和test.tag或者说test.skip、test.fixme这些标记来管理。test.describe(支付流程, () { test.skip(({ browserName }) browserName ! chromium, 只在Chromium上跑); test(支付宝支付, async ({ page }) { // ... }); test(微信支付, async ({ page }) { // ... }); });运行的时候可以按标题加tag过滤npx playwright test --grep sanity测试标题里带上tag比如test(首页登录 sanity, async ({ page }) { // ... });这样CI上就能只跑冒烟集避免每次改动都全量执行。4.3 数据驱动的参数化测试如果你有大量相似的用例只参数不同不要复制粘贴。Playwright支持循环里定义test也支持JSON参数文件。最简单的方式const users [ { username: alice, expectedText: 欢迎alice }, { username: bob, expectedText: 欢迎bob }, ]; for (const user of users) { test(用户${user.username}登录后看到欢迎语, async ({ page }) { await page.goto(https://example.com/login); await page.getByLabel(用户名).fill(user.username); await page.getByLabel(密码).fill(password); await page.getByRole(button, { name: 登录 }).click(); await expect(page.getByText(user.expectedText)).toBeVisible(); }); }运行结果里每个参数组合都是独立用例失败互不影响报告里也能清楚看到是哪个参数组合出了问题。5. 调试与问题排查实录写测试不踩坑是不可能的。这一节是我觉得全篇最有价值的部分——把我在真实项目里遇到的高频问题和排查思路整理出来。5.1 常见报错元素定位不到、超时、Strict Mode玩Playwright的人一定见过几种报错。我挑最常见的三个说。第一个locator.click: Timeout exceeded。这个报错说明元素在规定时间默认30秒内没有达到可操作状态。原因通常是页面没加载完、元素被遮挡、元素不可见、或者选择器指向了多个元素。排查顺序先看选择器是不是全局唯一再用Inspector模式看元素是不是真的在页面上如果都没问题就要怀疑是不是有遮罩层或者动画还没结束。第二个strict mode violation。当选择器匹配到多个元素时Playwright会直接报错而不是默认点第一个。这是它和Selenium很大的不同。解决方法是把选择器写得再具体一点或者用locator.first()、locator.nth()来选。第三个Element is not attached to the DOM。这个常见于单页应用你拿到了某个元素的引用但操作时这个节点已经被框架重新渲染掉了。解决办法是重新locate这个元素不要在引用上死磕。5.2 调试技巧Trace Viewer、headless调试、慢动作Playwright最香的调试工具就是Trace Viewer。运行测试时加上--trace on就能在测试结束后生成一份包含所有页面截图、DOM快照、网络请求、控制台日志的时间线文件。npx playwright test --trace on然后找到报告入口npx playwright show-report在失败用例里打开Trace Viewer你能看到每一步操作前后的页面快照甚至能直接点击“DOM快照”来看当时的HTML。这对定位那些“时好时坏”的用例帮助极大。另外一个我常用的调试姿势是如果把headless关掉直接看浏览器跑npx playwright test --headed想让操作慢一点可以在配置里设置use: { launchOptions: { slowMo: 500 } }slowMo的单位是毫秒。实测下来调试时开到300-500ms就够用了。5.3 稳定性的几个坑网络等待、动画等待最后总结几条提升稳定性的经验。第一不要用固定的sleep等待网络请求。Playwright虽然推荐你少写等待但不是说不等而是要等具体条件。比如等导航完成用waitForURL等某个接口返回用waitForResponse等某个元素出现在特定状态用expect轮询。await Promise.all([ page.waitForResponse(resp resp.url().includes(/api/order) resp.status() 200), page.getByRole(button, { name: 提交 }).click(), ]);这段代码的意图是点击提交的同时等待后端接口返回200。这种写法比sleep可靠得多。第二小心页面里长时间动画。比如Toast提示、下拉面板展开动画。默认情况下Playwright等元素稳定之后才点击所以大多数动画不会造成问题。但如果你遇到了“明明visible了却还是点不了”的怪事多半是元素在持续移动或被其他元素遮挡这时可以用expect.poll轮询某些自定义状态或者干脆等动画结束再操作。第三不要滥用toHaveScreenshot。截图对比在大项目里很崩溃——字体渲染、动画帧、网络图片加载都会造成不稳定。如果非要用记得固定viewport、mock掉高风险接口、控制并发。我个人的建议是只对关键页面用而且配合阈值设置。写在最后Playwright的“编写测试”其实没有多高深核心就是三件事把公共逻辑收进夹具或函数用用户视角的定位器选元素用带自动重试的断言替代手动等待。把这三点吃透了你已经超过很多写了两年测试但天天修脚本的人。我自己从Selenium迁到Playwright之后最大的感触是测试代码终于不是“负担”了——它更像是项目的另一个人工测试员写完之后能睡个安稳觉。最后再分享一个小技巧如果在本地经常跑同一组用例可以在playwright.config.js里把workers设小一点比如workers: 2避免并行太多导致浏览器资源不够反而更慢。这个配置在CI上可以改回默认值让并行效率最大化。