E2E Testing Patterns

提供 Playwright 与 Cypress 的端到端测试最佳实践,提升测试稳定性与效率。

已扫描
适合谁
前端开发工程师、测试自动化工程师
不适合谁
无前端或测试经验的初学者、仅需简单页面展示的非技术用户
国内可用性
需网络配置。可能需要网络配置或第三方服务可访问。
安装难度
新手友好(★☆☆)。基于终端操作、依赖、API Key 和本地环境要求的初步判断。

安装与下载

openclaw skills install @wpank/e2e-testing-patterns

Skill 说明

命令、参数、文件名以原文为准

E2E 测试模式

测试用户实际操作,而非代码实现方式。端到端测试验证系统整体运行正常——它们是你发布软件的信心来源。

安装

OpenClaw / Moltbot / Clawbot

npx clawhub@latest install e2e-testing-patterns

本技能的作用

提供构建端到端测试套件的模式,具备以下特性:

  • 在用户发现问题前发现回归问题
  • 运行速度足够快,适用于 CI/CD 管道
  • 保持稳定(无间歇性失败)
  • 覆盖关键用户流程,避免过度测试

适用场景

  • 为 Web 应用实现端到端测试自动化
  • 调试间歇性失败的测试
  • 在 CI/CD 管道中设置包含浏览器测试的流水线
  • 测试关键用户工作流(登录、结账、注册)
  • 决定哪些内容应使用 E2E 测试,哪些应使用单元或集成测试

测试金字塔 —— 明确你的测试层级

        /\
       /E2E\         ← 少量:仅关键路径(本技能覆盖范围)
      /─────\
     /Integr\        ← 较多:组件间交互、API 合约
    /────────\
   /Unit Tests\      ← 大量:快速、独立、覆盖边界情况
  /────────────\

什么是适合 E2E 测试的内容

适合 E2E 测试 ✓不适合 E2E 测试 ✗
关键用户旅程(登录 → 仪表盘 → 操作 → 登出)单元级逻辑(应使用单元测试)
多步骤流程(结账、引导向导)API 合约(应使用集成测试)
跨浏览器兼容性边界情况(速度太慢,应使用单元测试)
实际 API 集成内部实现细节
认证流程组件视觉状态(应使用 Storybook)

经验法则: 如果某个功能失效会对业务造成严重打击,就用 E2E 测试;如果只是不便,就用更快的单元或集成测试来验证。


核心原则

原则原因实现方式
测试行为,而非实现能够抵御重构影响断言用户可见结果,而非 DOM 结构
独立的测试可并行执行,易于调试每个测试自行创建数据,并在结束后清理
确定性的等待机制避免测试不稳定等待特定条件满足,而非固定超时时间
稳定的选择器能够应对 UI 变更使用 data-testid、角色、标签等,避免使用 CSS 类名
快速反馈开发者可频繁运行模拟外部服务,支持并行与分片执行

Playwright 测试模式

配置文件

// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  timeout: 30000,
  expect: { timeout: 5000 },
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [["html"], ["junit", { outputFile: "results.xml" }]],
  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
    video: "retain-on-failure",
  },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "firefox", use: { ...devices["Desktop Firefox"] } },
    { name: "webkit", use: { ...devices["Desktop Safari"] } },
    { name: "mobile", use: { ...devices["iPhone 13"] } },
  ],
});

模式:页面对象模型

封装页面逻辑,使测试读起来像用户故事。

// pages/LoginPage.ts
import { Page, Locator } from "@playwright/test";

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel("Email");
    this.passwordInput = page.getByLabel("Password");
    this.loginButton = page.getByRole("button", { name: "Login" });
    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.loginButton.click();
  }
}

// tests/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", "password123");

  await expect(page).toHaveURL("/dashboard");
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
});

模式:测试数据的 Fixture

自动创建和清理测试数据。

// fixtures/test-data.ts
import { test as base } from "@playwright/test";

export const test = base.extend<{ testUser: TestUser }>({
  testUser: async ({}, use) => {
    // 设置:创建测试用户
    const user = await createTestUser({
      email: `test-${Date.now()}@example.com`,
      password: "Test123!@#",
    });

    await use(user);

    // 清理:删除测试用户
    await deleteTestUser(user.id);
  },
});

// 使用示例 —— testUser 在测试前创建,测试后删除
test("用户可以更新个人资料", async ({ page, testUser }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(testUser.email);
  // ...
});

模式:智能等待

永远不要使用固定超时。应等待特定条件达成。

// ❌ 不稳定:固定超时
await page.waitForTimeout(3000);

// ✅ 稳定:等待具体条件
await page.waitForLoadState("networkidle");
await page.waitForURL("/dashboard");

// ✅ 最佳实践:自动等待的断言
await expect(page.getByText("欢迎")).toBeVisible();
await expect(page.getByRole("button", { name: "提交" })).toBeEnabled();

// 等待 API 响应
const responsePromise = page.waitForResponse(
  (r) => r.url().includes("/api/users") && r.status() === 200
);
await page.getByRole("button", { name: "加载" }).click();
await responsePromise;

模式:网络模拟

隔离测试与真实外部服务的影响。

# 端到端测试模式

## Playwright 测试示例

test("API 失败时显示错误信息", async ({ page }) => {

// 模拟 API 响应

await page.route("**/api/users", (route) => {

route.fulfill({

status: 500,

body: JSON.stringify({ error: "服务器错误" }),

});

});

await page.goto("/users");

await expect(page.getByText("加载用户失败")).toBeVisible();

});

test("网络缓慢时能正常处理", async ({ page }) => {

await page.route("**/api/data", async (route) => {

await new Promise((r) => setTimeout(r, 3000)); // 模拟延迟

await route.continue();

});

await page.goto("/dashboard");

await expect(page.getByText("加载中...")).toBeVisible();

});

---

## Cypress 测试模式

### 自定义命令

// cypress/support/commands.ts

declare global {

namespace Cypress {

interface Chainable {

login(email: string, password: string): Chainable<void>;

dataCy(value: string): Chainable<JQuery<HTMLElement>>;

}

}

}

Cypress.Commands.add("login", (email, password) => {

cy.visit("/login");

cy.get('[data-testid="email"]').type(email);

cy.get('[data-testid="password"]').type(password);

cy.get('[data-testid="login-button"]').click();

cy.url().should("include", "/dashboard");

});

Cypress.Commands.add("dataCy", (value) => {

return cy.get([data-cy="${value}"]);

});

// 使用方式

cy.login("user@example.com", "password");

cy.dataCy("submit-button").click();

### 网络拦截

// 模拟 API 响应

cy.intercept("GET", "/api/users", {

statusCode: 200,

body: [{ id: 1, name: "John" }],

}).as("getUsers");

cy.visit("/users");

cy.wait("@getUsers");

cy.get('[data-testid="user-list"]').children().should("have.length", 1);

---

## 选择器策略

| 优先级 | 选择器类型 | 示例 | 原因 |
|--------|------------|------|------|
| 1 | **角色 + 名称** | `getByRole("button", { name: "提交" })` | 可访问,面向用户 |
| 2 | **标签** | `getByLabel("邮箱地址")` | 可访问,语义明确 |
| 3 | **data-testid** | `getByTestId("结账表单")` | 稳定,测试专用 |
| 4 | **文本内容** | `getByText("欢迎回来")` | 面向用户 |
| ❌ | CSS 类名 | `.btn-primary` | 易受样式变更影响 |
| ❌ | DOM 结构 | `div > form > input:nth-child(2)` | 任何结构调整都会失效 |

// ❌ 不推荐:脆弱的选择器

cy.get(".btn.btn-primary.submit-button").click();

cy.get("div > form > div:nth-child(2) > input").type("text");

// ✅ 推荐:稳定的选项器

page.getByRole("button", { name: "提交" }).click();

page.getByLabel("邮箱地址").fill("user@example.com");

page.getByTestId("email-input").fill("user@example.com");

---

## 视觉回归测试

// Playwright 视觉对比

test("首页外观正确", async ({ page }) => {

await page.goto("/");

await expect(page).toHaveScreenshot("homepage.png", {

fullPage: true,

maxDiffPixels: 100,

});

});

test("按钮状态", async ({ page }) => {

const button = page.getByRole("button", { name: "提交" });

await expect(button).toHaveScreenshot("button-default.png");

await button.hover();

await expect(button).toHaveScreenshot("button-hover.png");

});

---

## 可访问性测试

// npm install @axe-core/playwright

import AxeBuilder from "@axe-core/playwright";

test("页面无可访问性违规", async ({ page }) => {

await page.goto("/");

const results = await new AxeBuilder({ page })

.exclude("#third-party-widget") // 排除无法控制的内容

.analyze();

expect(results.violations).toEqual([]);

});

---

## 测试失败调试

以有头模式运行(查看浏览器)

npx playwright test --headed

调试模式(逐步执行)

npx playwright test --debug

查看失败测试的报告

npx playwright show-report

// 添加测试步骤以提高失败报告可读性

test("结账流程", async ({ page }) => {

await test.step("将商品加入购物车", async () => {

await page.goto("/products");

await page.getByRole("button", { name: "加入购物车" }).click();

});

await test.step("完成结账", async () => {

await page.goto("/checkout");

// 如果失败,可明确知道是哪个步骤出错

});

});

// 暂停以便手动检查

await page.pause();

---

## 不稳定测试排查清单

当测试间歇性失败时,请检查:

| 问题 | 解决方案 |
|------|----------|
| 固定的 `waitForTimeout()` 调用 | 改为使用 `waitForSelector()` 或断言等待 |
| 页面加载时的竞争条件 | 等待 `networkidle` 或特定元素出现 |
| 测试数据污染 | 确保每个测试创建并清理自己的数据 |
| 动画时间不一致 | 等待动画完成或禁用动画 |
| 视口设置不一致 | 在配置中设置明确的视口尺寸 |
| 测试顺序随机导致的问题 | 测试必须相互独立 |
| 第三方服务不稳定 | 模拟外部 API 响应 |

---

## CI/CD 集成

GitHub Actions 示例

name: 端到端测试

on: [push, pull_request]

jobs:

e2e:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- uses: actions/setup-node@v4

- run: npm ci

- run: npx playwright install --with-deps

- run: npm run build

- run: npm run start & npx wait-on http://localhost:3000

- run: npx playwright test

- uses: actions/upload-artifact@v4

if: failure()

with:

name: playwright-report

path: playwright-report/

---

## 绝对不要这样做

1. **永远不要使用固定的 `waitForTimeout()` 或 `cy.wait(ms)`** — 这会导致测试不稳定并拖慢测试套件
2. **永远不要依赖 CSS 类名或 DOM 结构来选择元素** — 使用角色、标签或 `data-testid`
3. **永远不要在测试之间共享状态** — 每个测试必须完全独立
4. **永远不要测试实现细节** — 只测试用户可见和可操作的行为,而非内部结构
5. **永远不要跳过清理步骤** — 始终删除你创建的测试数据,即使测试失败也要清理
6. **永远不要用端到端测试覆盖所有场景** — 仅用于关键路径;边缘情况使用更快的测试类型
7. **永远不要忽略不稳定的测试** — 立即修复或删除;一个不稳定的测试比没有测试更糟糕
8. **永远不要在选择器中硬编码测试数据** — 使用动态等待处理内容变化的情况

---

## 快速参考

### Playwright 命令

// 导航

await page.goto("/path");

await page.goBack();

await page.reload();

// 交互

await page.click("selector");

await page.fill("selector", "text");

await page.type("selector", "text"); // 逐字符输入

await page.selectOption("select", "value");

await page.check("checkbox");

// 断言

await expect(page).toHaveURL("/expected");

await expect(locator).toBeVisible();

await expect(locator).toHaveText("expected");

await expect(locator).toBeEnabled();

await expect(locator).toHaveCount(3);

### Cypress 命令

// 导航

cy.visit("/path");

cy.go("back");

cy.reload();

// 交互

cy.get("selector").click();

cy.get("selector").type("text");

cy.get("selector").clear().type("text");

cy.get("select").select("value");

cy.get("checkbox").check();

// 断言

cy.url().should("include", "/expected");

cy.get("selector").should("be.visible");

cy.get("selector").should("have.text", "expected");

cy.get("selector").should("have.length", 3);

W
@wpank

已收录 6 个 Skill

相关推荐