端到端测试这事,我以前是抗拒的——慢、脆、维护成本高,跑十次有三次假阳性,CI 一红就有人开始 rerun 大法。直到这两年项目里全面切到 Playwright,才发现"E2E 不能写"这个判断有一半是工具的锅。
这篇是我把 Playwright 接进项目的完整路径,包含一些被 PR review 反复纠正之后才学会的取舍。
为什么是 Playwright
同类工具我都试过,Playwright 是唯一一个让我愿意把 E2E 加进 PR 必跑的:
- auto-wait 默认开——元素没出现、没可点击、没 stable,
click()自己等。再也不用写sleep(500)看运气。 - 多浏览器一套 API——Chromium / WebKit / Firefox,同一份代码跑三遍。
- trace viewer——失败的用例自动录一份带 DOM 快照、network、console 的离线 trace,本地双击就能"时间旅行"。
- 原生 TypeScript——没有
cy.*那种 magic 链式 API,IDE 补全到位。
不再用 Cypress 的核心原因不是它不好,而是它的架构选择——测试代码和被测页面跑在同一个浏览器进程里——决定了跨域、多 tab、跨 origin iframe 这些场景都要绕。Playwright 是带外驱动:Node 进程通过 CDP / WebKit 协议远程控制浏览器,浏览器里看到什么就是什么,多 context / 多 tab / 多 origin 天然支持。
3 分钟跑起来
在已有的 web 项目根目录:
| |
脚手架问的几个问题,建议这么选:
- TypeScript——选。JS 没有理由。
- tests 目录——默认
tests/,和单元测试分开,避免 jest/vitest 误抓。 - GitHub Actions workflow——选,下面 CI 章节会用。
- install browsers now——选。
第一条测试:登录
假设 web 跑在 http://localhost:5173,登录页有 email / password 输入框和登录按钮。tests/auth.spec.ts:
| |
不同框架默认端口不同:Vite
5173、Next.js / Nuxt / Remix3000、Astro4321,按你 dev server 的端口改下面的 baseURL。
把 baseURL 配到 playwright.config.ts,page.goto('/login') 才能省掉前缀;顺便让 Playwright 帮你拉起 dev server:
| |
Locator:唯一可信的选择器
这一节是新手最容易翻车的地方。Playwright 的核心抽象是 Locator,不是 jQuery 那种"选一个 DOM 出来"。
优先级从高到低:
getByRole('button', { name: 'Sign in' })——语义角色 + 可访问名称。和屏幕阅读器看到的一致,最稳。getByLabel('Email')——表单首选。label 改了说明 UI 真的变了,测试该跟着挂。getByText('Welcome back')——静态文本。getByTestId('submit-btn')——上面三个都不适用(比如纯 icon 按钮),加data-testid。page.locator('.btn-primary')——最后选项。CSS class 一改测试全挂,没人愿意维护这种用例。
反例(在我团队里 review 见到一次拒一次):
| |
不要 sleep,要 wait
看到 await page.waitForTimeout(2000) 的 PR,直接 request changes。它带来的是 flaky 测试 + CI 慢 的双输。
Playwright 的所有 action 和 expect() 都自带 retry-until-timeout:
| |
需要等接口返回?用 page.waitForResponse,不要 sleep:
| |
tRPC / GraphQL 同理——把 URL 匹配换成你那条具体的 endpoint(tRPC 是
/trpc/<router>.<procedure>,GraphQL 看body里的operationName)即可。
Fixture:把重复登录拿掉
10 条用例都需要先登录?不要在每条用例里复制 7 行登录代码。Playwright 的 fixture 比 jest beforeEach 强得多——它能 把登录态序列化到 storage state,所有用例直接复用。
第一步,写一个全局 setup(tests/global.setup.ts):
| |
然后在 config 里把 setup 接到所有 project 前面,并把 storageState 注入:
| |
之后每个用例的 page 一拿到就是登录态,省掉 N 倍重复。把 tests/.auth/ 加进 .gitignore。
网络层:mock 还是放过
E2E 设计里最大的争议。我的判断标准很简单:
- 自家后端 → 不 mock。E2E 的价值就是验证前后端契约,mock 等于自欺。本地起
docker compose up,CI 用 service container。 - 第三方 → mock。Stripe、OAuth、短信网关、LLM API——不可控、有费用、有限流。用
page.route()拦掉。
拦截示例(测支付网关 503 时,结账页应该展示降级文案):
| |
Stripe / Twilio / OpenAI / Auth0 同理——任何不可控、有费用、有限流的外部依赖都该这么拦。判断标准就一条:「这玩意儿挂了我能恢复吗?」不能就 mock。
出错时打开 trace viewer
这是 Playwright 最被低估的功能。配置里写:
| |
失败之后跑:
| |
浏览器打开后你会看到一个时间轴,每一步都有:
- Action 之前 / 之后的 DOM 快照(不是截图,是可点击的真 DOM)
- 所有 network request / response
- console log / error
- 调用栈——直接定位到测试文件的哪一行
排查 flaky 用例的速度从"猜半天"变成"看 trace 5 秒"。CI artifact 里把 test-results/ 上传一份,离线复盘也能用。
CI 集成与并发
GitHub Actions 模板(脚手架自动生成)的核心几行:
| |
GitLab CI 的对应版本:
| |
Jenkins / CircleCI / Drone 思路一致:装浏览器(或直接用 Playwright 官方镜像
mcr.microsoft.com/playwright)→ 跑npx playwright test→ 上传playwright-report/作为 artifact。
几个值得提前对齐的点:
- workers——
workers: process.env.CI ? 2 : undefined。本地用满,CI 收着点防 OOM。 - retries——
retries: process.env.CI ? 2 : 0。本地零容忍 flaky,CI 给两次机会但 trace 必须留下。 - shard——用例多了用
--shard=1/4切片并行,actions matrix 一摆,10 分钟变 3 分钟。
总结
- locator 选语义,不选 CSS。
getByRole/getByLabel是默认手段。 - 不要 sleep,要 expect。所有等待都该是"等到某个状态",不是"等够某段时间"。
- 登录态走 storageState。一次登录,全员复用。
- 自家后端不 mock,第三方必 mock。前者保契约,后者保稳定。
- trace viewer 是排查 flaky 的唯一武器。
trace: 'on-first-retry'写进配置。
端到端测试最大的成本不是写,是 flaky 用例慢慢腐蚀团队信任。每条 sleep、每条 CSS 选择器、每个 mock 自家接口的决策,都是在给未来的自己挖坑。Playwright 把"做对的事"做成了默认——顺着它的设计走,测试套就不会反过来咬你。
