玩转 Playwright:从 10 行脚本到一套不会半夜变红的端到端测试

我之前写过一篇 Playwright 自动化测试框架详解:从概念到实践,那篇回答的是”它是什么、支持哪些浏览器”,属于概念梳理。这篇不一样:**它回答”怎么玩”**——从一条十行的脚本,到一套能在 CI 上长期跑、失败了还能说清楚原因自己的测试。

版本基准:本文按 Playwright 1.64(2026-10 的最新线)撰写。文中涉及具体版本的能力都会标出来,方便你对照自己的版本。

一、是什么:一个”用代码开真实浏览器”的运行时

很多人以为 Playwright 就是”另一个 Selenium”,其实它的定位更宽:它是浏览器自动化运行时,测试只是它最重要的一种用法。

这一点从包结构就能看出来,它其实是两条线:

线 包 什么时候用
库 playwright 一次性脚本:抓数据、批量截图、给内部系统做自动化、当”网页版 cron”
测试运行器 @playwright/test 要长期跑的回归:用例发现、并行、重试、断言、报告、trace 全给你管好了

@playwright/test 是跑在库上面的那层壳。所以先学库的 API,再用测试运行器,路径会顺得多;反过来只会背脚手架,遇到点非常规需求就没底。

对象模型:四层,记住就够用

1
2
3
4
Browser(一个浏览器进程)
└─ BrowserContext(一个独立的"隐身窗口":cookie、存储、权限都隔离)
└─ Page(一个标签页)
└─ Locator(一个"延迟求值"的元素引用,还没找到东西)

Context 是 Playwright 最关键的设计。 在 Selenium 里,想要两份干净的会话,你得再造一个 driver、重启一个 profile,代价是秒级起步;Playwright 里 browser.newContext() 是毫秒级的,于是”每条用例一个全新浏览器状态”这种奢侈的隔离,变成了默认行为,而不是需要心疼资源的选项。并行、登录态复用、多用户交互测试,全都建立在这一点上。

Locator 是第二关键的设计。 它不是元素,是”如何找到元素的说明”。page.getByRole('button', { name: '保存' }) 返回的东西此刻在页面上根本不存在,直到你调用 .click() 它才去找——而且会一直等到能找到为止。这就是自动等待的来源。

和 Selenium / Puppeteer / Cypress 的差别在哪

不扯”快百分之多少”这种没出处的数字,机制上的差异才有决策价值:

  • 对 Selenium:Playwright 不走 WebDriver 那条”HTTP 命令一问一答”的通道,而是用浏览器自身的调试协议长连接驱动,事件能主动推回来。带来的实际差别是自动等待、网络层拦截、以及多上下文并行——这三件事在 Selenium 里都要你自己造轮子。
  • 对 Puppeteer:API 手感很像(Puppeteer 的不少设计后来被两边互相借鉴),但 Playwright 跨引擎(Chromium / Firefox / WebKit 都是官方维护),Puppeteer 基本绑 Chromium 生态。只做 Chrome 相关的事,Puppeteer 够用;要跨浏览器,Playwright。
  • 对 Cypress:Cypress 跑在浏览器 inside,天然拿不到浏览器进程外的能力,所以多标签、多域、真实系统浏览器切换这些是它的硬边界;Playwright 在浏览器外面驱动,这些都不是问题。反过来 Cypress 的调试体验对新手更友好一些。

二、为什么:现在学它,值在哪

1. 它把 flaky 当成设计目标来消灭

端到端测试最大的成本不是写,是它今天红了你不知道是真 bug 还是运气差。Playwright 的三件套正面解决这个:

  • 操作前自动等待元素可见、稳定、可点、可输入;
  • 断言自带轮询重试(toBeVisible() 不是”看一眼就下结论”,而是”在超时内等到它成立”);
  • 用例失败可自动重试(retries),重试时保留 trace。

2. 隔离即并行,不用改代码

每条用例一个 Context,Context 之间互不可见,于是 fullyParallel: true 打开就能吃满 CI 机器的核。这在 Selenium 体系里通常意味着重构整个测试基类。

3. 失败之后有”飞行记录仪”

trace 里有一次运行的每一步动作、动作前后的 DOM 快照、console 输出、网络请求。1.60 之后 HAR 网络捕获被折叠进了 tracing,一份 trace 文件同时是行为记录和流量记录;同期加了结构化的 errorContext,失败信息不再只是一坨给人看的字符串——这也为后面接 AI 排查留了口子。

4. 它的生态位正在变大

这条是”为什么是现在”的答案。把近一年的发布轨迹连起来看:

版本 时间 关键动作
1.56 2026 上半年 引入 Test Agents:planner / generator / healer
1.59 2026 browser.bind(),多个消费方可以挂到同一个浏览器实例上
1.60 2026-05 Test Agents 转生产可用;ARIA 快照支持携带元素坐标(bounding boxes);HAR 进 trace;errorContext
1.62 2026 组件测试换成 stories / galleries 模型;老的 React、Vue 组件测试包停止更新
1.63 2026-09 测试锁(协调争抢共享资源的用例)、locator.visible()、跨 frame 免选择器定位
1.64 2026-10 WebMCP 支持、Locator.within()、--shuffle

它在从”测试工程师写代码的框架”变成”AI Agent 操作浏览器的那一层运行时”:Agent 不靠截图猜坐标,而是读 ARIA 语义树(省一个数量级的 token,且定位是确定性的);MCP 服务器和 CLI + Skills 两条通道都在官方维护。哪怕你不做测试,把它当浏览器自动化底座用,也是押在一条正在变宽的路上。

三、怎么样:十分钟从零跑通

3.0 装

1
2
3
4
5
6
7
8
9
10
11
# 只要库(写脚本用)
npm i -D playwright

# 要做测试(推荐,这个包已经把断言、报告、并行都配好了)
npm i -D @playwright/test

# 下载浏览器引擎(第一次必须跑)
npx playwright install

# 只装某一个 + Linux CI 上顺带装系统依赖
npx playwright install chromium --with-deps

Windows 上直接跑就行;公司网有代理的话,下载浏览器会失败,设 HTTPS_PROXY,或者用 PLAYWRIGHT_BROWSERS_PATH 把浏览器缓存指到可写目录。Linux CI 上别忘了 --with-deps,否则缺字体和图形库会以非常难懂的报错表现给你。

不想下载它自带的浏览器也可以——直接用你机器上已经装好的 Chrome:

1
2
3
const { chromium } = require('playwright')

const browser = await chromium.launch({ channel: 'chrome' })

channel: 'chrome' 或 'msedge' 走系统浏览器,省 100 多兆下载,也能验到”真实用户机器上的那个浏览器”。

3.1 线一:一条脚本把事办了

下面这个例子不依赖任何外网页面,自己塞一段 HTML 进去就能跑,适合当第一个可复现的最小样本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// demo.js  ——  node demo.js
const { chromium } = require('playwright')

const html = `
<h1>家务记分</h1>
<input aria-label="事项名称" placeholder="洗碗" />
<input aria-label="分值" type="number" value="5" />
<button>添加</button>
<ul></ul>
<script>
const btn = document.querySelector('button')
btn.addEventListener('click', () => {
const name = document.querySelector('[aria-label=事项名称]').value
const li = document.createElement('li')
li.textContent = name + ' · 已记录'
document.querySelector('ul').appendChild(li)
})
</script>
`

;(async () => {
const browser = await chromium.launch()
const page = await browser.newPage()
await page.setContent(html)

await page.getByLabel('事项名称').fill('拖地')
await page.getByRole('button', { name: '添加' }).click()

// 不用 sleep:断言自己会等 li 出现
await page.getByText('拖地 · 已记录').waitFor()
console.log('当前条目数:', await page.locator('li').count())

await page.screenshot({ path: 'demo.png', fullPage: true })
await browser.close()
})()

三个手感要点:

  1. 不写 sleep,不写 waitForTimeout。 需要等的时候,等的是”条件成立”,不是”过了三秒”。
  2. 定位优先用语义(getByRole / getByLabel / getByText),CSS 选择器是兜底而不是默认。
  3. browser.close() 一定要在 finally 里,否则脚本崩一次就留一个僵尸浏览器进程。生产脚本请写成 try { … } finally { await browser.close() }。

顺手抓数据、批量出图,也还是这一套:

1
2
3
4
5
6
const rows = await page.$$eval('table.list tr', (trs) =>
trs.map((tr) => Array.from(tr.children).map((td) => td.textContent.trim()))
)

// 只有 Chromium 无头模式能出 PDF,做"网页转 PDF 存档"很好用
await page.pdf({ path: 'page.pdf', format: 'A4' })

3.2 让 codegen 替你写定位器

不知道某个元素怎么定位的时候,别去猜 class:

1
npx playwright codegen https://example.com

它会开一个浏览器 + 一个 Inspector,你点点看,代码实时生成。想直接生成测试运行器风格的代码:

1
npx playwright codegen --target=playwright-test https://example.com

codegen 是”起手式”,不是”工程实践”——它生成的选择器往往很脆(一改版就挂),录完记得手工换成语义定位。

3.3 线二:第一条真正的测试

1
npm init playwright@latest   # 交互式:选语言、选测试目录、是否建 GitHub Actions

它会生成一份 playwright.config.js 和 tests/ 目录。核心配置长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// playwright.config.js
const { defineConfig, devices } = require('@playwright/test')

module.exports = defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? '50%' : undefined,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'mobile', use: { ...devices['iPhone 13'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
})

写用例:

1
2
3
4
5
6
7
8
9
10
11
// tests/add-item.spec.js
const { test, expect } = require('@playwright/test')

test('添加一条家务会出现在列表里', async ({ page }) => {
await page.goto('/')
await page.getByLabel('事项名称').fill('铲猫砂')
await page.getByRole('button', { name: '添加' }).click()

await expect(page.getByRole('listitem')).toHaveCount(1)
await expect(page.getByText('铲猫砂 · 已记录')).toBeVisible()
})

跑:

1
2
3
4
5
6
7
npx playwright test                  # 全跑
npx playwright test --ui # 交互式界面,边改边重跑,最推荐
npx playwright test --debug # 单步,配 Inspector
npx playwright test -g "铲猫砂" # 按标题筛
npx playwright test --project=mobile # 只跑某个 project
npx playwright test --last-failed # 只重跑上次失败的
npx playwright show-report # 打开 HTML 报告

--ui 是我认为它相对所有框架最”回不去”的功能:测试树、实时运行、时间旅行式的每一步快照,改完自动重跑。写测试的体感差距是数量级的。

四、核心特性:真正会用到这八个

1. 定位器:语义优先,链式收窄

1
2
3
4
5
6
page.getByRole('button', { name: /保存|提交/ })   // 正则匹配可访问名
page.getByLabel('手机号')
page.getByPlaceholder('搜索…')
page.getByRole('list').getByRole('listitem', { name: '拖地' }) // 链式收窄作用域
page.locator('.row').filter({ hasText: '逾期' })
page.getByRole('button', { name: '删除' }).first()

Playwright 默认是 strict mode:一个定位器匹配到多个元素就报错,不会”帮你选第一个”。很多人第一次被这个坑到,但它是好事——它把”我以为只点到了这一个”的隐患在执行时就拦住了。

1.63 起的跨 frame 定位也简化了:page.frameLocator() 不传选择器时,会直接遍历所有子 frame 去找元素(多个 frame 里都有同名元素时会明确报歧义,不会随便挑一个)。

2. 自动等待 + web-first 断言

click() 会等元素可见、稳定、启用、可接收事件。断言写 locator,不要先取值再断言:

1
2
3
4
5
// ❌ 只检查一次,页面还没渲染完就挂了
expect(await page.textContent('.status')).toBe('完成')

// ✅ 断言自己重试到超时为止
await expect(page.locator('.status')).toHaveText('完成')

断言不满足又不想整个用例失败时,用 expect.soft;要等一段非 DOM 的状态,用 expect.poll 或 toPass:

1
2
3
4
5
6
7
8
await expect
.poll(async () => rowsInDb(), { message: '等后台任务落库', timeout: 15000 })
.toBeGreaterThan(0)

await expect(async () => {
const res = await request.get('/api/health')
expect(await res.json()).toMatchObject({ ok: true })
}).toPass({ timeout: 20000 })

3. 网络层:拦截、伪造、断网

这是 Playwright 相对同类最锋利的一层,page.route() 能改请求也能造响应:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 前端还没写完,先喂一份假数据把 UI 跑起来
await page.route('**/api/points', (route) =>
route.fulfill({ json: { balance: 120, items: [] } })
)

// 模拟接口挂了 / 用户断网
await page.route('**/api/**', (route) => route.abort('failed'))

// 等某个接口回来再断言,比 sleep 稳得多
const [resp] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/login') && r.status() === 200),
page.getByRole('button', { name: '登录' }).click(),
])

顺带一个省钱技巧:测试里把图片、字体、埋点全拦掉,跑分快一截。

1
await page.route(/.(png|jpe?g|webp|woff2?|gif|svg)/, (r) => r.abort())

4. 登录态复用:storageState

端到端测试最烦的是每条用例都要重登一遍(还可能踩验证码)。Playwright 的解法是把 cookie + localStorage 存成一个 JSON,之后所有用例直接带着它起跑:

1
2
3
4
5
6
7
8
9
// 1) 人工或脚本登录一次,存下来
const context = await browser.newContext()
const page = await context.newPage()
await page.goto('https://example.com/login')
await page.getByLabel('用户名').fill('me')
await page.getByLabel('密码').fill('secret')
await page.getByRole('button', { name: '登录' }).click()
await page.waitForURL('**/home')
await context.storageState({ path: 'auth.json' })
1
2
// 2) 之后所有用例免登录
await browser.newContext({ storageState: 'auth.json' })

测试运行器里更简单,config 的 use.storageState 全局设一下,或者用 globalSetup 生成它(见进阶第 2 条)。命令行也支持:npx playwright codegen --load-storage=auth.json https://example.com。

5. Context 级模拟:设备、权限、地理位置、语言时区

1
2
3
4
5
6
7
8
9
const { devices } = require('@playwright/test')

const context = await browser.newContext({
...devices['iPhone 13'],
geolocation: { longitude: 121.47, latitude: 31.23 },
permissions: ['geolocation'],
locale: 'zh-CN',
timezoneId: 'Asia/Shanghai',
})

注意这些是 Context 级的,不是浏览器级——这正是”同一份代码,同时验桌面 Chrome 和手机 Safari 行为”能并行的原因。

6. 新标签、iframe、弹窗、文件上传下载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 新标签页
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.getByRole('link', { name: '打开报告' }).click(),
])
await popup.waitForLoadState()

// iframe:frameLocator 返回的仍是可链式定位的东西
await page.frameLocator('#pay-form').getByLabel('卡号').fill('4242')

// 原生弹窗(alert/confirm)必须提前挂监听,否则会被默认行为处理掉
page.on('dialog', (dialog) => dialog.accept())

// 上传
await page.getByLabel('附件').setInputFiles('report.csv')

// 下载
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: '导出 JSON' }).click(),
])
await download.saveAs('out.json')

7. 多浏览器 + 多环境矩阵

projects 是它的矩阵机制:一个 project = 一组 use 配置。浏览器 × 视口 × 环境(本地/预发)都能这么表达,跑的时候 --project 精确选,CI 里拆成不同 job。

8. 调试三件套:UI Mode / Trace / Inspector

1
2
npx playwright test --ui
npx playwright show-trace trace.zip

trace 打开后是”每一步 → 当时的 DOM 快照 → 当时的网络请求”的时间轴,可以逐步回放。CI 里请把 trace 归档,配置里 trace: 'on-first-retry' 是性价比最高的档位(每条都存太占地方,只在重试时存刚好覆盖 flaky 场景)。

五、进阶玩法

1. 把”登录”和”造数据”沉到 fixture 里

用例只该讲业务,不该讲准备动作。fixture 就是干这个的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// fixtures.js
const base = require('@playwright/test')

exports.test = base.test.extend({
// 每条用例拿到一个已登录的 page
loggedInPage: async ({ page, storageState }, use) => {
await page.goto('/login?token=' + process.env.E2E_TOKEN)
await use(page)
},
// 用例跑完自动清理
chore: async ({ loggedInPage }, use) => {
const name = '洗碗-' + Date.now()
await use(name)
await loggedInPage.evaluate((n) => window.__cleanup?.(n), name)
},
})

exports.expect = base.expect
1
2
3
4
5
6
7
8
9
// tests/x.spec.js ——  用 test 而不是内置 test,就自动获得上面的能力
const { test, expect } = require('./fixtures')

test('新事项出现在列表', async ({ loggedInPage, chore }) => {
await loggedInPage.goto('/')
await loggedInPage.getByLabel('事项名称').fill(chore)
await loggedInPage.getByRole('button', { name: '添加' }).click()
await expect(loggedInPage.getByText(chore)).toBeVisible()
})

再往前一步是 Page Object:把一页的定位器和动作封成一个类,用例读起来像验收标准。小项目不必强上,用例超过几十条以后收益才压过抽象成本。

2. globalSetup 一次登录,webServer 自动起应用

globalSetup 在整个套件开始前跑一次,天生适合”登录 + 存 storageState + 准备测试账号”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// global-setup.js
const { chromium } = require('@playwright/test')

module.exports = async () => {
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(process.env.BASE_URL + '/login')
await page.getByLabel('用户名').fill(process.env.E2E_USER)
await page.getByLabel('密码').fill(process.env.E2E_PASS)
await page.getByRole('button', { name: '登录' }).click()
await page.waitForURL('**/earn')
await page.context().storageState({ path: 'e2e/.auth/user.json' })
await browser.close()
}

配合 config 里的 webServer(上面 3.3 有例子),npx playwright test 一条命令会自己把开发服务器拉起来、等端口就绪、跑完再收——本地和 CI 用同一份配置,不用再写两套启动脚本。

3. 并行调优,以及 1.63 的测试锁

并行是端到端最容易翻车的地方,因为共享资源:多个 worker 同时改同一个账号的设置、同时操作同一个外部服务,单跑全过、并行必炸。

常规手段是数据隔离(每条用例自己造数据、自己清理,名字带时间戳),实在隔离不了的才排队:

  • workers / fullyParallel 控总量;
  • test.describe.configure({ mode: 'serial' }) 让一组用例串行;
  • --shard=2/4 把套件切片分到多台 CI 机器;
  • 1.64 提供 --shuffle 打乱顺序,专门用来暴露”依赖执行顺序”的假稳定用例;
  • 1.63 的测试锁:给争抢同一资源的用例声明同名 lock,同名锁的用例跨文件、跨 worker、跨 project 都会串行排队,而不同名或没加锁的用例照常并行。这比”为了安全把整套件改成单 worker”精细得多。

两个提醒:锁名要在全项目内统一规范,只有标识完全相同的才会互相排队;锁不能替代数据隔离,也不会帮你清理外部服务的残留状态。升级时先给”共享账号 / 全局配置 / 单实例独占”这类用例加锁,观察时长和失败率变化,别一上来给全量套同一个锁。

4. 视觉回归:toHaveScreenshot

1
2
3
4
5
6
7
test('首页视觉不跑偏', async ({ page }) => {
await page.goto('/')
await expect(page.locator('.hero')).toHaveScreenshot('hero.png', {
maxDiffPixelRatio: 0.01,
animations: 'disabled', // 关键:不关动画,diff 永远不过
})
})

基线用 --update-snapshots 生成。视觉回归的坑基本都不在 Playwright,在环境:字体渲染、时间戳、随机内容、动画、DPR。所以要么在 CI 里跑(环境统一),要么把这些内容用网络拦截固定掉。跨浏览器做视觉对比要谨慎——不同引擎的默认字体本来就不一样。

5. 往报告里塞自定义证据

1
2
3
4
5
test.info().annotations.push({ type: 'issue', description: 'HOME-42' })
await test.info().attach('接口返回', {
body: JSON.stringify(payload, null, 2),
contentType: 'application/json',
})

排查的人会很感谢你——比在报告里贴一屏 console 输出有用得多。

6. 接口测试和 UI 混着写

request fixture 可以不启浏览器直接打接口。正确用法是:用接口造前置数据、验后端结果,用 UI 只验用户能看见的那一段。

1
2
3
4
5
6
7
test('删除事件后统计跟着变小', async ({ request, page }) => {
const created = await request.post('/api/data', { data: { events: [{ name: 'x', points: 1 }] } })
expect(created.ok()).toBeTruthy()

await page.goto('/')
await expect(page.getByText('+1')).toBeVisible()
})

全是 UI 操作的套件又慢又脆,全是接口的套件又验不到用户真正看到的东西。

7. 连进一个已经打开的真实浏览器

有些场景你不想让 Playwright 自己起浏览器:想复用已经登录好的日常浏览器、想绕开每次都要过的验证码、想看它操作的全过程。

1
2
# 先用调试端口启动一个 Chrome(Windows)
chrome.exe --remote-debugging-port=9222 --user-data-dir=C:/tmp/pw-profile
1
2
3
4
5
6
7
8
const { chromium } = require('playwright')

const browser = await chromium.connectOverCDP('http://127.0.0.1:9222')
const context = browser.contexts()[0]
const page = context.pages()[0] ?? (await context.newPage())

await page.goto('https://example.com')
// 注意:用 connectOverCDP 时不要 browser.close(),用 browser.close() 会断开连接而不是杀进程

connect() / connectOverCDP() 是长期稳定的能力;1.59 的 browser.bind() 则更进一步,让多个消费方(Agent、runner、MCP)挂到同一个浏览器实例上,状态不再分裂。做 Agent 相关的事时值得留意这条线。

8. 把它当”网页版 cron”用

不一定非要测试。我自己的两个用法:

  • 给自家应用做上线冒烟:部署完自动跑三条用例(能登录、记一笔能加分、兑换页能打开),比人肉点一遍可靠。
  • 定时抓页面出图存档:headless 打开报表页,等关键元素出现,page.screenshot() 或 page.pdf() 存下来,配合 storageState 免登录,一台机器就能跑。

这类脚本记得三件事:browser.close() 放 finally、给整个脚本套总超时、失败时把截图一起落到日志目录。

9. 和 AI 协作:MCP 与 Test Agents

这是 Playwright 现在最有意思的一块。

Playwright MCP 把浏览器能力暴露成 Agent 可调用的工具。它走的是 ARIA 快照路线而不是截图:不跑视觉模型,直接把可访问性树转成结构化文本,Agent 读到的是”这是个 textbox,名字叫用户名,ref 是 e5”——比截图省一个数量级的 token,而且定位是确定性的。1.60 起快照还能带上元素坐标(boxes 选项),布局推理、遮挡判断这类以前必须靠视觉模型的判断,现在从语义树里直接能拿到。

1
npx @playwright/mcp@latest

Test Agents(1.56 引入,1.60 转生产可用)预置了三个角色,构成一个闭环:

  • planner 探索运行中的应用,产出一份人类可读的 Markdown 测试计划;
  • generator 读计划生成 spec,生成过程中实时验证选择器和断言;
  • healer 跑失败的用例,检查运行时 UI,提出修复(通常是更新定位器或调整等待条件)。
1
npx playwright init-agents --loop=vscode

(这是官方发布说明里给的接入方式,具体参数请以你版本的 changelog 为准。)

但有一条边界,Playwright 自己划得很清楚,我认为这也是它比多数 Agent 工具清醒的地方:healer 能修定位器、修等待条件,但不碰断言。 因为它无法可靠区分”应用改版了”和”应用有 bug 了”——前者该修测试,后者该保持红色。所以实践上:heal 必须以提案形式出现(一个 PR 里的 diff,人 review),绝不自动合入。 允许 heal 直接进 main 的团队,最后都会发现整套测试”礼貌地绕过了一个真 bug”。同理,planner 能提覆盖方案,但”这个断言对不对”仍然是产品判断。

六、踩坑清单

  1. waitForTimeout() 是技术债的写法,等条件,别等时间。
  2. expect(await …) 只断言一次,写 expect(locator) 才有重试。
  3. **strict mode 报错别急着加 .first()**,先想是不是选择器写得太宽——.first() 经常是在掩盖一个真 bug。
  4. **page.route() 记得 unroute()**,或者用 { times: 1 } 限制次数,否则拦截会渗到后续用例。
  5. page.pdf() 只在 Chromium 无头可用,headed 或 Firefox/WebKit 会直接报错。
  6. headed 与 headless 行为有差异(字体、UA、扩展、视口),视觉基线要在和 CI 一致的模式下生成。
  7. Linux CI 缺系统依赖的报错很难懂,装浏览器时用 --with-deps。
  8. 企业代理下浏览器下载失败:设 HTTPS_PROXY,或 PLAYWRIGHT_BROWSERS_PATH 指到可写目录。
  9. 别给整套件加同一个锁或改成单 worker,那是把并行收益全丢了;只锁真正争抢共享资源的用例。
  10. 升级前看 changelog:1.62 之后老的 React/Vue 组件测试包停止更新(官方引导迁到 stories 模型),Ubuntu 20.04 也已移出官方支持范围。
  11. 反爬场景别用默认 headless 特征,真实 UA、语言、时区一起设,并且先确认对方站点允许你这么做、控制频率。
  12. trace 不归档等于没跑:CI 上 trace: 'on-first-retry' 起步,Agent 驱动的自动化更是必须存 trace,否则不可审计。

总结

一句话选型:一次性的事用 playwright 库,要长期跑的事用 @playwright/test。

再往深一层,Playwright 真正的价值不是”API 比谁多”,而是它把三件麻烦事做成了默认行为——自动等待让 flaky 从”要靠经验躲”变成”框架替你兜”,Context 隔离让并行从”要重构”变成”打开开关”,trace 让失败从”贴一屏日志”变成”回放那一步”。这三件事一旦成为默认,端到端自动化才第一次具备”能长期养着”的性质。

而它现在正在做的第四件事,是把同一套能力交给 Agent:结构化的感知(ARIA 快照)、一致的执行环境(bind)、可审计的记录(trace + HAR)、预置的工作流(planner / generator / healer)。执行层的所有权在转移,判断层的归属没有——测什么、什么算对、能不能发,仍然是人的事。

好的端到端测试不是”能跑通”,而是”红的时候你能立刻知道为什么”。

想系统了解它的能力边界和多浏览器支持,可以先看前面那篇 Playwright 自动化测试框架详解;这篇里的代码按 1.64 的 API 写,示例自带一段最小 HTML,复制即可跑。


注:本文基于 Playwright 1.64(2026-10)的公开 API 与官方 changelog 撰写;文中出现的具体版本号来自官方发布记录,升级后请以对应版本的 Release Notes 为准。