e2e-testing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

E2E Testing

端到端(E2E)测试

When to Use

适用场景

Activate this skill when:
  • Writing E2E tests for complete user workflows (login, CRUD operations, multi-page flows)
  • Creating critical path regression tests that validate the full stack
  • Testing cross-browser compatibility (Chromium, Firefox, WebKit)
  • Validating authentication flows end-to-end
  • Testing file upload/download workflows
  • Writing smoke tests for deployment verification
Do NOT use this skill for:
  • React component unit tests (use
    react-testing-patterns
    )
  • Python backend unit/integration tests (use
    pytest-patterns
    )
  • TDD workflow enforcement (use
    tdd-workflow
    )
  • API contract testing without a browser (use
    pytest-patterns
    with httpx)
在以下场景中使用本技能:
  • 为完整用户流程(登录、CRUD操作、多页面流程)编写E2E测试
  • 创建验证全栈的关键路径回归测试
  • 测试跨浏览器兼容性(Chromium、Firefox、WebKit)
  • 端到端验证认证流程
  • 测试文件上传/下载工作流
  • 编写用于部署验证的冒烟测试
请勿在以下场景中使用本技能:
  • React组件单元测试(请使用
    react-testing-patterns
  • Python后端单元/集成测试(请使用
    pytest-patterns
  • TDD工作流实施(请使用
    tdd-workflow
  • 无浏览器的API契约测试(请结合httpx使用
    pytest-patterns

Instructions

操作指南

Test Structure

测试结构

e2e/
├── playwright.config.ts         # Global Playwright configuration
├── fixtures/
│   ├── auth.fixture.ts          # Authentication state setup
│   └── test-data.fixture.ts     # Test data creation/cleanup
├── pages/
│   ├── base.page.ts             # Base page object with shared methods
│   ├── login.page.ts            # Login page object
│   ├── users.page.ts            # Users list page object
│   └── user-detail.page.ts     # User detail page object
├── tests/
│   ├── auth/
│   │   ├── login.spec.ts
│   │   └── logout.spec.ts
│   ├── users/
│   │   ├── create-user.spec.ts
│   │   ├── edit-user.spec.ts
│   │   └── list-users.spec.ts
│   └── smoke/
│       └── critical-paths.spec.ts
└── utils/
    ├── api-helpers.ts           # Direct API calls for test setup
    └── test-constants.ts        # Shared constants
Naming conventions:
  • Test files:
    <feature>.spec.ts
  • Page objects:
    <page-name>.page.ts
  • Fixtures:
    <concern>.fixture.ts
  • Test names: human-readable sentences describing the user action and expected outcome
e2e/
├── playwright.config.ts         # Global Playwright configuration
├── fixtures/
│   ├── auth.fixture.ts          # Authentication state setup
│   └── test-data.fixture.ts     # Test data creation/cleanup
├── pages/
│   ├── base.page.ts             # Base page object with shared methods
│   ├── login.page.ts            # Login page object
│   ├── users.page.ts            # Users list page object
│   └── user-detail.page.ts     # User detail page object
├── tests/
│   ├── auth/
│   │   ├── login.spec.ts
│   │   └── logout.spec.ts
│   ├── users/
│   │   ├── create-user.spec.ts
│   │   ├── edit-user.spec.ts
│   │   └── list-users.spec.ts
│   └── smoke/
│       └── critical-paths.spec.ts
└── utils/
    ├── api-helpers.ts           # Direct API calls for test setup
    └── test-constants.ts        # Shared constants
命名规范:
  • 测试文件:
    <feature>.spec.ts
  • 页面对象:
    <page-name>.page.ts
  • 夹具:
    <concern>.fixture.ts
  • 测试名称:描述用户操作和预期结果的易读性语句

Page Object Model

页面对象模型

Every page gets a page object class that encapsulates selectors and actions. Tests never interact with selectors directly.
Base page object:
typescript
// e2e/pages/base.page.ts
import { type Page, type Locator } from "@playwright/test";

export abstract class BasePage {
  constructor(protected readonly page: Page) {}

  /** Navigate to the page's URL. */
  abstract goto(): Promise<void>;

  /** Wait for the page to be fully loaded. */
  async waitForLoad(): Promise<void> {
    await this.page.waitForLoadState("networkidle");
  }

  /** Get a toast/notification message. */
  get toast(): Locator {
    return this.page.getByRole("alert");
  }

  /** Get the page heading. */
  get heading(): Locator {
    return this.page.getByRole("heading", { level: 1 });
  }
}
Concrete page object:
typescript
// e2e/pages/users.page.ts
import { type Page, type Locator } from "@playwright/test";
import { BasePage } from "./base.page";

export class UsersPage extends BasePage {
  // ─── Locators ─────────────────────────────────────────
  readonly createButton: Locator;
  readonly searchInput: Locator;
  readonly userTable: Locator;

  constructor(page: Page) {
    super(page);
    this.createButton = page.getByTestId("create-user-btn");
    this.searchInput = page.getByRole("searchbox", { name: /search users/i });
    this.userTable = page.getByRole("table");
  }

  // ─── Actions ──────────────────────────────────────────
  async goto(): Promise<void> {
    await this.page.goto("/users");
    await this.waitForLoad();
  }

  async searchFor(query: string): Promise<void> {
    await this.searchInput.fill(query);
    // Wait for search results to update (debounced)
    await this.page.waitForResponse("**/api/v1/users?*");
  }

  async clickCreateUser(): Promise<void> {
    await this.createButton.click();
  }

  async getUserRow(email: string): Promise<Locator> {
    return this.userTable.getByRole("row").filter({ hasText: email });
  }

  async getUserCount(): Promise<number> {
    // Subtract 1 for header row
    return (await this.userTable.getByRole("row").count()) - 1;
  }
}
Rules for page objects:
  • One page object per page or major UI section
  • Locators are public readonly properties
  • Actions are async methods
  • Page objects never contain assertions -- tests assert
  • Page objects handle waits internally after actions
每个页面对应一个页面对象类,封装选择器和操作。测试从不直接与选择器交互。
基础页面对象:
typescript
// e2e/pages/base.page.ts
import { type Page, type Locator } from "@playwright/test";

export abstract class BasePage {
  constructor(protected readonly page: Page) {}

  /** Navigate to the page's URL. */
  abstract goto(): Promise<void>;

  /** Wait for the page to be fully loaded. */
  async waitForLoad(): Promise<void> {
    await this.page.waitForLoadState("networkidle");
  }

  /** Get a toast/notification message. */
  get toast(): Locator {
    return this.page.getByRole("alert");
  }

  /** Get the page heading. */
  get heading(): Locator {
    return this.page.getByRole("heading", { level: 1 });
  }
}
具体页面对象:
typescript
// e2e/pages/users.page.ts
import { type Page, type Locator } from "@playwright/test";
import { BasePage } from "./base.page";

export class UsersPage extends BasePage {
  // ─── Locators ─────────────────────────────────────────
  readonly createButton: Locator;
  readonly searchInput: Locator;
  readonly userTable: Locator;

  constructor(page: Page) {
    super(page);
    this.createButton = page.getByTestId("create-user-btn");
    this.searchInput = page.getByRole("searchbox", { name: /search users/i });
    this.userTable = page.getByRole("table");
  }

  // ─── Actions ──────────────────────────────────────────
  async goto(): Promise<void> {
    await this.page.goto("/users");
    await this.waitForLoad();
  }

  async searchFor(query: string): Promise<void> {
    await this.searchInput.fill(query);
    // Wait for search results to update (debounced)
    await this.page.waitForResponse("**/api/v1/users?*");
  }

  async clickCreateUser(): Promise<void> {
    await this.createButton.click();
  }

  async getUserRow(email: string): Promise<Locator> {
    return this.userTable.getByRole("row").filter({ hasText: email });
  }

  async getUserCount(): Promise<number> {
    // Subtract 1 for header row
    return (await this.userTable.getByRole("row").count()) - 1;
  }
}
页面对象规则:
  • 每个页面或主要UI区域对应一个页面对象
  • 选择器为公共只读属性
  • 操作为异步方法
  • 页面对象从不包含断言——断言逻辑由测试代码实现
  • 页面对象在操作后内部处理等待逻辑

Selector Strategy

选择器策略

Priority order (highest to lowest):
PrioritySelectorExampleWhen to Use
1
data-testid
getByTestId("submit-btn")
Interactive elements, dynamic content
2Role
getByRole("button", { name: /save/i })
Buttons, links, headings, inputs
3Label
getByLabel("Email")
Form inputs with labels
4Placeholder
getByPlaceholder("Search...")
Search inputs
5Text
getByText("Welcome back")
Static text content
NEVER use:
  • CSS selectors (
    .class-name
    ,
    #id
    ) -- brittle, break on styling changes
  • XPath (
    //div[@class="foo"]
    ) -- unreadable, extremely brittle
  • DOM structure selectors (
    div > span:nth-child(2)
    ) -- break on layout changes
Adding data-testid attributes:
tsx
// In React components -- add data-testid to interactive elements
<button data-testid="create-user-btn" onClick={handleCreate}>
  Create User
</button>

// Convention: kebab-case, descriptive
// Pattern: <action>-<entity>-<element-type>
// Examples: create-user-btn, user-email-input, delete-confirm-dialog
优先级顺序(从高到低):
优先级选择器示例适用场景
1
data-testid
getByTestId("submit-btn")
交互元素、动态内容
2角色
getByRole("button", { name: /save/i })
按钮、链接、标题、输入框
3标签
getByLabel("Email")
带标签的表单输入框
4占位符
getByPlaceholder("Search...")
搜索输入框
5文本
getByText("Welcome back")
静态文本内容
禁止使用:
  • CSS选择器(
    .class-name
    ,
    #id
    )——易失效,样式变更时会中断
  • XPath(
    //div[@class="foo"]
    )——可读性差,极易失效
  • DOM结构选择器(
    div > span:nth-child(2)
    )——布局变更时会中断
添加data-testid属性:
tsx
// In React components -- add data-testid to interactive elements
<button data-testid="create-user-btn" onClick={handleCreate}>
  Create User
</button>

// Convention: kebab-case, descriptive
// Pattern: <action>-<entity>-<element-type>
// Examples: create-user-btn, user-email-input, delete-confirm-dialog

Wait Strategies

等待策略

NEVER use hardcoded waits:
typescript
// BAD: Hardcoded wait -- flaky, slow
await page.waitForTimeout(3000);

// BAD: Sleep
await new Promise((resolve) => setTimeout(resolve, 2000));
Use explicit wait conditions:
typescript
// GOOD: Wait for a specific element to appear
await page.getByRole("heading", { name: "Dashboard" }).waitFor();

// GOOD: Wait for navigation
await page.waitForURL("/dashboard");

// GOOD: Wait for API response
await page.waitForResponse(
  (response) =>
    response.url().includes("/api/v1/users") && response.status() === 200,
);

// GOOD: Wait for network to settle
await page.waitForLoadState("networkidle");

// GOOD: Wait for element state
await page.getByTestId("submit-btn").waitFor({ state: "visible" });
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
Auto-waiting: Playwright auto-waits for elements to be actionable before clicking, filling, etc. Explicit waits are needed only for assertions or complex state transitions.
禁止使用硬编码等待:
typescript
// BAD: Hardcoded wait -- flaky, slow
await page.waitForTimeout(3000);

// BAD: Sleep
await new Promise((resolve) => setTimeout(resolve, 2000));
使用显式等待条件:
typescript
// GOOD: Wait for a specific element to appear
await page.getByRole("heading", { name: "Dashboard" }).waitFor();

// GOOD: Wait for navigation
await page.waitForURL("/dashboard");

// GOOD: Wait for API response
await page.waitForResponse(
  (response) =>
    response.url().includes("/api/v1/users") && response.status() === 200,
);

// GOOD: Wait for network to settle
await page.waitForLoadState("networkidle");

// GOOD: Wait for element state
await page.getByTestId("submit-btn").waitFor({ state: "visible" });
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
自动等待: Playwright会自动等待元素可操作后再执行点击、填充等操作。仅在断言或复杂状态转换时需要显式等待。

Auth State Reuse

认证状态复用

Avoid logging in before every test. Save auth state and reuse it.
Setup auth state once:
typescript
// e2e/fixtures/auth.fixture.ts
import { test as base } from "@playwright/test";
import path from "path";

const AUTH_STATE_PATH = path.resolve("e2e/.auth/user.json");

export const setup = base.extend({});

setup("authenticate", async ({ page }) => {
  // Perform real login
  await page.goto("/login");
  await page.getByLabel("Email").fill("testuser@example.com");
  await page.getByLabel("Password").fill("TestPassword123!");
  await page.getByRole("button", { name: /sign in/i }).click();

  // Wait for auth to complete
  await page.waitForURL("/dashboard");

  // Save signed-in state
  await page.context().storageState({ path: AUTH_STATE_PATH });
});
Reuse in tests:
typescript
// playwright.config.ts
export default defineConfig({
  projects: [
    // Setup project runs first and saves auth state
    { name: "setup", testDir: "./e2e/fixtures", testMatch: "auth.fixture.ts" },
    {
      name: "chromium",
      use: {
        storageState: "e2e/.auth/user.json",  // Reuse auth state
      },
      dependencies: ["setup"],
    },
  ],
});
避免在每个测试前重复登录。保存认证状态并复用。
一次性设置认证状态:
typescript
// e2e/fixtures/auth.fixture.ts
import { test as base } from "@playwright/test";
import path from "path";

const AUTH_STATE_PATH = path.resolve("e2e/.auth/user.json");

export const setup = base.extend({});

setup("authenticate", async ({ page }) => {
  // Perform real login
  await page.goto("/login");
  await page.getByLabel("Email").fill("testuser@example.com");
  await page.getByLabel("Password").fill("TestPassword123!");
  await page.getByRole("button", { name: /sign in/i }).click();

  // Wait for auth to complete
  await page.waitForURL("/dashboard");

  // Save signed-in state
  await page.context().storageState({ path: AUTH_STATE_PATH });
});
在测试中复用:
typescript
// playwright.config.ts
export default defineConfig({
  projects: [
    // Setup project runs first and saves auth state
    { name: "setup", testDir: "./e2e/fixtures", testMatch: "auth.fixture.ts" },
    {
      name: "chromium",
      use: {
        storageState: "e2e/.auth/user.json",  // Reuse auth state
      },
      dependencies: ["setup"],
    },
  ],
});

Test Data Management

测试数据管理

Principles:
  • Tests create their own data (never depend on pre-existing data)
  • Tests clean up after themselves (or use API to reset)
  • Use API calls for setup, not UI interactions (faster, more reliable)
API helpers for test data:
typescript
// e2e/utils/api-helpers.ts
import { type APIRequestContext } from "@playwright/test";

export class TestDataAPI {
  constructor(private request: APIRequestContext) {}

  async createUser(data: { email: string; displayName: string }) {
    const response = await this.request.post("/api/v1/users", { data });
    return response.json();
  }

  async deleteUser(userId: number) {
    await this.request.delete(`/api/v1/users/${userId}`);
  }

  async createOrder(userId: number, items: Array<Record<string, unknown>>) {
    const response = await this.request.post("/api/v1/orders", {
      data: { user_id: userId, items },
    });
    return response.json();
  }
}
Usage in tests:
typescript
test("edit user name", async ({ page, request }) => {
  const api = new TestDataAPI(request);

  // Setup: create user via API (fast)
  const user = await api.createUser({
    email: "edit-test@example.com",
    displayName: "Before Edit",
  });

  try {
    // Test: edit via UI
    const usersPage = new UsersPage(page);
    await usersPage.goto();
    // ... perform edit via UI ...
  } finally {
    // Cleanup: remove test data
    await api.deleteUser(user.id);
  }
});
原则:
  • 测试自行创建数据(绝不依赖预存在的数据)
  • 测试完成后清理数据(或通过API重置)
  • 使用API调用进行初始化,而非UI交互(更快、更可靠)
用于测试数据的API助手:
typescript
// e2e/utils/api-helpers.ts
import { type APIRequestContext } from "@playwright/test";

export class TestDataAPI {
  constructor(private request: APIRequestContext) {}

  async createUser(data: { email: string; displayName: string }) {
    const response = await this.request.post("/api/v1/users", { data });
    return response.json();
  }

  async deleteUser(userId: number) {
    await this.request.delete(`/api/v1/users/${userId}`);
  }

  async createOrder(userId: number, items: Array<Record<string, unknown>>) {
    const response = await this.request.post("/api/v1/orders", {
      data: { user_id: userId, items },
    });
    return response.json();
  }
}
在测试中使用:
typescript
test("edit user name", async ({ page, request }) => {
  const api = new TestDataAPI(request);

  // Setup: create user via API (fast)
  const user = await api.createUser({
    email: "edit-test@example.com",
    displayName: "Before Edit",
  });

  try {
    // Test: edit via UI
    const usersPage = new UsersPage(page);
    await usersPage.goto();
    // ... perform edit via UI ...
  } finally {
    // Cleanup: remove test data
    await api.deleteUser(user.id);
  }
});

Debugging Flaky Tests

调试不稳定测试

1. Use trace viewer for failures:
typescript
// playwright.config.ts
use: {
  trace: "on-first-retry",  // Capture trace only on retry
}
View trace:
npx playwright show-trace trace.zip
2. Run in headed mode for debugging:
bash
npx playwright test --headed --debug tests/users/create-user.spec.ts
3. Common causes of flaky tests:
CauseFix
Hardcoded waitsUse explicit wait conditions
Shared test dataEach test creates its own data
Animation interferenceSet
animations: "disabled"
in config
Race conditionsWait for API responses before assertions
Viewport-dependent behaviorSet explicit viewport in config
Session leaks between testsUse
storageState
correctly, clear cookies
4. Retry strategy:
typescript
// playwright.config.ts
export default defineConfig({
  retries: process.env.CI ? 2 : 0,  // Retry in CI only
});
1. 使用追踪查看器分析失败:
typescript
// playwright.config.ts
use: {
  trace: "on-first-retry",  // Capture trace only on retry
}
查看追踪:
npx playwright show-trace trace.zip
2. 使用有头模式调试:
bash
npx playwright test --headed --debug tests/users/create-user.spec.ts
3. 不稳定测试的常见原因:
原因修复方案
硬编码等待使用显式等待条件
共享测试数据每个测试自行创建独立数据
动画干扰在配置中设置
animations: "disabled"
竞态条件断言前等待API响应
视口依赖行为在配置中设置固定视口
测试间会话泄漏正确使用
storageState
,清除Cookie
4. 重试策略:
typescript
// playwright.config.ts
export default defineConfig({
  retries: process.env.CI ? 2 : 0,  // Retry in CI only
});

CI Configuration

CI配置

yaml
undefined
yaml
undefined

.github/workflows/e2e.yml

.github/workflows/e2e.yml

name: E2E Tests on: push: branches: [main] pull_request: branches: [main]
jobs: e2e: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm
  - name: Install dependencies
    run: npm ci

  - name: Install Playwright browsers
    run: npx playwright install --with-deps chromium

  - name: Start application
    run: |
      docker compose up -d
      npx wait-on http://localhost:3000 --timeout 60000

  - name: Run E2E tests
    run: npx playwright test

  - name: Upload test report
    if: always()
    uses: actions/upload-artifact@v4
    with:
      name: playwright-report
      path: playwright-report/
      retention-days: 14

  - name: Upload traces on failure
    if: failure()
    uses: actions/upload-artifact@v4
    with:
      name: test-traces
      path: test-results/

Use `scripts/run-e2e-with-report.sh` to run Playwright with HTML report output locally.
name: E2E Tests on: push: branches: [main] pull_request: branches: [main]
jobs: e2e: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm
  - name: Install dependencies
    run: npm ci

  - name: Install Playwright browsers
    run: npx playwright install --with-deps chromium

  - name: Start application
    run: |
      docker compose up -d
      npx wait-on http://localhost:3000 --timeout 60000

  - name: Run E2E tests
    run: npx playwright test

  - name: Upload test report
    if: always()
    uses: actions/upload-artifact@v4
    with:
      name: playwright-report
      path: playwright-report/
      retention-days: 14

  - name: Upload traces on failure
    if: failure()
    uses: actions/upload-artifact@v4
    with:
      name: test-traces
      path: test-results/

使用`scripts/run-e2e-with-report.sh`在本地运行Playwright并生成HTML测试报告。

Examples

示例

See
references/page-object-template.ts
for annotated page object class. See
references/e2e-test-template.ts
for annotated E2E test. See
references/playwright-config-example.ts
for production Playwright config.
请查看
references/page-object-template.ts
获取带注释的页面对象类示例。 请查看
references/e2e-test-template.ts
获取带注释的E2E测试示例。 请查看
references/playwright-config-example.ts
获取生产环境Playwright配置示例。