e2e-testing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

E2E Testing Patterns

E2E测试模式

Build reliable, fast, and maintainable end-to-end test suites that provide confidence to ship code quickly and catch regressions before users do.
构建可靠、快速且易于维护的端到端测试套件,让你能够自信地快速交付代码,并在用户发现之前捕获回归问题。

When to Use This Skill

何时使用该技能

  • Implementing end-to-end test automation
  • Debugging flaky or unreliable tests
  • Testing critical user workflows
  • Setting up CI/CD test pipelines
  • Testing across multiple browsers
  • Validating accessibility requirements
  • Testing responsive designs
  • Establishing E2E testing standards
  • 实施端到端测试自动化
  • 调试不稳定或不可靠的测试
  • 测试关键用户工作流
  • 搭建CI/CD测试流水线
  • 跨多浏览器测试
  • 验证无障碍访问要求
  • 测试响应式设计
  • 建立E2E测试标准

Core Concepts

核心概念

1. E2E Testing Fundamentals

1. E2E测试基础

What to Test with E2E:
  • Critical user journeys (login, checkout, signup)
  • Complex interactions (drag-and-drop, multi-step forms)
  • Cross-browser compatibility
  • Real API integration
  • Authentication flows
What NOT to Test with E2E:
  • Unit-level logic (use unit tests)
  • API contracts (use integration tests)
  • Edge cases (too slow)
  • Internal implementation details
E2E测试的适用场景:
  • 关键用户旅程(登录、结账、注册)
  • 复杂交互(拖拽、多步骤表单)
  • 跨浏览器兼容性
  • 真实API集成
  • 认证流程
E2E测试的不适用场景:
  • 单元级逻辑(使用单元测试)
  • API契约(使用集成测试)
  • 边缘情况(速度太慢)
  • 内部实现细节

2. Test Philosophy

2. 测试理念

The Testing Pyramid:
        /\
       /E2E\         ← Few, focused on critical paths
      /─────\
     /Integr\        ← More, test component interactions
    /────────\
   /Unit Tests\      ← Many, fast, isolated
  /────────────\
Best Practices:
  • Test user behavior, not implementation
  • Keep tests independent
  • Make tests deterministic
  • Optimize for speed
  • Use data-testid, not CSS selectors
测试金字塔:
        /\
       /E2E\         ← 少量,聚焦关键路径
      /─────\
     /Integr\        ← 较多,测试组件交互
    /────────\
   /Unit Tests\      ← 大量,快速、独立
  /────────────\
最佳实践:
  • 测试用户行为,而非实现细节
  • 保持测试独立
  • 确保测试具有确定性
  • 优化测试速度
  • 使用data-testid,而非CSS选择器

Playwright Patterns

Playwright模式

Setup and Configuration

配置与设置

typescript
// 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"] } },
  ],
});
typescript
// 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"] } },
  ],
});

Pattern 1: Page Object Model

模式1:页面对象模型

typescript
// 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();
  }

  async getErrorMessage(): Promise<string> {
    return (await this.errorMessage.textContent()) ?? "";
  }
}

// Test using Page Object
import { test, expect } from "@playwright/test";
import { LoginPage } from "./pages/LoginPage";

test("successful login", 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();
});

test("failed login shows error", async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login("invalid@example.com", "wrong");

  const error = await loginPage.getErrorMessage();
  expect(error).toContain("Invalid credentials");
});
typescript
// 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();
  }

  async getErrorMessage(): Promise<string> {
    return (await this.errorMessage.textContent()) ?? "";
  }
}

// 使用页面对象的测试
import { test, expect } from "@playwright/test";
import { LoginPage } from "./pages/LoginPage";

test("successful login", 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();
});

test("failed login shows error", async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login("invalid@example.com", "wrong");

  const error = await loginPage.getErrorMessage();
  expect(error).toContain("Invalid credentials");
});

Pattern 2: Fixtures for Test Data

模式2:测试数据的Fixtures

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

type TestData = {
  testUser: {
    email: string;
    password: string;
    name: string;
  };
  adminUser: {
    email: string;
    password: string;
  };
};

export const test = base.extend<TestData>({
  testUser: async ({}, use) => {
    const user = {
      email: `test-${Date.now()}@example.com`,
      password: "Test123!@#",
      name: "Test User",
    };
    // Setup: Create user in database
    await createTestUser(user);
    await use(user);
    // Teardown: Clean up user
    await deleteTestUser(user.email);
  },

  adminUser: async ({}, use) => {
    await use({
      email: "admin@example.com",
      password: process.env.ADMIN_PASSWORD!,
    });
  },
});

// Usage in tests
import { test } from "./fixtures/test-data";

test("user can update profile", async ({ page, testUser }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(testUser.email);
  await page.getByLabel("Password").fill(testUser.password);
  await page.getByRole("button", { name: "Login" }).click();

  await page.goto("/profile");
  await page.getByLabel("Name").fill("Updated Name");
  await page.getByRole("button", { name: "Save" }).click();

  await expect(page.getByText("Profile updated")).toBeVisible();
});
typescript
// fixtures/test-data.ts
import { test as base } from "@playwright/test";

type TestData = {
  testUser: {
    email: string;
    password: string;
    name: string;
  };
  adminUser: {
    email: string;
    password: string;
  };
};

export const test = base.extend<TestData>({
  testUser: async ({}, use) => {
    const user = {
      email: `test-${Date.now()}@example.com`,
      password: "Test123!@#",
      name: "Test User",
    };
    // 前置操作:在数据库中创建测试用户
    await createTestUser(user);
    await use(user);
    // 后置操作:清理测试用户
    await deleteTestUser(user.email);
  },

  adminUser: async ({}, use) => {
    await use({
      email: "admin@example.com",
      password: process.env.ADMIN_PASSWORD!,
    });
  },
});

// 在测试中使用
import { test } from "./fixtures/test-data";

test("user can update profile", async ({ page, testUser }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(testUser.email);
  await page.getByLabel("Password").fill(testUser.password);
  await page.getByRole("button", { name: "Login" }).click();

  await page.goto("/profile");
  await page.getByLabel("Name").fill("Updated Name");
  await page.getByRole("button", { name: "Save" }).click();

  await expect(page.getByText("Profile updated")).toBeVisible();
});

Pattern 3: Waiting Strategies

模式3:等待策略

typescript
// ❌ Bad: Fixed timeouts
await page.waitForTimeout(3000); // Flaky!

// ✅ Good: Wait for specific conditions
await page.waitForLoadState("networkidle");
await page.waitForURL("/dashboard");
await page.waitForSelector('[data-testid="user-profile"]');

// ✅ Better: Auto-waiting with assertions
await expect(page.getByText("Welcome")).toBeVisible();
await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled();

// Wait for API response
const responsePromise = page.waitForResponse(
  (response) =>
    response.url().includes("/api/users") && response.status() === 200,
);
await page.getByRole("button", { name: "Load Users" }).click();
const response = await responsePromise;
const data = await response.json();
expect(data.users).toHaveLength(10);

// Wait for multiple conditions
await Promise.all([
  page.waitForURL("/success"),
  page.waitForLoadState("networkidle"),
  expect(page.getByText("Payment successful")).toBeVisible(),
]);
typescript
// ❌ 错误做法:固定超时
await page.waitForTimeout(3000); // 不稳定!

// ✅ 正确做法:等待特定条件
await page.waitForLoadState("networkidle");
await page.waitForURL("/dashboard");
await page.waitForSelector('[data-testid="user-profile"]');

// ✅ 更优做法:结合断言自动等待
await expect(page.getByText("Welcome")).toBeVisible();
await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled();

// 等待API响应
const responsePromise = page.waitForResponse(
  (response) =>
    response.url().includes("/api/users") && response.status() === 200,
);
await page.getByRole("button", { name: "Load Users" }).click();
const response = await responsePromise;
const data = await response.json();
expect(data.users).toHaveLength(10);

// 等待多个条件
await Promise.all([
  page.waitForURL("/success"),
  page.waitForLoadState("networkidle"),
  expect(page.getByText("Payment successful")).toBeVisible(),
]);

Pattern 4: Network Mocking and Interception

模式4:网络模拟与拦截

typescript
// Mock API responses
test("displays error when API fails", async ({ page }) => {
  await page.route("**/api/users", (route) => {
    route.fulfill({
      status: 500,
      contentType: "application/json",
      body: JSON.stringify({ error: "Internal Server Error" }),
    });
  });

  await page.goto("/users");
  await expect(page.getByText("Failed to load users")).toBeVisible();
});

// Intercept and modify requests
test("can modify API request", async ({ page }) => {
  await page.route("**/api/users", async (route) => {
    const request = route.request();
    const postData = JSON.parse(request.postData() || "{}");

    // Modify request
    postData.role = "admin";

    await route.continue({
      postData: JSON.stringify(postData),
    });
  });

  // Test continues...
});

// Mock third-party services
test("payment flow with mocked Stripe", async ({ page }) => {
  await page.route("**/api/stripe/**", (route) => {
    route.fulfill({
      status: 200,
      body: JSON.stringify({
        id: "mock_payment_id",
        status: "succeeded",
      }),
    });
  });

  // Test payment flow with mocked response
});
typescript
// 模拟API响应
test("API失败时显示错误", async ({ page }) => {
  await page.route("**/api/users", (route) => {
    route.fulfill({
      status: 500,
      contentType: "application/json",
      body: JSON.stringify({ error: "Internal Server Error" }),
    });
  });

  await page.goto("/users");
  await expect(page.getByText("Failed to load users")).toBeVisible();
});

// 拦截并修改请求
test("可以修改API请求", async ({ page }) => {
  await page.route("**/api/users", async (route) => {
    const request = route.request();
    const postData = JSON.parse(request.postData() || "{}");

    // 修改请求
    postData.role = "admin";

    await route.continue({
      postData: JSON.stringify(postData),
    });
  });

  // 测试继续...
});

// 模拟第三方服务
test("使用模拟Stripe的支付流程", async ({ page }) => {
  await page.route("**/api/stripe/**", (route) => {
    route.fulfill({
      status: 200,
      body: JSON.stringify({
        id: "mock_payment_id",
        status: "succeeded",
      }),
    });
  });

  // 使用模拟响应测试支付流程
});

Cypress Patterns

Cypress模式

Setup and Configuration

配置与设置

typescript
// cypress.config.ts
import { defineConfig } from "cypress";

export default defineConfig({
  e2e: {
    baseUrl: "http://localhost:3000",
    viewportWidth: 1280,
    viewportHeight: 720,
    video: false,
    screenshotOnRunFailure: true,
    defaultCommandTimeout: 10000,
    requestTimeout: 10000,
    setupNodeEvents(on, config) {
      // Implement node event listeners
    },
  },
});
typescript
// cypress.config.ts
import { defineConfig } from "cypress";

export default defineConfig({
  e2e: {
    baseUrl: "http://localhost:3000",
    viewportWidth: 1280,
    viewportHeight: 720,
    video: false,
    screenshotOnRunFailure: true,
    defaultCommandTimeout: 10000,
    requestTimeout: 10000,
    setupNodeEvents(on, config) {
      // 实现节点事件监听器
    },
  },
});

Pattern 1: Custom Commands

模式1:自定义命令

typescript
// cypress/support/commands.ts
declare global {
  namespace Cypress {
    interface Chainable {
      login(email: string, password: string): Chainable<void>;
      createUser(userData: UserData): Chainable<User>;
      dataCy(value: string): Chainable<JQuery<HTMLElement>>;
    }
  }
}

Cypress.Commands.add("login", (email: string, password: string) => {
  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("createUser", (userData: UserData) => {
  return cy.request("POST", "/api/users", userData).its("body");
});

Cypress.Commands.add("dataCy", (value: string) => {
  return cy.get(`[data-cy="${value}"]`);
});

// Usage
cy.login("user@example.com", "password");
cy.dataCy("submit-button").click();
typescript
// cypress/support/commands.ts
declare global {
  namespace Cypress {
    interface Chainable {
      login(email: string, password: string): Chainable<void>;
      createUser(userData: UserData): Chainable<User>;
      dataCy(value: string): Chainable<JQuery<HTMLElement>>;
    }
  }
}

Cypress.Commands.add("login", (email: string, password: string) => {
  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("createUser", (userData: UserData) => {
  return cy.request("POST", "/api/users", userData).its("body");
});

Cypress.Commands.add("dataCy", (value: string) => {
  return cy.get(`[data-cy="${value}"]`);
});

// 使用示例
cy.login("user@example.com", "password");
cy.dataCy("submit-button").click();

Pattern 2: Cypress Intercept

模式2:Cypress拦截

typescript
// Mock API calls
cy.intercept("GET", "/api/users", {
  statusCode: 200,
  body: [
    { id: 1, name: "John" },
    { id: 2, name: "Jane" },
  ],
}).as("getUsers");

cy.visit("/users");
cy.wait("@getUsers");
cy.get('[data-testid="user-list"]').children().should("have.length", 2);

// Modify responses
cy.intercept("GET", "/api/users", (req) => {
  req.reply((res) => {
    // Modify response
    res.body.users = res.body.users.slice(0, 5);
    res.send();
  });
});

// Simulate slow network
cy.intercept("GET", "/api/data", (req) => {
  req.reply((res) => {
    res.delay(3000); // 3 second delay
    res.send();
  });
});
typescript
// 模拟API调用
cy.intercept("GET", "/api/users", {
  statusCode: 200,
  body: [
    { id: 1, name: "John" },
    { id: 2, name: "Jane" },
  ],
}).as("getUsers");

cy.visit("/users");
cy.wait("@getUsers");
cy.get('[data-testid="user-list"]').children().should("have.length", 2);

// 修改响应
cy.intercept("GET", "/api/users", (req) => {
  req.reply((res) => {
    // 修改响应
    res.body.users = res.body.users.slice(0, 5);
    res.send();
  });
});

// 模拟慢速网络
cy.intercept("GET", "/api/data", (req) => {
  req.reply((res) => {
    res.delay(3000); // 3秒延迟
    res.send();
  });
});

Advanced Patterns

高级模式

Pattern 1: Visual Regression Testing

模式1:视觉回归测试

typescript
// With Playwright
import { test, expect } from "@playwright/test";

test("homepage looks correct", async ({ page }) => {
  await page.goto("/");
  await expect(page).toHaveScreenshot("homepage.png", {
    fullPage: true,
    maxDiffPixels: 100,
  });
});

test("button in all states", async ({ page }) => {
  await page.goto("/components");

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

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

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

  // Disabled state
  await button.evaluate((el) => el.setAttribute("disabled", "true"));
  await expect(button).toHaveScreenshot("button-disabled.png");
});
typescript
// 使用Playwright
import { test, expect } from "@playwright/test";

test("首页显示正常", async ({ page }) => {
  await page.goto("/");
  await expect(page).toHaveScreenshot("homepage.png", {
    fullPage: true,
    maxDiffPixels: 100,
  });
});

test("按钮的所有状态", async ({ page }) => {
  await page.goto("/components");

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

  // 默认状态
  await expect(button).toHaveScreenshot("button-default.png");

  // 悬停状态
  await button.hover();
  await expect(button).toHaveScreenshot("button-hover.png");

  // 禁用状态
  await button.evaluate((el) => el.setAttribute("disabled", "true"));
  await expect(button).toHaveScreenshot("button-disabled.png");
});

Pattern 2: Parallel Testing with Sharding

模式2:分片并行测试

typescript
// playwright.config.ts
export default defineConfig({
  projects: [
    {
      name: "shard-1",
      use: { ...devices["Desktop Chrome"] },
      grepInvert: /@slow/,
      shard: { current: 1, total: 4 },
    },
    {
      name: "shard-2",
      use: { ...devices["Desktop Chrome"] },
      shard: { current: 2, total: 4 },
    },
    // ... more shards
  ],
});

// Run in CI
// npx playwright test --shard=1/4
// npx playwright test --shard=2/4
typescript
// playwright.config.ts
export default defineConfig({
  projects: [
    {
      name: "shard-1",
      use: { ...devices["Desktop Chrome"] },
      grepInvert: /@slow/,
      shard: { current: 1, total: 4 },
    },
    {
      name: "shard-2",
      use: { ...devices["Desktop Chrome"] },
      shard: { current: 2, total: 4 },
    },
    // ... 更多分片
  ],
});

// 在CI中运行
// npx playwright test --shard=1/4
// npx playwright test --shard=2/4

Pattern 3: Accessibility Testing

模式3:无障碍访问测试

typescript
// Install: npm install @axe-core/playwright
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("page should not have accessibility violations", async ({ page }) => {
  await page.goto("/");

  const accessibilityScanResults = await new AxeBuilder({ page })
    .exclude("#third-party-widget")
    .analyze();

  expect(accessibilityScanResults.violations).toEqual([]);
});

test("form is accessible", async ({ page }) => {
  await page.goto("/signup");

  const results = await new AxeBuilder({ page }).include("form").analyze();

  expect(results.violations).toEqual([]);
});
typescript
// 安装:npm install @axe-core/playwright
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("页面应无无障碍访问违规", async ({ page }) => {
  await page.goto("/");

  const accessibilityScanResults = await new AxeBuilder({ page })
    .exclude("#third-party-widget")
    .analyze();

  expect(accessibilityScanResults.violations).toEqual([]);
});

test("表单符合无障碍访问要求", async ({ page }) => {
  await page.goto("/signup");

  const results = await new AxeBuilder({ page }).include("form").analyze();

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

Best Practices

最佳实践

  1. Use Data Attributes:
    data-testid
    or
    data-cy
    for stable selectors
  2. Avoid Brittle Selectors: Don't rely on CSS classes or DOM structure
  3. Test User Behavior: Click, type, see - not implementation details
  4. Keep Tests Independent: Each test should run in isolation
  5. Clean Up Test Data: Create and destroy test data in each test
  6. Use Page Objects: Encapsulate page logic
  7. Meaningful Assertions: Check actual user-visible behavior
  8. Optimize for Speed: Mock when possible, parallel execution
  9. No Hardcoded Dates: Always compute dates dynamically relative to
    new Date()
    — hardcoded dates make tests brittle over time
  10. Locale-Agnostic Selectors: Never rely on text tied to a single language; prefer
    data-testid
    or regex matching all supported locales
  11. Separate Mobile and Desktop Tests: Write two distinct test functions per scenario — never use
    if (isMobile)
    or
    test.skip()
    inside a test body
  12. Small Reusable Functions: Extract every meaningful interaction into a named utility function; a test body should read as a sequence of high-level steps
  13. Code Quality Gate: Before considering a test done, ensure
    typecheck
    ,
    lint
    , and
    format
    all pass with zero errors
  14. No Repo Pollution: Keep exploration/debug scripts in
    /tmp/
    , never commit them to the project
typescript
// ❌ Bad selectors
cy.get(".btn.btn-primary.submit-button").click();
cy.get("div > form > div:nth-child(2) > input").type("text");

// ✅ Good selectors
cy.getByRole("button", { name: "Submit" }).click();
cy.getByLabel("Email address").type("user@example.com");
cy.get('[data-testid="email-input"]').type("user@example.com");

// ❌ Bad: hardcoded date
await page.fill('[data-testid="checkin"]', "2026-06-15");

// ✅ Good: dynamic date
const checkin = new Date();
checkin.setMonth(checkin.getMonth() + 1);
await page.fill('[data-testid="checkin"]', checkin.toISOString().split("T")[0]);

// ❌ Bad: text selector tied to one locale
await page.getByText("Select dates").click();

// ✅ Good: locale-agnostic selector
await page.locator('[data-testid="date-picker-trigger"]').click();
// or with regex covering multiple locales
await page.getByText(/select dates|choisir les dates/i).click();
  1. 使用数据属性: 使用
    data-testid
    data-cy
    作为稳定选择器
  2. 避免脆弱选择器: 不要依赖CSS类或DOM结构
  3. 测试用户行为: 模拟点击、输入、查看等行为,而非实现细节
  4. 保持测试独立: 每个测试应独立运行
  5. 清理测试数据: 在每个测试中创建并销毁测试数据
  6. 使用页面对象: 封装页面逻辑
  7. 有意义的断言: 检查用户可见的实际行为
  8. 优化测试速度: 尽可能模拟外部API,并行执行测试
  9. 不要硬编码日期: 始终根据
    new Date()
    动态计算日期——硬编码日期会让测试随时间变得不稳定
  10. 与区域设置无关的选择器: 永远不要依赖单一语言的文本;优先使用
    data-testid
    或匹配所有支持区域设置的正则表达式
  11. 分离移动端和桌面端测试: 每个场景编写两个独立的测试函数——永远不要在测试体内使用
    if (isMobile)
    test.skip()
  12. 小型可复用函数: 将每个有意义的交互提取为命名工具函数;测试体应读取为一系列高级步骤
  13. 代码质量关卡: 在认为测试完成之前,确保
    typecheck
    lint
    format
    都零错误通过
  14. 不要污染代码库: 将探索/调试脚本保存在
    /tmp/
    中,永远不要提交到项目
typescript
// ❌ 糟糕的选择器
cy.get(".btn.btn-primary.submit-button").click();
cy.get("div > form > div:nth-child(2) > input").type("text");

// ✅ 良好的选择器
cy.getByRole("button", { name: "Submit" }).click();
cy.getByLabel("Email address").type("user@example.com");
cy.get('[data-testid="email-input"]').type("user@example.com");

// ❌ 错误做法:硬编码日期
await page.fill('[data-testid="checkin"]', "2026-06-15");

// ✅ 正确做法:动态日期
const checkin = new Date();
checkin.setMonth(checkin.getMonth() + 1);
await page.fill('[data-testid="checkin"]', checkin.toISOString().split("T")[0]);

// ❌ 错误做法:绑定单一区域设置的文本选择器
await page.getByText("Select dates").click();

// ✅ 正确做法:与区域设置无关的选择器
await page.locator('[data-testid="date-picker-trigger"]').click();
// 或使用覆盖多区域设置的正则表达式
await page.getByText(/select dates|choisir les dates/i).click();

Pattern 5: Mobile & Desktop Split

模式5:移动端与桌面端分离

Never use conditional logic inside a test to handle viewports. Write two explicit functions:
typescript
// utils/search.ts
export async function fillSearchForm(page: Page, destination: string) {
  await page.locator('[data-testid="destination-input"]').fill(destination);
  await page.locator('[data-testid="destination-input"]').press("Enter");
}

export async function submitSearch(page: Page) {
  await page.locator('[data-testid="search-button"]').click();
  await page.waitForURL(/\/results/);
}

// search.spec.ts
import { test, expect } from "@playwright/test";
import { fillSearchForm, submitSearch } from "./utils/search";

async function testDesktop(page: Page) {
  await page.goto("/");
  await fillSearchForm(page, "Paris");
  await submitSearch(page);
  await expect(page.locator('[data-testid="results-list"]')).toBeVisible();
}

async function testMobile(page: Page) {
  await page.goto("/");
  await page.locator('[data-testid="mobile-search-toggle"]').click();
  await fillSearchForm(page, "Paris");
  await submitSearch(page);
  await expect(page.locator('[data-testid="results-list"]')).toBeVisible();
}

test("search results - desktop", async ({ page }) => {
  await testDesktop(page);
});

test("search results - mobile", async ({ page }) => {
  await page.setViewportSize({ width: 390, height: 844 });
  await testMobile(page);
});
永远不要在测试中使用条件逻辑处理视口。编写两个明确的函数:
typescript
// utils/search.ts
export async function fillSearchForm(page: Page, destination: string) {
  await page.locator('[data-testid="destination-input"]').fill(destination);
  await page.locator('[data-testid="destination-input"]').press("Enter");
}

export async function submitSearch(page: Page) {
  await page.locator('[data-testid="search-button"]').click();
  await page.waitForURL(/\/results/);
}

// search.spec.ts
import { test, expect } from "@playwright/test";
import { fillSearchForm, submitSearch } from "./utils/search";

async function testDesktop(page: Page) {
  await page.goto("/");
  await fillSearchForm(page, "Paris");
  await submitSearch(page);
  await expect(page.locator('[data-testid="results-list"]')).toBeVisible();
}

async function testMobile(page: Page) {
  await page.goto("/");
  await page.locator('[data-testid="mobile-search-toggle"]').click();
  await fillSearchForm(page, "Paris");
  await submitSearch(page);
  await expect(page.locator('[data-testid="results-list"]')).toBeVisible();
}

test("搜索结果 - 桌面端", async ({ page }) => {
  await testDesktop(page);
});

test("搜索结果 - 移动端", async ({ page }) => {
  await page.setViewportSize({ width: 390, height: 844 });
  await testMobile(page);
});

Common Pitfalls

常见陷阱

  • Flaky Tests: Use proper waits, not fixed timeouts
  • Slow Tests: Mock external APIs, use parallel execution
  • Over-Testing: Don't test every edge case with E2E
  • Coupled Tests: Tests should not depend on each other
  • Poor Selectors: Avoid CSS classes and nth-child
  • No Cleanup: Clean up test data after each test
  • Testing Implementation: Test user behavior, not internals
  • Hardcoded Dates: Tests will silently break over time — compute dates at runtime
  • Single-Locale Text: Text selectors break in other languages — use
    data-testid
    or regex
  • Viewport Conditionals:
    if (isMobile)
    in tests hides intent — split into dedicated functions
  • Monolithic Tests: Long test bodies are hard to debug — extract into small named utilities
  • Skipping the Quality Gate: Shipping tests with type errors or lint warnings causes CI noise
  • 不稳定测试: 使用适当的等待方式,而非固定超时
  • 慢速测试: 模拟外部API,使用并行执行
  • 过度测试: 不要用E2E测试每个边缘情况
  • 耦合测试: 测试不应相互依赖
  • 糟糕的选择器: 避免CSS类和nth-child
  • 未清理数据: 每个测试后清理测试数据
  • 测试实现细节: 测试用户行为,而非内部逻辑
  • 硬编码日期: 测试会随时间无声失效——在运行时计算日期
  • 单一区域设置文本: 文本选择器在其他语言中会失效——使用
    data-testid
    或正则表达式
  • 视口条件逻辑: 测试中的
    if (isMobile)
    会隐藏意图——拆分为专用函数
  • 庞大的测试: 长测试体难以调试——提取为小型命名工具函数
  • 跳过质量关卡: 发布带有类型错误或lint警告的测试会导致CI噪音

Debugging Failing Tests

调试失败的测试

typescript
// Playwright debugging
// 1. Run in headed mode
npx playwright test --headed

// 2. Run in debug mode
npx playwright test --debug

// 3. Use trace viewer
await page.screenshot({ path: 'screenshot.png' });
await page.video()?.saveAs('video.webm');

// 4. Add test.step for better reporting
test('checkout flow', async ({ page }) => {
    await test.step('Add item to cart', async () => {
        await page.goto('/products');
        await page.getByRole('button', { name: 'Add to Cart' }).click();
    });

    await test.step('Proceed to checkout', async () => {
        await page.goto('/cart');
        await page.getByRole('button', { name: 'Checkout' }).click();
    });
});

// 5. Inspect page state
await page.pause();  // Pauses execution, opens inspector
typescript
// Playwright调试
// 1. 以有头模式运行
npx playwright test --headed

// 2. 以调试模式运行
npx playwright test --debug

// 3. 使用跟踪查看器
await page.screenshot({ path: 'screenshot.png' });
await page.video()?.saveAs('video.webm');

// 4. 添加test.step以获得更好的报告
test('checkout flow', async ({ page }) => {
    await test.step('Add item to cart', async () => {
        await page.goto('/products');
        await page.getByRole('button', { name: 'Add to Cart' }).click();
    });

    await test.step('Proceed to checkout', async () => {
        await page.goto('/cart');
        await page.getByRole('button', { name: 'Checkout' }).click();
    });
});

// 5. 检查页面状态
await page.pause();  // 暂停执行,打开检查器