E2E 测试:Playwright 实战
E2E(End-to-End)测的是真实用户链路:打开浏览器 → 操作 → 看到结果。前端 E2E 首选 Playwright(Microsoft 出品,2025+ 事实标准),Cypress 已被反超。
一、避坑指南
E2E 不要写太多:测试金字塔顶端的 E2E 慢、脆、维护贵。只覆盖核心链路(登录、支付、主流程),其余用单测/集成测兜底。
必须用「用户视角」定位元素:
// ❌ 错误:绑定 DOM 结构,重构即崩
page.locator("div.container > div:nth-child(2) > button.submit").click();
// ✅ 正确:用户能感知的语义
page.getByRole("button", { name: "提交" }).click();
page.getByLabel("邮箱").fill("user@example.com");
page.getByTestId("user-card").click();不要
waitForTimeout硬等:// ❌ 错误:要么白等、要么不够
await page.waitForTimeout(2000);
// ✅ 正确:Playwright 有 auto-wait,等元素可达 + stable
await page.getByRole("button", { name: "提交" }).click();
await expect(page.getByText("保存成功")).toBeVisible();网络必须可控:测试要稳定,必须能 mock 后端响应,否则后端一抖测试就 flake。
测试数据要隔离:每个测试用独立账号/数据,不要共用。
CI 必须配重试 + trace:CI 环境比本地慢,必须有 trace 视频以便定位失败。
二、Playwright vs Cypress(2026 现状)
| 维度 | Playwright | Cypress |
|---|---|---|
| 出品方 | Microsoft(2020+) | Cypress.io(2017+) |
| 浏览器引擎 | 真实 Chromium / Firefox / WebKit | 跑在 Electron 里(贴近真实) |
| 多 tab / 多 origin | ✅ 原生支持 | ❌ 受限 |
| 移动端 | ✅ 设备模拟(iPhone / Pixel) | 弱 |
| 等待机制 | Web-first auto-wait(自动) | 手动 retry / 显式 wait |
| 网络拦截 | ✅ page.route() | ✅ cy.intercept() |
| 并行执行 | ✅ 内置(shard / worker) | 付费版 |
| 语言 | TS / JS / Python / Java / .NET | 仅 JS / TS |
| Trace 调试 | Trace Viewer(极强) | Dashboard(要钱) |
| 学习曲线 | 中 | 较低 |
| 社区 | 极活跃(反超 Cypress) | 活跃但增速放缓 |
结论:新项目无脑选 Playwright。Cypress 仍能用,但已被反超。
三、快速上手
# 1. 安装
pnpm add -D @playwright/test
pnpm exec playwright install # 下载浏览器
pnpm exec playwright install-deps # 装系统依赖(CI 必跑)
# 2. 初始化
pnpm exec playwright init # 生成 playwright.config.ts
# 3. 跑测试
pnpm exec playwright test
pnpm exec playwright test --ui # UI 模式(推荐调试用)
pnpm exec playwright test --headed # 看浏览器跑
playwright.config.ts:
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true, // 并行跑
forbidOnly: !!process.env.CI, // CI 禁 .only
retries: process.env.CI ? 2 : 0, // CI 失败重试
workers: process.env.CI ? 1 : undefined, // CI 单 worker 稳定
reporter: process.env.CI
? [["html"], ["junit", { outputFile: "test-results/junit.xml" }]]
: "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: "Mobile Safari", use: { ...devices["iPhone 14"] } },
],
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
四、核心 API
4.1 定位器(locators)
| 定位器 | 用途 | 优先级 |
|---|---|---|
getByRole("button", { name }) | 按 ARIA 角色 + 名称(最稳) | ⭐⭐⭐⭐⭐ |
getByLabel("邮箱") | 表单 label 关联 | ⭐⭐⭐⭐⭐ |
getByText("提交") | 按可见文本 | ⭐⭐⭐⭐ |
getByPlaceholder("搜索...") | input placeholder | ⭐⭐⭐ |
getByTestId("submit-btn") | data-testid(兜底) | ⭐⭐⭐ |
locator("css") | CSS 选择器(不推荐) | ⭐ |
4.2 auto-wait(Playwright 最大优势)
// Playwright 自动等待元素:
// ✅ 元素已 attached 到 DOM
// ✅ 元素 visible
// ✅ 元素 stable(不在动画)
// ✅ 元素 enabled / editable
// 点击前 4 个条件都满足才执行
await page.getByRole("button").click(); // 自动等 30s
4.3 交互 API
await page.goto("/login");
await page.getByLabel("邮箱").fill("user@example.com");
await page.getByLabel("密码").fill("123456");
await page.getByRole("button", { name: "登录" }).click();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByText("欢迎")).toBeVisible();
4.4 断言(expect)
import { expect } from "@playwright/test";
await expect(page).toHaveTitle("My App");
await expect(page).toHaveURL(/dashboard/);
await expect(locator).toBeVisible();
await expect(locator).toHaveText("保存成功");
await expect(locator).toHaveValue("user@example.com");
await expect(locator).toHaveClass(/active/);
await expect(locator).toBeDisabled();
五、Page Object Model(POM)
解决 E2E 维护噩梦:把页面操作封装成对象,测试代码只关注业务流程。
// e2e/pages/LoginPage.ts
import { type Page, type Locator } from "@playwright/test";
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(private page: Page) {
this.emailInput = page.getByLabel("邮箱");
this.passwordInput = page.getByLabel("密码");
this.submitButton = page.getByRole("button", { name: "登录" });
}
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();
}
}
// e2e/login.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "./pages/LoginPage";
test("登录成功", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login("user@example.com", "123456");
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByText("欢迎")).toBeVisible();
});
六、网络拦截与 Mock
test("接口错误时显示降级 UI", async ({ page }) => {
// 拦截接口
await page.route("**/api/users", (route) => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: "Server Error" }),
});
});
await page.goto("/users");
await expect(page.getByText("服务异常,请稍后重试")).toBeVisible();
});
Mock 整个后端(Playwright 内置):
test("用 fixture 数据", async ({ page }) => {
await page.route("**/api/products", (route) =>
route.fulfill({
path: "./fixtures/products.json",
})
);
await page.goto("/products");
await expect(page.getByText("iPhone")).toBeVisible();
});
七、视觉回归测试
test("首页 UI 不变", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveScreenshot("home.png", {
maxDiffPixelRatio: 0.01, // 允许 1% 像素差异
});
});
# 首次跑生成 baseline
pnpm exec playwright test --update-snapshots
进阶:视觉回归用 AI 模型(如 Meticulous.ai)替代像素对比,抗 UI 微小变化(字体渲染、像素抗锯齿)。详见 AI 驱动的测试。
八、调试技巧
8.1 Trace Viewer(最常用)
// playwright.config.ts
use: { trace: "on-first-retry" }
# 失败时打开 trace
pnpm exec playwright show-trace test-results/.../trace.zip
可以看到每一步的截图、网络、DOM 快照。
8.2 UI 模式
pnpm exec playwright test --ui
可视化点击运行、暂停、单步执行。
8.3 调试器
test("调试", async ({ page }) => {
await page.goto("/");
await page.pause(); // 暂停,进入交互式 REPL
});
九、CI 集成(GitHub Actions)
# .github/workflows/e2e.yml
name: E2E
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps
- run: pnpm build
- name: 跑 E2E
run: pnpm exec playwright test
- name: 上传 report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
性能优化:用
shard分片并行跑:strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- run: pnpm exec playwright test --shard=${{ matrix.shard }}
十、面试高频
Q1:Playwright 的 auto-wait 解决了什么?
A:Cypress 等老工具要手动 waitFor / expect().to.be.visible 反复轮询,要么白等要么不够。Playwright 在 click/fill 前自动等元素 attached / visible / stable / enabled,30s 内重试。少 80% 的 flake 来自等待问题。
Q2:Page Object Model 是什么?为什么要用?
A:把页面操作封装成类(如 LoginPage),测试代码只调用 loginPage.login() 这种业务语义方法,不关心定位器。好处:① 改 UI 时只改 POM 不改测试;② 测试可读性高。
Q3:怎么解决 E2E 慢的问题?
A:① 并行(Playwright 内置多 worker);② shard 分片到多个 CI job;③ 只测核心链路(不要试图覆盖所有场景);④ mock 替代真实慢接口。
Q4:E2E 失败了怎么排查?
A:① Trace Viewer(看每步的截图、网络、DOM 快照);② test.only 单独跑失败用例;③ --headed 模式本地看;④ CI 失败时下载 playwright-report artifact。
Q5:什么时候不该用 E2E?
A:纯函数逻辑、单组件交互、API mock 校验。这些用单测/集成测更快更稳。E2E 只覆盖「真实用户从进站到离开的关键 1~2 步」。