E2E 测试实战:Playwright
单测验证单元、集成测验证组合,但"用户能不能真正用"只有 E2E 能答。本文讲清 Playwright(微软出品,跨浏览器/自动等待/网络拦截/并行)的核心能力、Page Object 模式、CI 集成、以及 E2E 该测什么不该测什么。
本文要点
- 主题聚焦:React、TypeScript
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
测试金字塔讲了十年,但大多数项目往上走到集成测试就停了。单测 mock 一切、集成测只到组件组合,真正能回答"用户能不能在 Chrome 里点那个按钮完成结账"的,只有 E2E。问题是 E2E 一直被骂——慢、贵、flaky、难维护。这些骂名一半是真的,一半是工具不行。
我用过 Selenium、Cypress、TestCafe、Playwright。Selenium 是上一代的霸主,API 啰嗦、等待靠猜;Cypress 架构优雅但只支持 Chromium 且商用功能收费;TestCafe 小众。2020 年微软推出 Playwright 之后,这个领域基本被它统一了。现在新项目选 E2E 工具,默认答案就是 Playwright。这篇讲清为什么,以及怎么用得稳。
一、为什么需要 E2E:回答"用户能不能用"的那一层
测试金字塔从下到上:单元测试、集成测试、E2E 测试。每层回答不同问题。
- 单元测试:这个函数给定输入,输出对不对?快、隔离、数量最多。
- 集成测试:这几个模块组合起来,数据流是不是通的?中等速度,数量适中。
- E2E 测试:真实用户在真实浏览器里走完整流程,能不能完成任务?慢、贵、数量最少。
举个例子:你写了个结账流程。单测能验证购物车计算的金额对不对,集成测能验证 cart 组件和 API 接口对得上,但下面这些问题,只有 E2E 能抓住:
- 用户登录后,token 写进 cookie 了,但 cookie 是
Secure的,而你的预发环境是 HTTP——浏览器拒收,用户每次刷新都被踢回登录页。 - 支付按钮 onClick 触发 fetch,但按钮被一个 z-index 更高的 toast 盖住了,点不到。
- 路由用了
window.history.pushState,但没配 SSR fallback,直接访问/orders/123返回 404。 - 第三方分析脚本加载慢,阻塞了主流程的
await,结账卡死。
这些 bug 的共同点:单个模块都对,组合起来在真实环境里坏了。单测不可能发现,因为单测里 cookie、网络、浏览器行为全是 mock 的。E2E 把这层 mock 全部拆掉,用真浏览器跑真流程,能抓到这一整类"集成层的真实问题"。
代价也明确:E2E 慢(单测毫秒级,E2E 秒级)、贵(CI 要跑浏览器)、容易 flaky(网络、动画、时序都是变量)。所以正确姿势是少量精准的 E2E 守关键路径,大量单测守边界逻辑。E2E 不是替代单测,是补上单测到不了的那一层。
二、Playwright 的核心优势:为什么是它统一了市场
Playwright 由微软出品(团队很多是当年做 Puppeteer 的),从 2020 年发布起就以"开发者体验"为刀刃。它的核心优势我按重要性排:
1. 跨浏览器,一份代码跑三家。Chromium、Firefox、WebKit 全支持,API 完全一致。Safari 一直是最难测的浏览器(WebKit),过去只能用昂贵的 BrowserStack 或自建 Mac farm,Playwright 让 WebKit 测试成了本地一行命令的事。注意:WebKit ≠ Safari,Playwright 用的是 WebKit 的开源构建,和真 Safari 有细微差异,但 95% 的兼容性问题都能抓到。
npx playwright install chromium firefox webkit
2. auto-wait,告别 sleep。这是 Playwright 最被低估的特性。当你写 await page.click('#submit'),它不是立刻点,而是先等这个元素可操作(attached to DOM、visible、stable、能接收事件),最多等 30 秒。sleep(1000) 这种黑魔法可以彻底扔掉——而 Cypress 的很多 flaky 问题根源就是开发者习惯性加 cy.wait(500)。
// Playwright 自动等待,无需 sleep
await page.click('#submit'); // 等到按钮可点击
await page.waitForResponse('/api/login'); // 显式等某个接口
await expect(page.locator('.welcome')).toBeVisible(); // 断言也会等
3. 网络拦截,API mock 一等公民。page.route() 让你在浏览器层拦截任意请求,改 status、改 body、延迟、直接 abort。这意味着 E2E 测试可以不依赖真实后端——后端没起来、或想测错误分支,都能搞定。
// 拦截登录接口,模拟失败
await page.route('**/api/login', async (route) => {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: 'invalid credentials' }),
});
});
// 模拟接口超时(测 loading 态和超时处理)
await page.route('**/api/orders', (route) =>
new Promise((resolve) => setTimeout(() => resolve(route.abort()), 5000))
);
4. 并行执行,速度快。Playwright 用 worker 进程并行跑测试文件,默认按 CPU 核数开 worker。一个 60 个测试的套件,串行 5 分钟,开 4 个 worker 可能 1 分半。配合 test.describe.configure({ mode: 'parallel' }) 还能在文件内并行。
5. trace viewer,flaky 杀手。测试失败时,Playwright 能录下完整的执行 trace——每一步的 DOM 快照、网络请求、控制台日志、截图、视频。用 npx playwright show-trace trace.zip 打开,像看录像一样拖动时间轴,定位 flaky 的根因。这个工具把我调试 E2E 的时间砍掉了 70%。
再加几个加分项:codegen(npx playwright codegen 录制操作自动生成代码)、locator 优先级语义清晰、内置 accessibility selector、fixtures 机制让 setup/teardown 优雅、component testing 正在成熟。一句话:它是过去五年 E2E 工具里综合体验最好的,没有之一。
三、上手:5 分钟跑通第一个测试
npm init playwright@latest
# 选 TypeScript、选放 tests 目录、装浏览器
脚手架会生成 playwright.config.ts、一个示例测试、装好三浏览器。配置文件是我见过的工具里最直观的:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true, // 文件级并行
forbidOnly: !!process.env.CI, // CI 上禁止 test.only
retries: process.env.CI ? 2 : 0, // CI 上重试 2 次,本地不重试
workers: process.env.CI ? 2 : undefined, // CI 上限制 worker 数
reporter: process.env.CI
? [['github'], ['html', { open: 'never' }]]
: 'list',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry', // 第一次重试时才录 trace,省空间
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'mobile', use: { ...devices['iPhone 15'] } },
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
几个我坚持的配置习惯:
trace: 'on-first-retry'而不是'on'。trace 文件不小,每次都录磁盘吃不消。第一次重试才录,既保证 flaky 时有材料,又不烧空间。retries区分本地和 CI。本地重试会掩盖问题,CI 重试能滤掉真 flaky。这个差异很重要——本地写测试时不重试,bug 立刻暴露。webServer让测试自启应用。pnpm dev起服务、等url可达、再跑测试,CI 上零配置;本地reuseExistingServer复用已经在跑的 dev server,改完代码秒跑。- projects 分浏览器和设备。同一个测试跑多份,覆盖面立刻翻倍。但成本也翻倍,所以 CI 上我会精选——PR 跑 Chromium 一个,nightly 跑全量。
第一个测试:
import { test, expect } from '@playwright/test';
test('用户可以登录', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('邮箱').fill('[email protected]');
await page.getByLabel('密码').fill('correct-password');
await page.getByRole('button', { name: '登录' }).click();
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('欢迎回来')).toBeVisible();
});
读这段代码要注意几个细节:getByLabel、getByRole 是语义化 selector,优先级最高(下面会讲为什么);fill 和 click 都自带 auto-wait,不用 waitFor;expect 是断言也会自动重试,直到超时或断言通过。整个测试没有任何 sleep,这是稳定性的根本来源。
四、Page Object 模式:让测试不变成"复制粘贴地狱"
测试代码不写架构,迟早烂掉。第一个测试看着清爽,写到第十个的时候,你会发现每个测试都在重复"填邮箱、填密码、点登录、等跳转"。某天登录页改了 UI,密码输入框从 getByLabel('密码') 变成了 getByPlaceholder('请输入密码'),你要改十个文件。这就是 Page Object 模式要解决的问题。
核心思想:把每个页面的"长什么样"封装成类,测试只表达"做什么"。页面变了,只改 Page Object;流程变了,只改测试。
// pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('邮箱');
this.passwordInput = page.getByLabel('密码');
this.submitButton = page.getByRole('button', { name: '登录' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}
测试代码立刻清爽:
import { test } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test('登录成功', async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
await login.login('[email protected]', 'correct-password');
await expect(page).toHaveURL('/dashboard');
});
test('密码错误显示提示', async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
await login.login('[email protected]', 'wrong-password');
await login.expectError('邮箱或密码错误');
});
Page Object 的几条纪律:
- Page Object 只封装"用户能看到的操作",不包含断言逻辑的细节。但像"登录后应该跳到 dashboard"这种页面级的状态断言,放 Page Object 里也合理。我的边界是:业务断言放测试里,UI 状态断言可以放 Page Object 里。
- 方法名用业务语言(
login、addToCart、checkout),而不是技术语言(fillEmailInput、clickSubmitButton)。测试读起来应该像业务流程描述。 - 不要把 Page Object 当 God Object。一个类对应一个页面或一个明显的页面区域。结账流程分成
CartPage、ShippingPage、PaymentPage、ConfirmationPage,而不是塞进一个CheckoutFlow里——后者会膨胀到不可维护。 - 跨页面流程用 helper 或 fixture。比如"已登录状态"在很多测试里都要,用 fixture 注入一个"预登录的 page"比每个测试都走一遍登录流程快得多。
Playwright 官方现在更推一种"轻量"风格——直接在测试里用 locator,小项目不强制 Page Object。我的判断是:超过 20 个测试就上 Page Object。否则重复代码会反咬你。
五、该测什么、不该测什么:E2E 的边界
这是最容易被搞错的一块。E2E 又慢又贵,测错东西比不测更糟——既浪费 CI 时间,又给人虚假的安全感。我有一条硬规则:E2E 只测"钱和信任"相关的关键路径。
该测的(用户价值最高的 happy path):
- 注册:用户能不能完成注册、收到验证邮件、激活账号?
- 登录/登出:核心身份流程,挂了一次投诉能炸三次。
- 结账/支付:直接关系收入,这条路径挂一小时,损失看得见。
- 核心业务动作:发文章、提交订单、上传文件——这个产品"存在是为了什么"的那个动作。
- 关键错误分支:支付失败、网络断开、权限不足——这些用户高频遇到,体验差会直接流失。
不该测的(留给单测/集成测的):
- CSS 细节、视觉回归:按钮是不是圆角 4px、颜色对不对,留给 Visual Regression Testing(Playwright 也有 toHaveScreenshot)或 Storybook + Chromatic。E2E 测视觉又慢又 flaky。
- 纯函数逻辑:金额计算、日期格式化、表单校验规则——单测覆盖,几毫秒一个。
- 组件内部状态:React 组件的 state 变化、props 传递——组件测试(Playwright component testing 或 Testing Library)足够,E2E 杀鸡用牛刀。
- 第三方库的行为:你不必测 React Router 会不会路由、测 axios 会不会发请求,那是它们的事。
数量参考:一个 SaaS 应用,我通常把 E2E 控制在 20-50 个测试,覆盖 5-10 条核心路径。每个测试跑 5-15 秒,全量 2-5 分钟。超过这个量级,要么是测了不该测的,要么是测试设计有冗余——该砍就砍。E2E 套件像花园,不修剪就长荒草。
还有一条反模式要警惕:不要用 E2E 测试覆盖率去追求指标。E2E 的覆盖率天然低于单测(因为它走的是用户路径,不是代码路径),把 E2E 当覆盖率工具会逼着团队写大量低价值测试去补边角代码。E2E 的衡量标准是"关键路径有没有被守住",不是 line coverage。
六、CI 集成:让 E2E 真正在每次提交时跑起来
E2E 不进 CI 等于没写。本地能跑不算数,关键是有没有 PR 提交时自动跑、失败时有没有现场可查。GitHub Actions + Playwright 的标配:
name: E2E
on:
pull_request:
push:
branches: [main]
schedule:
- cron: '0 4 * * *' # 每天凌晨 4 点全量跑,测 flaky
concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
# 关键:缓存浏览器二进制,首次 30 秒,后续 3 秒
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install browser binaries
run: pnpm exec playwright install --with-deps chromium
- name: Run E2E
run: pnpm exec playwright test --project=chromium
# 失败时上传 artifact:HTML 报告、trace、视频、截图
- name: Upload report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
- name: Upload traces
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: playwright-traces
path: test-results/
retention-days: 3
几个 CI 上的实战经验:
- 缓存浏览器二进制。
playwright install要下 100MB+ 的浏览器,不缓存每次都拉,慢且费流量。key 绑 lockfile,Playwright 升级时自动失效。 - PR 只跑 Chromium,nightly 跑全量。
--project=chromium在 PR 上是性价比之王。Firefox、WebKit、mobile 这些放进 schedule cron,每天跑一次,flaky 和兼容性问题夜里抓。 if: ${{ !cancelled() }}上传报告。即使测试失败也上传 HTML 报告,开发者点开 artifact 就能看到可视化结果、每个失败步骤的截图。failure()单独上传 trace 和 video,这些是 debug 的核心材料。timeout-minutes: 30必加。E2E 容易因为某次 flaky 或环境挂起而无限跑,有上限才不会烧光额度。- 失败通知用 GitHub native。
check_run失败会自动出现在 PR 上,不必再接 Slack webhooks——除非团队不在 GitHub 上看。
如果团队用 Vercel Preview 部署,有个更优雅的玩法:在 PR 上不跑本地 dev server,而是等 Vercel 部署完 preview URL,直接对这个 URL 跑 E2E。这样测的是真正接近生产的环境,本地 dev server 的环境差异问题一并解决。Playwright 的 baseURL 用脚本动态注入 PR 的 preview URL 即可。
七、稳定性:E2E 不 flaky 的六条军规
flaky 是 E2E 的原罪。一个时过时不过的测试,比没有测试还糟——它让团队对 CI 失去信任,红色按钮被习惯性忽略,真问题来了也没人理。我把这些年压 flaky 的经验压成六条:
1. selector 优先级:data-testid > 语义 > CSS > text。Playwright 官方推荐的优先级是有道理的:
// 最佳:data-testid,与实现解耦,专为测试而生
page.getByTestId('checkout-button')
// 次选:语义化角色,可访问性友好,鼓励写好 HTML
page.getByRole('button', { name: '结账' })
// 再次:label、placeholder、alt
page.getByLabel('邮箱')
// 避免:CSS selector,UI 重构就挂
page.locator('.btn-primary.large.checkout')
// 最差:纯文本,国际化一改就全挂
page.locator('text=结账')
CSS selector 是 flaky 重灾区——前端改 className 太常见,测试跟着挂。text selector 在多语言项目里是定时炸弹。data-testid 看着"丑",但它是测试和业务代码之间最干净的契约,该用就用,别为了"代码干净"牺牲测试稳定性。
2. 绝不写 waitForTimeout。page.waitForTimeout(1000) 是 E2E 里最该被禁的 API。它本质是 sleep,要么不够长(导致 flaky),要么太长(浪费时间)。正确的做法是等事件:等元素出现、等请求完成、等 URL 变化。Playwright 的 auto-wait 已经覆盖 80% 场景,剩下 20% 用显式 waitForResponse、waitForURL、expect().toBeVisible() 这些。
// 烂:固定等待,既慢又 flaky
await page.waitForTimeout(2000);
await page.click('#next');
// 好:等接口返回,事件驱动
await Promise.all([
page.waitForResponse('**/api/data'),
page.click('#next'),
]);
Promise.all 包 click 和 waitForResponse 是经典模式——click 触发请求,response 回来后才继续,既不漏等也不多等。
3. 测试间隔离,绝不共享状态。每个测试必须能独立跑、能乱序跑、能并行跑。Playwright 默认每个测试一个全新的 browser context(等价于无痕窗口),cookie、localStorage 自动隔离。但如果你手动往 localStorage 塞了 token、或用了共享的全局变量,这条就被破坏了。看到测试套件"必须按顺序跑才能过",那就是隔离坏了——这种套件迟早 flaky。
4. mock 外部依赖,但别 mock 全部。第三方 API(SMS、支付、邮件)在 CI 里不可控,必须 mock——它们挂了你的 CI 也挂。但你自己的后端不要 mock,否则测的是假系统。Playwright 的 page.route() 拦第三方,自己的 API 走真实链路,这是黄金分割。
test.beforeEach(async ({ page }) => {
// 拦截所有第三方
await page.route('**/segment.io/**', (route) => route.fulfill({ status: 200, body: '' }));
await page.route('**/sentry.io/**', (route) => route.fulfill({ status: 200, body: '' }));
// 自家 API 不拦,走真实链路
});
5. retry 是药不是饭。retries: 2 在 CI 上能滤掉真 flaky,但绝不能拿 retry 当"我测试写得烂也没事"的借口。每用一次 retry,问自己:这个 flaky 的根因是什么? 大概率是某个 waitForTimeout 没改成事件等待,某个 selector 不够稳。retry 是止痛药,该吃但别上瘾,长期吃会掩盖病情。
6. flaky 一定要复盘。每个 flaky 出现,要么修测试(改 selector、改等待方式),要么修产品(处理竞态、加 loading 态)。Playwright 的 trace viewer 是复盘利器——失败时下载 trace.zip,拖时间轴看每一步的 DOM 和网络,十次有八次根因一目了然。flaky 不修,半年后你的 CI 就是废人。
补一句关于"测试数据":E2E 跑真实链路就要面对数据准备。最干净的做法是每个测试自己 setup 自己的测试数据(通过 API 创建测试用户、测试订单),测试结束自己 teardown。Playwright 的 fixtures 配合 test.extend 非常适合做这件事。共用一份"测试账号"会让测试互相干扰,在并行场景下灾难性地 flaky。
小结
- E2E 测的是单测到不了的那一层:单测验单元、集成测验组合,但 cookie、网络、浏览器行为、SSR 这些"真实环境"问题,只有 E2E 能抓。它是补充不是替代,少量精准守关键路径,大量单测守边界。
- Playwright 统一市场不是偶然:跨浏览器(Chromium/Firefox/WebKit 一份代码)、auto-wait(告别 sleep)、网络拦截(mock API 一等公民)、并行执行、trace viewer(flaky 杀手)。综合体验,目前没有对手。
- Page Object 是 E2E 不烂掉的架构:页面长什么样封装进类,测试只表达做什么。20 个测试以上必上,方法名用业务语言,跨页流程用 fixture 注入预登录态。
- 该测什么比怎么测更重要:E2E 只守"钱和信任"——注册、登录、结账、核心业务动作、关键错误分支。CSS 细节、纯函数、组件内部状态都归单测。E2E 数量控制在 20-50,衡量标准是关键路径覆盖率,不是 line coverage。
- 稳定性靠纪律,不靠工具:selector 优先级
data-testid > 语义 > CSS > text,绝不写waitForTimeout,测试间严格隔离,mock 第三方但不 mock 自家后端,retry 是药不是饭,flaky 必复盘。
一句话收尾:E2E 测试的价值不在数量多,而在每一测都"真·用户路径",失败时有现场、有 trace、能定位。 写 100 个 flaky 的 E2E 不如写 10 个稳的——稳定是 E2E 的唯一货币,失去它,整个测试套件就是噪音。
Related