Skip to main content

E2E 测试:Playwright 实战

E2E(End-to-End)测的是真实用户链路:打开浏览器 → 操作 → 看到结果。前端 E2E 首选 Playwright(Microsoft 出品,2025+ 事实标准),Cypress 已被反超

一、避坑指南

  1. E2E 不要写太多:测试金字塔顶端的 E2E 慢、脆、维护贵。只覆盖核心链路(登录、支付、主流程),其余用单测/集成测兜底。

  2. 必须用「用户视角」定位元素

    // ❌ 错误:绑定 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();
  3. 不要 waitForTimeout 硬等

    // ❌ 错误:要么白等、要么不够
    await page.waitForTimeout(2000);

    // ✅ 正确:Playwright 有 auto-wait,等元素可达 + stable
    await page.getByRole("button", { name: "提交" }).click();
    await expect(page.getByText("保存成功")).toBeVisible();
  4. 网络必须可控:测试要稳定,必须能 mock 后端响应,否则后端一抖测试就 flake。

  5. 测试数据要隔离:每个测试用独立账号/数据,不要共用

  6. CI 必须配重试 + trace:CI 环境比本地慢,必须有 trace 视频以便定位失败。

二、Playwright vs Cypress(2026 现状)

维度PlaywrightCypress
出品方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 步」

十一、相关文档