
最近把团队里一套跑了两年的 UI 回归测试从 Selenium 迁到了 Playwright跑完一个迭代的稳定性对比之后我想认真聊聊官方文档里第一章“编写测试”。很多新手拿到 Playwright 的第一反应是先去装环境、录脚本反而忽略了文档第一章里早就埋下的核心设计测试用例怎么组织、断言怎么写才稳、选择器怎么定位才不脆。这一章读透后面写 E2E 测试、做自动化测试平台、接 CI 流水线都会顺很多。1. Playwright 初体验这套测试框架到底解决了什么问题1.1 为什么我从 Selenium 换到 Playwright先说背景。我最早做自动化测试用的也是 Selenium配合 WebDriver 跑了快五年。不是说 Selenium 不行而是它有一些“成年老账”隐式等待和显式等待混用时经常撞车、慢网络环境下定位元素动不动就超时、调试手段主要靠截图和堆日志写 UI 自动化测试的人大多数时间都耗在“等元素”和“猜为什么挂在第 3 步”上面。Playwright 由微软维护第一版出来我就试过后来稳定在 1.4x 之后基本成了我这边 E2E 测试的默认选项。它本质上是一个浏览器自动化库支持 Chromium、Firefox、WebKit 三大内核同样一套测试跑三种浏览器是常规操作。跟 Selenium 最大的区别是它内置了 web-first 的自动等待机制操作元素之前会自动检查元素是否可见、可点击、不在动画中、不被遮挡这些检查过了之后才执行真正的点击或输入。1.2 “编写测试”的核心思路把页面交互映射成代码我第一次用 Playwright 写测试时最大的感触是它不逼着你定义一堆“页面对象”或“工具类”而是提供一套很自然的 DSL把人的操作翻译成代码打开页面page.goto(https://example.com)点击按钮page.getByRole(button, { name: 登录 }).click()输入文本page.getByLabel(用户名).fill(admin)断言结果await expect(page.getByText(登录成功)).toBeVisible()读起来几乎就是把测试用例的步骤翻译成英文句子。官方文档第一章“编写测试”要解决的核心问题就是让你知道测试用例长什么样、放在哪里、怎么运行。1.3 三种使用姿势Test Runner、Codegen 与裸 APIPlaywright 有三种常见用法。最推荐的是它的测试运行器playwright/test自带测试组织、断言库、fixture、并行执行、Trace Viewer 报告这也就是文档第一章主要在讲的东西。第二种是 Codegen 录制npx playwright codegen打开一个浏览器你在里面操作它自动生成脚本适合快速起步。第三种是把 Playwright 当普通库用不依赖它的 test runner直接调用 API 写脚本适合做爬虫或者接到自研测试工具里。我个人建议做正式项目的自动化测试不要只依赖 Codegen 生成的代码。录制脚本能用来探索页面和快速验证定位器但真正要维护的测试用例必须手工整理尤其是断言和等待逻辑录制出来的脚本通常又啰嗦又不稳定。2. 环境搭建与工程配置十分钟跑起第一个用例2.1 安装与浏览器内核下载Playwright 的安装根据语言不一样Node 生态通常是npm init playwrightlatest它会问你用什么语言TypeScript/JavaScript、测试目录叫什么、要不要生成 GitHub Actions 工作流然后自动装好playwright/test并下载浏览器内核。Python 环境则是pip install playwright playwright install这里有个很容易踩的坑playwright install下载的是 Chromium、Firefox、WebKit 的定制版本体积不小默认存在用户目录下。如果你在 CI 或者 Docker 环境跑别漏了这一步否则会报类似“Executable doesnt exist”的错误。提示如果公司网络有限制浏览器内核下载不下来可以单独设置镜像地址。Python 版本还能通过PLAYWRIGHT_DOWNLOAD_HOST指定下载源但要注意版本必须匹配。2.2 配置文件里的关键参数初始化完成后会生成一个playwright.config.ts或.js这个文件是整套测试的地基。我拆几个最常用的配置讲import { defineConfig, devices } from playwright/test; export default defineConfig({ testDir: ./tests, timeout: 30_000, expect: { timeout: 5_000, }, use: { baseURL: http://localhost:3000, headless: true, screenshot: only-on-failure, video: retain-on-failure, trace: on-first-retry, }, projects: [ { name: chromium, use: { ...devices[Desktop Chrome] } }, { name: firefox, use: { ...devices[Desktop Firefox] } }, { name: webkit, use: { ...devices[Desktop Safari] } }, ], });testDir告诉测试运行器去哪里找测试文件timeout是每个用例的超时时间默认 30 秒expect.timeout是断言自动重试的最大时间headless决定是否显示浏览器窗口trace配置是否记录 trace 日志后面调试时非常有用。2.3 跑通第一个测试文件在tests目录下新建example.spec.tsimport { test, expect } from playwright/test; test(打开首页并检查标题, async ({ page }) { await page.goto(/); await expect(page).toHaveTitle(/Playwright 中文文档/); });命令行运行npx playwright test如果配置了baseURLpage.goto(/)会自动拼接成完整 URL。注意测试文件名约定默认匹配*.spec.ts和*.test.ts你想自定义的话可以在配置里改testMatch。3. 核心写法拆解test 用例、断言与元素操作3.1 用 test.describe 组织用例组当测试数量多起来光靠平铺的 test 会乱。官方文档第一章很强调用test.describe来分组作用类似其他框架里的 suiteimport { test, expect } from playwright/test; test.describe(登录模块, () { test.beforeEach(async ({ page }) { await page.goto(/login); }); test(正确密码可以登录, async ({ page }) { await page.getByLabel(用户名).fill(admin); await page.getByLabel(密码).fill(123456); await page.getByRole(button, { name: 登录 }).click(); await expect(page).toHaveURL(/dashboard); }); test(错误密码提示错误信息, async ({ page }) { await page.getByLabel(用户名).fill(admin); await page.getByLabel(密码).fill(wrong); await page.getByRole(button, { name: 登录 }).click(); await expect(page.getByText(用户名或密码错误)).toBeVisible(); }); });beforeEach会在组内每个用例前执行适合做公共的前置操作。还有beforeAll、afterEach、afterAll钩子本身也可以传入 fixture比如test.beforeEach(async ({ page }) { ... })。3.2 常用元素操作 API 一览文档里反复强调Playwright 的定位器locator是惰性的只有真正执行操作时才会去页面里找元素。这意味着你可以先定义好定位器后面再操作不会在上一步就报“找不到元素”。常用操作我整理如下操作API 示例说明打开页面await page.goto(url)支持waitUntil参数点击await locator.click()自动滚动到可视区域并等待可点击输入文本await locator.fill(text)清空原值再输入键盘操作await locator.press(Enter)支持组合键如ControlA下拉选择await locator.selectOption(value)支持 label、value、index勾选await locator.check()/uncheck()只适用于 checkbox/radio悬停await locator.hover()常用于显示下拉菜单聚焦await locator.focus()触发 focus 事件上传文件await locator.setInputFiles(path)直接输入本地文件路径3.3 断言为什么说“web-first 断言”是灵魂Playwright 的断言与 Jest 不同它最大的特点是自动重试。普通断言失败就立即抛错但 Playwright 会等上一段时间默认 5 秒反复检查条件直到超时才失败。这就是文档里说的“web-first assertions”。比如await expect(locator).toBeVisible()哪怕元素因为异步渲染晚出现几秒也不会误报。常用断言await expect(page).toHaveTitle(/登录/); await expect(page).toHaveURL(/user); await expect(locator).toBeVisible(); await expect(locator).toHaveText(文案内容); await expect(locator).toContainText(部分文案); await expect(locator).toHaveValue(input值); await expect(locator).toBeChecked(); await expect(locator).toBeDisabled();这里我建议团队里统一规则能用 web-first 断言就不用waitForTimeout。很多人遇到“页面跳转慢”就习惯性加await page.waitForTimeout(3000)这种固定等待在慢 CI 上会积累大量无意义时间在快机器上又容易产生竞态。正确做法是断言一个结果态比如 URL、按钮可点击、错误提示可见。3.4 显式等待与 locator.waitFor虽然 Playwright 会自动等待大多数操作但有些场景你需要手动等待。比如弹窗出现、网络请求完成、某个元素从 DOM 中移除。这时候可以用await page.getByText(加载中).waitFor({ state: detached }); await page.waitForURL(**/dashboard); await page.waitForResponse(**/api/user, response response.status() 200);如果只是等待一个元素出现locator.waitFor()比page.waitForSelector()更推荐因为定位器更语义化后面还能继续链式操作。4. 选择器与定位别再为元素找不到头疼4.1 优先用语义化定位器Playwright 文档第一章给了很明确的定位器优先级建议我实践下来非常管用。优先级从高到低getByRole按 ARIA role 和名称定位最贴近用户视角getByLabel按表单 label 关联文本定位getByPlaceholder按 placeholder 提示定位getByText按可见文本定位getByTestId按>form label foremail邮箱/label input idemail typeemail placeholder请输入邮箱 / button typesubmit提交/button /form用角色定位按钮page.getByRole(button, { name: 提交 })。用标签定位输入框page.getByLabel(邮箱)。这样写出来的测试几乎不需要改动就能适应页面层级调整远比写一大串#app form div input稳定。4.2 测试 ID 与 getByTestId 的最佳实践当页面上没有合适的 label 或文本时我建议前端同事配合加>div>const count page.getByTestId(order-count); await expect(count).toHaveText(3);如果项目里已经有人用>use: { testIdAttribute: data-cy, }这样getByTestId照样生效前置改动很小。4.3 多个匹配时的筛选与链式调用定位器经常碰到一个页面里同一文本出现多次的情况。用filter方法可以减少定位器范围const row page.getByRole(listitem).filter({ hasText: Playwright }); await row.getByRole(button, { name: 删除 }).click();如果还需要精确定位匹配项可以用first()、last()、nth(index)。但说实话我建议优先把hasText、has用起来少用 index 定位。index 一旦页面顺序变了测试就挂了排查成本高。4.4 iframe、Shadow DOM 和动态页面动态 iframe 是很多自动化测试里最难搞的部分。Playwright 提供了frameLocator不需要来回切换 context直接链式操作const frame page.frameLocator(#iframe-id); await frame.getByPlaceholder(请输入验证码).fill(1234); await frame.getByRole(button, { name: 确认 }).click();Shadow DOM 也支持穿透普通 CSS 定位时用locator(css自定义元素 内部元素)但更好的方式是用getByText、getByRole这类语义化定位器Playwright 默认会穿透 open shadow root。动态页面的核心准则是不要依赖固定等待。等一个元素的条件比睡 3 秒再操作要可靠得多。之前我做一个爬虫项目用 Playwright 渲染动态 iframe 里的表格数据重点就是先frameLocator设置好再expect(frame.getByRole(row)).toHaveCount(10)等表格行数稳定之后再采集。5. 复杂场景实战多页面、接口 Mock 与文件上传下载5.1 多标签页与 popup 处理点击target_blank链接时会新开页面。Playwright 推荐用 Promise 同时等待const [popup] await Promise.all([ page.waitForEvent(popup), page.getByRole(link, { name: 去新页面 }).click(), ]); await popup.waitForLoadState(); const title await popup.title();这里必须用Promise.all因为如果不先注册事件监听popup 可能在监听前就被打开导致事件丢失。这是新手最容易犯的错。5.2 用 page.route 做接口 Mock前后端并行开发的时候前端页面调用的接口还没准备好可以用page.route拦截返回假数据await page.route(**/api/order/list, route { route.fulfill({ status: 200, contentType: application/json, body: JSON.stringify({ code: 0, data: [{ id: 1, name: 测试订单 }] }), }); });我见过很多团队为了测试某个页面直接在代码里改 mock 配置等联调完再删非常容易漏。用route在测试用例里动态拦请求干净又可控还能模拟 500、超时等异常场景。5.3 文件上传与下载上传组件如果是原生input typefile用setInputFiles直接给文件路径await page.locator(input[typefile]).setInputFiles({ name: report.pdf, mimeType: application/pdf, buffer: Buffer.from(测试文件内容), });如果要测下载const [download] await Promise.all([ page.waitForEvent(download), page.getByRole(button, { name: 导出 }).click(), ]); await download.saveAs(./downloads/report.pdf);5.4 截图、录屏与 Trace 记录测试失败后能自动留下证据非常重要。配置里开启use: { screenshot: only-on-failure, video: retain-on-failure, trace: on-first-retry, }这样失败用例会自动截图和录像重试时还会记录 trace。手动截图用await page.screenshot({ path: screenshots/home.png, fullPage: true });fullPage 参数会截取整个滚动页面非常实用。6. 运行调试与报告命令行、UI 模式与 Trace Viewer6.1 命令行运行技巧开发阶段最常用的几个命令# 只跑指定文件 npx playwright test tests/login.spec.ts # 带浏览器窗口运行 npx playwright test --headed # 按标题过滤 npx playwright test --grep 登录 # 指定项目 npx playwright test --projectchromium # 调试模式 npx playwright test --debug多文件并行跑的时候默认按 CPU 核数分 worker。如果你发现某些测试互相干扰可以用test.describe.configure({ mode: serial })或fullyParallel: false控制执行模式。6.2 UI Mode可视化调试利器新版本有个npx playwright test --ui命令打开一个可视化面板。左边是测试用例列表右边可以实时看页面快照、定位器信息、相关 mock 和 trace。最方便的是“Pick locator”功能鼠标在页面里点一下就能复制对应的定位器建议省去了反复试定位器的过程。6.3 Trace Viewer 排查失败用例当用例失败后报告目录里会生成 trace 文件。运行npx playwright show-trace test-results/xxx/trace.zipTrace Viewer 会展示整个用例执行的时间线、每一步的 DOM 快照、网络请求、控制台输出。排查“点击没生效”“元素没找到”这类问题效率比看日志高一个量级。6.4 与 CI 集成和报告输出官方提供了 HTML 报告npx playwright test --reporterhtml报告生成在playwright-report目录里面有所有用例、失败详情、慢请求、trace 入口。CI 上建议把这两个目录作为构建产物保留playwright-report/test-results/这样测试挂了可以直接下载报告排查不用重新跑。7. 常见问题排查与避坑清单7.1 元素定位不到或超时最常见的问题是 locator 定位器写得太脆。排查顺序建议用 Codegen 里的 Pick locator 重新确认定位器检查元素是否在 iframe 或 Shadow DOM 里检查元素是否在当前打开的新标签页里检查静态文本还是动态渲染如果是 SPA 异步渲染改用 web-first 断言等待打开 Trace Viewer 看失败时页面实际长什么样7.2 误用 waitForTimeout很多从 Selenium 转过来的人习惯了Thread.sleep在 Playwright 里继续写page.waitForTimeout(5000)。这种等待非常脆弱性能好时白白等性能差时等待不够。我遇到过不少案例本地跑得稳一到 CI 就挂最后定位到是固定等待造成的。正确姿势是await expect(page.getByText(加载完成)).toBeVisible();7.3 Python 同步异步 API 混用的报错Python 版 Playwright 有sync_playwright和async_playwright两套 API。如果你用sync_playwright却写成了await page.click()会直接报错类似it looks like you are using playwright sync api inside the asyncio loop要么全同步、要么全异步不要混用。我建议初学用同步 API代码简单直接做并发量大的爬虫再切异步。7.4 测试隔离fixture 的妙用测试用例之间最好完全隔离。Playwright 的test回调里提供了page和contextfixture每个用例自动创建独立的浏览器上下文也就是独立的 cookie 和 localStorage。但如果你自己编写了全局钩子或者手动创建了 context就要小心串数据。更好的做法是把公共逻辑抽成 fixtureimport { test as base, expect } from playwright/test; export const test base.extend({ signedInPage: async ({ page }, use) { await page.goto(/login); await page.getByLabel(用户名).fill(admin); await page.getByLabel(密码).fill(123456); await page.getByRole(button, { name: 登录 }).click(); await use(page); }, });测试里直接test(订单页显示订单, async ({ signedInPage }) { await signedInPage.getByRole(heading, { name: 订单列表 }).toBeVisible(); });7.5 headless 模式下表现差异headless: true时浏览器没有窗口某些依赖系统字体渲染的页面、动画效果、视频播放可能有差异。遇到这种场景先在本地--headed跑一遍确认不是环境差异。还可以在 headless 模式下打开调试--debug它会临时弹出窗口方便查看。8. 从 Playwright 到 AI 测试生态一点扩展尝试最后聊个题外话。最近社区里很火的 Playwright MCP、Claude 自动化测试框架本质上就是通过 MCP 协议把 Playwright 的能力暴露给大模型让 AI 辅助编写测试、生成定位器甚至直接用自然语言描述测试意图来驱动浏览器操作。我自己试过让 AI 根据页面快照生成初始用例速度确实快但离“能上生产”还有距离。AI 生成的定位器往往不语义化断言也不够严谨需要人工整理一遍。不过用来做探索性测试倒是很合适让 AI 根据需求描述点遍页面流程发现意料之外的行为。另外 Playwright 也常和 Scrapy 这类爬虫框架搭配专门处理动态 iframe、需要点击或滚动才能加载的内容。过程中核心还是“页面交互 稳定等待”和写测试用例的底层逻辑是一回事。回到文档第一章“编写测试”本身我的实际体会是Playwright 的语法不难难的是建立“面向结果等待、面向语义定位”的测试习惯。如果你刚开始折腾自动化测试别急着写几百行页面对象先把手上的用例用这套思维重写一遍等稳定了再抽公共层。踩过几次“定位器过于依赖布局”的坑之后你自然会理解官方文档为什么把“Write tests”作为一个独立章节从头讲起。