Playwright 做 E2E:关键路径比覆盖率更重要

写 E2E 之前,先得回答一个更靠前的问题:这一条用例到底要不要用 E2E 来测。

同一个断言,放进单元测试可能一毫秒跑完,放进 E2E 就要拉起浏览器、走完整个页面、等接口回来。如果什么都想用 E2E 覆盖,测试很快会变得又慢又脆,CI 一天红几次,最后大家干脆把它标成 flaky 忽略掉。反过来,一条 E2E 都不写,所有回归全靠人工点,上线前的巡检就成了运气。

这两种极端我都见过。Playwright 这半年在我们中后台项目里跑得很稳,但它再顺手,也不能替我决定「测什么」。这篇想先把这个取舍讲清楚:哪些用例值得进 E2E,哪些该退回组件测试或单元测试,剩下的选择器、等待、登录态才是为这个取舍服务的手段。

什么进 E2E,什么不进

判断标准我用一句话概括:这条流程断了,是不是会直接影响用户完成核心任务,而且只有把整条链路跑通才能发现。符合这两点的,才值得进 E2E。

在我们的后台里,进 E2E 的大概就这几类:

  • 登录能成功,登录态能带进后续页面
  • 核心列表能搜索、能筛选、能进详情
  • 关键表单能提交并落库
  • 权限不足的账号会被拦在门外
  • 支付、审批、发布这类一旦出错就是线上事故的高风险流程

不进 E2E 的更多。按钮什么颜色、某个工具函数边界、某个弹窗在极端输入下的表现,这些用组件测试或单元测试更快也更准。举个具体例子,一个金额格式化函数,要覆盖负数、超大数、小数精度十几种情况,用 E2E 每种都点一遍页面纯属浪费,单元测试里一个 describe 就写完了。

E2E 越少越要准。我更愿意把它当成上线前的自动巡检:保证主干没断,而不是替代所有测试层级。测试金字塔那套说法在 2023 年不算新,但真正落到项目里,最容易失衡的就是 E2E 这一层——它写起来最有「安全感」,也最容易堆到失控。

Playwright 这一年还提供了组件测试模式(@playwright/experimental-ct-react 之类,还是实验性的),能在真实浏览器里单独挂载一个组件测交互,介于单元测试和全链路 E2E 之间。它给了「测什么」这个取舍一个新的中间档:一个复杂表单组件的校验逻辑,不必拉起整个应用走 E2E,也不必在 jsdom 里用近似环境跑单元测试,直接在浏览器里挂载这一个组件测。因为还带实验标记,我目前只在个别复杂交互上试,没全面铺开,但它确实让「关键路径走 E2E、组件行为走组件测试」的分层更好落地了。

选择器要像用户一样找元素

选择器是 E2E 稳定性的第一道分水岭。Playwright 推荐用 role、label、text 这类语义选择器:

1await page.getByRole('button', { name: '提交' }).click()
2await page.getByLabel('用户名').fill('admin')

这比 .btn-primary:nth-child(2) 稳得多。类名和 DOM 层级会因为一次样式重构就变,而角色和可见文案更贴近用户实际看到的东西。一次 CSS Modules 的类名 hash 变化,就能让一大片基于 class 的选择器全部失效,这种坑我不想再踩第二次。

如果文案要国际化,或者页面上同名按钮太多,可以补 data-testid

1<button data-testid="submit-order">提交</button>

但别滥用。优先语义选择器,测试顺带还能推着页面的可访问性变好——一个连 getByRole('button', { name }) 都定位不到的按钮,多半是 aria 或语义标签本身有问题。

Playwright 这一版的 getByRole 系列 API 相比早期的 page.locator('text=...') 更成熟,还内置了自动等待:定位到的元素只要还没可交互,它会自己重试到超时,而不是立刻失败。这一点省掉了大量手写的等待逻辑。

选择器还有个稳定性技巧是善用 filter 和链式定位,而不是写死层级。比如列表里要点「订单 A」那一行的删除按钮:

1await page
2  .getByRole('row')
3  .filter({ hasText: '订单 A' })
4  .getByRole('button', { name: '删除' })
5  .click()

这样即使表格列顺序调整、外面又套了一层容器,定位照样成立,因为它描述的是「含订单 A 文案的那一行里的删除按钮」这个语义关系,而不是「第几个 tr 里的第几个 button」。E2E 脆不脆,很大程度上就取决于选择器描述的是语义还是结构。

不要用 waitForTimeout 硬等

E2E 最常见的不稳定来源,是拿时间当等待条件:

1await page.waitForTimeout(3000)

本地网络快,三秒够了;CI 机器慢一点,接口还没回来就断言,失败;CI 快一点,又白等两秒。硬等本质上是在赌时间,赌输了就是 flaky。

更好的方式是等一个具体状态:

1await expect(page.getByText('提交成功')).toBeVisible()

要等接口结果,可以直接等响应:

1const responsePromise = page.waitForResponse('/api/orders')
2await page.getByRole('button', { name: '查询' }).click()
3await responsePromise

等待要和业务结果绑定,而不是和墙上时钟绑定。Playwright 的 expect 断言本身也带轮询重试,toBeVisibletoHaveText 这些会在超时窗口内反复检查,所以大多数情况根本不需要自己写 sleep。真要等一个没有明显 UI 反馈的中间态,也应该用 waitForFunction 去轮询某个条件,而不是拍一个固定秒数。

登录态复用,别每条用例都重新登

每条用例都从登录页点起,既慢又脆:验证码、风控、第三方登录任何一环抖一下,整批用例全挂,而它们要测的其实根本不是登录。

Playwright 的做法是先跑一次登录,把 storage state 存下来:

1await page.goto('/login')
2await page.getByLabel('用户名').fill('admin')
3await page.getByLabel('密码').fill('password')
4await page.getByRole('button', { name: '登录' }).click()
5await page.context().storageState({ path: 'auth.json' })

后续用例直接复用这份登录态:

1test.use({ storageState: 'auth.json' })

这样测试更快,也更聚焦业务路径。更进一步,可以把登录写成一个 globalSetup,整个测试进程开始前只登一次,把产物落到磁盘,所有 project 共享。如果系统有多种角色,就为管理员、普通用户、只读账号各存一份 state,用例按需 test.use 切换。

当然,登录本身要保留一条独立的 E2E——它是关键路径。只是没必要让另外几十条用例都替它重复验证一遍。

有个坑要提醒:storage state 里存的是 cookie 和 localStorage,如果登录态本身有有效期,跑得慢的时候可能中途过期。所以 globalSetup 里最好每次都重新登、重新生成 state,别把一份陈旧的 auth.json 提交进仓库反复用。另外如果系统靠 httpOnly cookie 维持会话,storageState 也能带上——它连 cookie 一起存,这点比自己手动塞 header 省心。

测试数据要可控

E2E 很怕依赖环境里的脏数据。

测「删除订单」,如果环境里刚好没有订单就直接失败;如果删掉的是别的用例正在用的数据,还会连累别人。这种偶发失败排查起来最费劲,因为它和代码无关,只和当时环境里有没有那条数据有关。

更稳的做法是每条用例自己准备数据:

1const order = await createTestOrder()
2await page.goto(`/orders/${order.id}`)

用例跑完清理,或者干脆用一个专门的测试租户隔离。没有稳定的数据前提,E2E 断言的其实是随机数。如果没法直接调后端造数据,至少要固定测试账号和固定样本,避免依赖那种「生产上自然长出来」的数据。

数据的准备和清理最好放进 fixture。Playwright 的 fixture 机制可以把「造一个订单、用完删掉」封成一个带自动清理的依赖,用例声明它就能拿到,不用每个 test 里手动 beforeEach/afterEach。这比全局的 setup/teardown 粒度更细,也更不容易漏清理:

1const test = base.extend<{ order: Order }>({
2  order: async ({}, use) => {
3    const order = await createTestOrder()
4    await use(order)          // 用例期间可用
5    await deleteTestOrder(order.id)  // use 之后自动清理
6  },
7})
8
9test('能进入订单详情', async ({ page, order }) => {
10  await page.goto(`/orders/${order.id}`)
11  await expect(page.getByRole('heading', { name: order.title })).toBeVisible()
12})

use 之前是准备、之后是清理,即便用例中途断言失败,use 之后的代码也照样执行,清理不会被跳过。这一点比 afterEach 里手写清理更可靠——手写的很容易因为前面 throw 就没跑到。

错误路径也要测一两条

很多 E2E 只测成功流程,结果接口一失败页面直接白屏,没人发现。可关键路径的错误分支,恰恰是用户真会撞上的。

Playwright 可以拦截接口、伪造失败响应:

1await page.route('/api/orders', async (route) => {
2  await route.fulfill({
3    status: 500,
4    body: JSON.stringify({ message: 'server error' }),
5  })
6})

然后断言页面有兜底:

1await expect(page.getByText('加载失败')).toBeVisible()
2await expect(page.getByRole('button', { name: '重试' })).toBeVisible()

接口失败必须有 UI 兜底,否则就是白屏。这里也要回到那个取舍:不是每个接口的每种错误码都值得写 E2E,而是挑关键路径上「失败了用户会懵」的那一两条,收益最高。page.route 还能用来 mock 掉那些不稳定的第三方依赖,让用例只聚焦自己要验证的那段逻辑。

时间也是常见的不稳定源。测「订单超过 30 天不能退款」这种带时间判断的逻辑,别真去等,用 page.clock 或注入固定时间把「现在」钉死,让断言可复现:

1await page.addInitScript(() => {
2  const fixed = new Date('2023-03-20T10:00:00Z').getTime()
3  Date.now = () => fixed
4})

依赖真实时间的用例,换一天跑就可能换个结果,和依赖脏数据是同一类病——断言的前提不可控。凡是「今天是几号」会影响结果的用例,都该把时间固定下来。

并行和分片,别让用例互相踩

E2E 慢是常态,Playwright 默认就并行跑多个 worker 来抵消。但并行有个前提:用例之间不能共享会互相污染的状态。前面强调「每条用例自备数据」,一半原因是为了断言稳定,另一半就是为了能安全并行——两个用例同时改同一条订单,并行下就是灾难。

CI 上如果用例多到单机跑不动,可以分片(sharding),把用例切成几份丢到多台机器并行:

1npx playwright test --shard=1/3
2npx playwright test --shard=2/3
3npx playwright test --shard=3/3

三台机器各跑三分之一,再把各自的报告合并。这是把「E2E 慢」这个老问题从「优化单条用例」转向「横向扩机器」的思路。前提还是那句话:用例彼此独立、数据自备,否则分片只会让偶发冲突更难复现。

跨浏览器也走 projects 配置,一份用例在 Chromium、Firefox、WebKit 上各跑一遍。但要克制——不是每条用例都值得三浏览器全测,关键路径覆盖 WebKit 用来兜 Safari 的差异就够了,全量三倍跑只会把 CI 时间拖垮,又回到开头那个「什么值得测」的取舍上。

CI 上失败要能定位

E2E 在 CI 上失败,最怕只留下一行红字。所以要让它带证据回来:截图、trace、video、console log、network log。

Playwright 的 trace 尤其有用,它能把每一步操作、每次网络请求、每个 DOM 快照都录下来,事后用 npx playwright show-trace 回放,像看录像一样定位问题。CI 上失败的用例,本地开发者往往复现不出来——机器慢、数据不同、时序差一点,这时一份完整的 trace 就是唯一的现场证据,能看到失败那一刻页面究竟长什么样、请求回了什么。没有它,跨环境的偶发失败基本无从查起。

配置上一般只在失败或重试时才留 trace,避免每次都生成拖慢 CI:

1// playwright.config.ts
2use: {
3  trace: 'on-first-retry',
4  screenshot: 'only-on-failure',
5  video: 'retain-on-failure',
6}

retries 也要配一个小值。E2E 偶发抖动难以完全消除,允许失败自动重试一次能显著降低噪音,但重试次数别开太大,否则会把真正稳定的 bug 也「重试」过去,掩盖问题。一个折中做法是只在 CI 上开 retries,本地设成 0——本地跑一次就红,反而更容易在开发时暴露出用例自身的不稳定,早点修掉。

Playwright 还带一个 UI 模式(--ui),本地调试时能可视化地看每一步的 DOM 快照、时间线、网络请求,写用例时定位为什么某个断言不过特别快。配合 codegen 录制器,还能边点页面边生成选择器初稿,虽然生成的代码得手动改成语义选择器,但起草阶段能省不少事。这些工具本身不决定测试质量,但能让「写稳一条关键路径」的成本降下来,间接支撑了「少而稳」这个策略。

用例命名要说清楚测的是什么:

1test('用户可以创建并发布文章', async ({ page }) => {})

别写 test('case1')。失败报告应该让人扫一眼就知道是哪条业务路径断了,而不是再回去翻代码猜。

flaky 用例要当 bug 治,别当噪音忍

E2E 走到后期,最消耗信任的不是某条用例真的挂了,而是它时红时绿。一旦团队开始习惯性地「再跑一次说不定就绿了」,整套 E2E 就废了——没人再信它的红色。

我的原则是:一条用例只要 flaky,就当成 bug 立项,而不是调大 retries 蒙混过去。常见的 flaky 根因就那么几类,前面其实都点过:硬等时间、依赖脏数据、依赖真实时钟、用例之间共享状态、选择器绑死了结构。排查时先看 trace 回放,定位到底是哪一步偶发地等不到元素或拿错数据,再对号入座去修根因。

有一类隐蔽的 flaky 来自动画和过渡。元素已经在 DOM 里,但正在做进场动画,点击落空。Playwright 的 click 默认会等元素稳定(stable),但有些自定义动画它判断不出来,这时可以在测试环境全局关掉动画:

1await page.addStyleTag({
2  content: `*, *::before, *::after {
3    transition: none !important;
4    animation: none !important;
5  }`,
6})

把动画在测试里禁掉,既让点击更稳,也让截图对比更可复现。这类处理不改产品行为,只是把测试环境的不确定性摁下去。

回到那个取舍

把这些手段串起来,其实都在服务开头那个判断:E2E 是稀缺资源,只留给关键路径。

选择器像用户一样找元素、等待绑定业务状态、登录态复用、数据自备、错误分支挑一两条、CI 留 trace——这些都不是为了让 E2E 更「全」,而是为了让那少数几条关键用例足够稳,稳到大家愿意信它、上线前愿意等它跑完。一套没人信、动不动就重跑的 E2E,比没有 E2E 还糟,因为它既占着 CI 时间,又给不了任何保障。

还有一个隐性收益值得说:写得好的关键路径 E2E,本身就是一份可执行的业务文档。新人接手时,读一遍 test('用户可以创建并发布文章') 这类用例,比翻一堆散落的需求文档更快看清主干流程该怎么走。前提依然是它们少而清晰——几百条脆弱用例既当不了文档,也当不了防线。

Playwright 让 E2E 写起来很顺,顺到很容易忘记克制。这一层的价值由用例花在哪儿决定,而不是用例数量本身。少而稳的关键路径,比一大堆动不动就红的脆弱用例值钱得多。