E2E 测试(Playwright)

系统掌握 Playwright E2E 测试框架的核心架构、API 设计与工程实践。涵盖 Locator 策略、Auto-waiting 机制、Network Interception、Page Object Model、CI 集成与 Trace 调试。

为什么选择 Playwright

Playwright 由 Microsoft 开发(核心团队来自 Puppeteer),是 2024-2025 年增长最快的 E2E 测试框架。相比 Cypress,Playwright 在以下维度有明显优势:

维度 Playwright Cypress
浏览器支持 Chromium + Firefox + WebKit Chromium 为主
多标签页/多窗口 ✅ 原生支持 ❌ 不支持
iframe ✅ 原生支持 部分支持
并行执行 ✅ Worker 级并行 需要 Cypress Cloud
执行模型 进程外控制(CDP/WebSocket) 进程内注入
语言支持 TS/JS/Python/Java/C# JS/TS only
免费 CI 集成 ✅ 完全免费 Dashboard 需付费

一、Playwright 架构

1.1 核心组件

Test Runner (playwright/test)
    |
    ├── Browser Server (Chromium / Firefox / WebKit)
    │       |
    │       ├── Browser Context (隔离的浏览器会话)
    │       │       |
    │       │       ├── Page(标签页)
    │       │       │     ├── Frame(iframe)
    │       │       │     └── Locator(元素定位器)
    │       │       │
    │       │       └── Page(可多个标签页)
    │       │
    │       └── Browser Context(可多个独立上下文)
    │
    └── Reporter(测试报告生成)

1.2 Browser Context 隔离

每个测试默认获得一个独立的 Browser Context,相当于隐身窗口:

  • 独立的 Cookie、LocalStorage、SessionStorage
  • 独立的 Service Workers
  • 独立的权限设置
  • 无需在测试间手动清理状态
// 每个测试自动获得隔离的 context 和 page
test('test A', async ({ page }) => {
  // page A 的状态完全独立
});

test('test B', async ({ page }) => {
  // page B 与 A 互不影响
});

二、配置与项目结构

2.1 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,

  reporter: [
    ['html', { open: 'never' }],
    ['json', { outputFile: 'test-results.json' }],
  ],

  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: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 7'] },
    },
    {
      name: 'mobile-safari',
      use: { ...devices['iPhone 14'] },
    },
  ],

  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

2.2 项目结构

e2e/
  ├── fixtures/           ← 自定义 Fixture
  │   └── auth.ts
  ├── pages/              ← Page Object Models
  │   ├── login.page.ts
  │   └── dashboard.page.ts
  ├── auth.spec.ts        ← 按功能模块组织
  ├── checkout.spec.ts
  └── global-setup.ts     ← 全局 setup(如认证)

三、Locator — 元素定位

3.1 推荐的 Locator 优先级

// 1. Role-based(最推荐 — 与 Testing Library 一致)
page.getByRole('button', { name: 'Submit' });
page.getByRole('link', { name: 'Home' });
page.getByRole('heading', { level: 1 });

// 2. Label-based(表单元素)
page.getByLabel('Email');
page.getByPlaceholder('Enter your email');

// 3. Text-based
page.getByText('Welcome back');
page.getByText(/welcome/i); // 正则

// 4. Test ID(最后手段)
page.getByTestId('submit-button');

// 5. CSS / XPath(不推荐,但有时需要)
page.locator('.card >> nth=0');
page.locator('xpath=//div[@class="container"]');

3.2 Locator 链式操作

// 过滤
page.getByRole('listitem').filter({ hasText: 'Product A' });
page.getByRole('listitem').filter({ has: page.getByRole('button', { name: 'Buy' }) });

// 嵌套定位
const card = page.locator('.product-card').filter({ hasText: 'iPhone' });
await card.getByRole('button', { name: 'Add to Cart' }).click();

// 第 N 个元素
page.getByRole('listitem').nth(0);
page.getByRole('listitem').first();
page.getByRole('listitem').last();

3.3 Auto-waiting 机制

Playwright 的 Locator 内置自动等待,无需手动 waitForSelector

// Playwright 自动等待按钮变为可见、可用后才点击
await page.getByRole('button', { name: 'Submit' }).click();

// 等待链:
// 1. 等待元素 attached 到 DOM
// 2. 等待元素 visible
// 3. 等待元素 stable(不在动画中)
// 4. 等待元素 enabled(非 disabled)
// 5. 等待元素接收事件(没有被其他元素遮挡)
// 6. 执行点击

四、Assertions — Web-First 断言

4.1 自动重试的断言

import { expect } from '@playwright/test';

// 这些断言会自动重试直到超时(默认 5s)
await expect(page.getByText('Success')).toBeVisible();
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByRole('textbox')).toHaveValue('hello');
await expect(page.getByRole('checkbox')).toBeChecked();
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle(/Dashboard/);

// 否定断言
await expect(page.getByText('Loading')).not.toBeVisible();
await expect(page.getByText('Error')).toBeHidden();

4.2 常用断言清单

// 元素状态
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeChecked();
await expect(locator).toBeFocused();
await expect(locator).toBeEditable();

// 内容
await expect(locator).toHaveText('exact text');
await expect(locator).toContainText('partial');
await expect(locator).toHaveValue('input value');
await expect(locator).toHaveAttribute('href', '/home');
await expect(locator).toHaveClass(/active/);
await expect(locator).toHaveCSS('color', 'rgb(0, 0, 0)');
await expect(locator).toHaveCount(5);

// 页面级
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle('Dashboard');
await expect(page).toHaveScreenshot('dashboard.png');

五、Network Interception

5.1 拦截与 Mock API

test('should display mocked data', async ({ page }) => {
  // 拦截 API 请求并返回 Mock 数据
  await page.route('/api/users', (route) => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Alice' },
        { id: 2, name: 'Bob' },
      ]),
    });
  });

  await page.goto('/users');

  await expect(page.getByText('Alice')).toBeVisible();
  await expect(page.getByText('Bob')).toBeVisible();
});

5.2 等待网络请求

test('should submit form and wait for API response', async ({ page }) => {
  await page.goto('/contact');

  // 设置请求拦截(在操作之前)
  const responsePromise = page.waitForResponse('/api/contact');

  // 填写并提交表单
  await page.getByLabel('Message').fill('Hello');
  await page.getByRole('button', { name: 'Send' }).click();

  // 等待 API 响应
  const response = await responsePromise;
  expect(response.status()).toBe(200);

  await expect(page.getByText('Message sent!')).toBeVisible();
});

5.3 修改请求/响应

// 修改请求头
await page.route('/api/**', (route) => {
  route.continue({
    headers: {
      ...route.request().headers(),
      'X-Custom-Header': 'test-value',
    },
  });
});

// 修改响应
await page.route('/api/config', async (route) => {
  const response = await route.fetch();
  const json = await response.json();
  json.featureFlags.newUI = true; // 强制开启 feature flag
  route.fulfill({ response, json });
});

六、Test Fixtures — 测试装置

6.1 内置 Fixtures

test('example', async ({
  page,           // 隔离的页面
  context,        // Browser Context
  browser,        // Browser 实例
  request,        // API 请求上下文(无 UI)
  browserName,    // 'chromium' | 'firefox' | 'webkit'
}) => {
  // ...
});

6.2 自定义 Fixtures

// e2e/fixtures/auth.ts
import { test as base, type Page } from '@playwright/test';

type AuthFixtures = {
  authenticatedPage: Page;
  adminPage: Page;
};

export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ page }, use) => {
    // Setup: 登录
    await page.goto('/login');
    await page.getByLabel('Email').fill('[email protected]');
    await page.getByLabel('Password').fill('password');
    await page.getByRole('button', { name: 'Login' }).click();
    await page.waitForURL('/dashboard');

    // 提供给测试使用
    await use(page);

    // Teardown: 登出
    await page.goto('/logout');
  },

  adminPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: 'e2e/.auth/admin.json', // 预存的认证状态
    });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },
});

export { expect } from '@playwright/test';

6.3 Global Setup — 认证状态复用

// e2e/global-setup.ts
import { chromium, type FullConfig } from '@playwright/test';

export default async function globalSetup(config: FullConfig) {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  // 执行登录
  await page.goto('http://localhost:3000/login');
  await page.getByLabel('Email').fill('[email protected]');
  await page.getByLabel('Password').fill('admin123');
  await page.getByRole('button', { name: 'Login' }).click();
  await page.waitForURL('/dashboard');

  // 保存认证状态
  await page.context().storageState({ path: 'e2e/.auth/admin.json' });

  await browser.close();
}

七、Page Object Model

7.1 POM 设计

// e2e/pages/login.page.ts
import type { Page, Locator } from '@playwright/test';

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

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: /sign in/i });
    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.submitButton.click();
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }
}

7.2 在测试中使用

import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/login.page';

test.describe('Login', () => {
  let loginPage: LoginPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    await loginPage.goto();
  });

  test('should login successfully', async ({ page }) => {
    await loginPage.login('[email protected]', 'password');
    await expect(page).toHaveURL('/dashboard');
  });

  test('should show error for invalid credentials', async () => {
    await loginPage.login('[email protected]', 'wrong');
    await loginPage.expectError('Invalid email or password');
  });
});

八、调试与 Trace

8.1 Trace Viewer

Playwright 的 Trace Viewer 是最强大的调试工具,记录测试执行的完整时间线:

// playwright.config.ts
use: {
  trace: 'on-first-retry', // 仅在重试时记录(推荐)
  // trace: 'on',          // 始终记录(开发时)
  // trace: 'retain-on-failure', // 失败时保留
}
# 查看 trace
npx playwright show-trace test-results/auth-Login-should-login/trace.zip

Trace 包含:

  • 每一步操作的截图和 DOM 快照
  • 网络请求时间线
  • Console 日志
  • 操作耗时

8.2 UI Mode

npx playwright test --ui

交互式 UI 模式:实时运行测试、查看步骤、时间旅行调试。

8.3 Debug Mode

# 以 headed 模式 + Inspector 运行
npx playwright test --debug

# 在代码中设置断点
await page.pause(); // 打开 Playwright Inspector

8.4 Codegen — 代码生成

npx playwright codegen http://localhost:3000

录制浏览器操作,自动生成测试代码。


九、CI/CD 集成

9.1 GitHub Actions

# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install dependencies
        run: npm ci

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

      - name: Build application
        run: npm run build

      - name: Run E2E tests
        run: npx playwright test

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

9.2 Sharding — 大型测试分片

strategy:
  matrix:
    shard: [1/4, 2/4, 3/4, 4/4]
steps:
  - run: npx playwright test --shard ${{ matrix.shard }}

十、面试高频题

题型 1:Playwright 的 Auto-waiting 是如何工作的?

Playwright 的每个操作(click、fill、check 等)都会自动执行 Actionability Checks:

  1. Attached:元素在 DOM 中
  2. Visible:元素可见
  3. Stable:元素位置不再变化(动画结束)
  4. Enabled:元素非 disabled
  5. Receives Events:没有被其他元素遮挡

如果条件不满足,Playwright 会自动重试直到超时。这消除了手动 sleepwaitFor 的需求。

题型 2:如何处理 Flaky E2E Tests?

  1. 网络不稳定 → 使用 page.route() Mock 关键 API
  2. 动画干扰 → 全局禁用动画:page.emulateMedia({ reducedMotion: 'reduce' })
  3. 时间依赖 → 使用 page.clock Mock 时间
  4. 数据依赖 → 每个测试独立准备数据
  5. 重试机制 → 配置 retries: 2 + trace: 'on-first-retry'
  6. 并行隔离 → 确保测试不共享外部状态

题型 3:E2E 测试 vs 组件测试,如何选择?

  • 组件测试:验证组件级别的行为、渲染逻辑、状态变化。快速、稳定、成本低。
  • E2E 测试:验证跨页面的完整业务流程。慢、脆弱、但覆盖真实用户路径。
  • 原则:先用组件测试覆盖所有组件行为,用 E2E 测试覆盖 核心业务 Happy Path

与其他主题的关联

关联主题 关系
testing-philosophy E2E 是测试金字塔的顶层
component-testing 组件测试覆盖不了的跨页面流程由 E2E 补充
visual-regression Playwright 内置 screenshot comparison 支持视觉回归
mocking-strategies page.route() 是 E2E 级别的 Mock 方案
error-monitoring E2E 可模拟错误场景验证错误边界

参考资料

延展阅读