Playwright E2E Testing Deep Dive: From Test Cases to CI Integration

前端工程

Why Playwright Over Cypress or Selenium

I've used Selenium, Cypress, and Playwright for E2E testing across three projects. Selenium was the most painful — WebDriver protocol is slow, waiting logic relies on sleep, and browser version matching feels like defusing a bomb. Cypress ran fine in a mid-sized project for half a year, until we hit a page with three window.open calls and Cypress just stopped — it doesn't support multiple tabs.

Playwright solves these problems. Built by the same team behind Puppeteer (who left Google), it natively supports Chromium, Firefox, and WebKit with a consistently designed API. Its killer feature is auto-waiting — it automatically waits for elements to be visible and actionable before clicking, so you never write waitForSelector by hand.

This article isn't an API translation. I'll focus on the patterns that matter in real projects and what we learned going from 0 to 50 test cases.


Setup and Project Structure

npm init playwright@latest

Generated project structure:

e2e/
├── tests/
│   ├── example.spec.ts
│   ├── login.spec.ts
│   └── dashboard.spec.ts
├── fixtures/
│   └── auth.setup.ts
├── pages/
│   ├── LoginPage.ts
│   └── DashboardPage.ts
├── utils/
│   └── helpers.ts
└── playwright.config.ts

Core Configuration

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e/tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 3 : undefined,
  reporter: [
    ['html', { outputFolder: 'playwright-report' }],
    ['json', { outputFile: 'test-results.json' }],
  ],
  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 Chrome', use: { ...devices['Pixel 5'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});
  • fullyParallel: true — all files and tests run in parallel; 50 cases complete in 2-3 minutes in CI
  • trace: 'on-first-retry' — auto-records trace on failure, replayable like a video recording
  • webServer — auto-starts dev server, no manual npm run dev

Locator Strategy: Your First Line of Defense

Unstable locators are the number one killer of E2E tests. Change a DOM structure and everything goes red. Playwright offers a layered locator strategy, ranked by stability:

Priority Locator Example Stability
Highest getByRole page.getByRole('button', { name: 'Submit' }) ✅ Excellent
High getByLabel page.getByLabel('Email') ✅ Stable
High getByPlaceholder page.getByPlaceholder('Enter email') ✅ Stable
High getByText page.getByText('Order Details') ✅ Stable
Medium getByTestId page.getByTestId('submit-btn') ✅ Stable
Medium locator (CSS) page.locator('.login-form .submit') ⚠️ Fragile
Low locator (XPath) page.locator('//div[@class="btn"]') ❌ Very fragile

In Practice

// ❌ Bad: CSS class changes break the test
await page.locator('#app > div > form > button.btn-primary').click();

// ⚠️ Okay: testId is stable but requires adding data-testid to source
await page.getByTestId('login-submit').click();

// ✅ Good: based on accessibility semantics, framework-agnostic
await page.getByRole('button', { name: 'Login' }).click();

// ✅ Good: based on accessible labels
await page.getByLabel('Email Address').fill('test@example.com');
await page.getByPlaceholder('Enter password').fill('Secret123');

getByRole matches based on WAI-ARIA semantics. As long as your HTML uses semantic tags or correct role attributes, it finds them. This is the most stable approach — Tailwind can change every class and the role stays the same.

Filtering and Chaining

const item = page.getByRole('listitem').filter({ hasText: 'Order #2024' });
const row = page.getByRole('row').filter({ has: page.getByText('Completed') });
const card = page.getByTestId('user-card');
await card.getByRole('button', { name: 'Edit' }).click();
page.getByText('Delete', { exact: true });  // exact match only

Assertion Strategy: Don't Assert Everything

The most common mistake in E2E tests is over-assertion. Asserting every intermediate state makes tests unreadable and high-maintenance.

test('user logs in and lands on dashboard', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('admin@example.com');
  await page.getByLabel('Password').fill('password123');

  await page.getByRole('button', { name: 'Login' }).click();
  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByText('Welcome back, Admin')).toBeVisible();
  await expect(page.getByText('Login failed')).not.toBeVisible();
});

Quick Reference

await expect(page).toHaveTitle(/Dashboard/);
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByText('Saved successfully')).toBeVisible();
await expect(page.getByRole('listitem')).toHaveCount(5);
await expect(page.getByLabel('Email')).toHaveValue('admin@example.com');
await expect(page.getByRole('button', { name: 'Submit' })).toBeDisabled();
await expect(page).toHaveScreenshot('homepage.png');

Network Interception and Mocking

E2E tests shouldn't fail because the backend is down. Playwright's page.route() intercepts at the network level:

Basic Interception

test('display user list — mock backend data', async ({ page }) => {
  await page.route('**/api/users**', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        data: [
          { id: 1, name: 'Alice', email: 'alice@example.com' },
          { id: 2, name: 'Bob', email: 'bob@example.com' },
        ],
        total: 2,
      }),
    });
  });

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

Conditional Interception

await page.route('**/api/orders**', async (route, request) => {
  const url = new URL(request.url());
  const status = url.searchParams.get('status');

  if (status === 'pending') {
    await route.fulfill({ body: JSON.stringify({ data: pendingOrders }) });
  } else {
    await route.fallback();  // Pass through to real server
  }
});

Simulating Failures

// 500 error
await page.route('**/api/submit', async (route) => {
  await route.fulfill({
    status: 500,
    body: JSON.stringify({ error: 'Internal Server Error' }),
  });
});

// Network timeout
await page.route('**/api/slow-endpoint', async (route) => {
  await route.abort('timedout');
});

Authentication Reuse

Don't log in fresh for every test. storageState reuses authentication state:

// playwright.config.ts
export default defineConfig({
  projects: [
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'e2e/.auth/user.json',
      },
    },
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },
  ],
});
// e2e/tests/auth.setup.ts
import { test as setup } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '../.auth/user.json');

setup('authenticate and save state', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('admin@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Login' }).click();
  await expect(page).toHaveURL('/dashboard');
  await page.context().storageState({ path: authFile });
});

Subsequent tests load login state instantly from user.json.


Page Object Pattern

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

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

  constructor(readonly page: Page) {
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Login' });
    this.errorMessage = page.getByTestId('login-error');
  }

  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);
  }
}
test('wrong password shows error', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('admin@example.com', 'wrong-password');
  await loginPage.expectError('Invalid email or password');
});

Visual Regression Testing

test('homepage screenshot comparison', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('homepage.png', {
    fullPage: true,
    maxDiffPixelRatio: 0.01,
  });
});

test('button component dark mode', async ({ page }) => {
  await page.emulateMedia({ colorScheme: 'dark' });
  await page.goto('/components/button');
  await expect(page.getByTestId('primary-btn')).toHaveScreenshot(
    'button-primary-dark.png'
  );
});

Screenshots are stored in e2e/tests/__screenshots__/. First run generates baselines; subsequent runs compare against them. Use Docker in CI for consistent rendering.


CI/CD Integration

GitHub Actions

name: E2E Tests
on:
  pull_request:
    branches: [main]

jobs:
  e2e:
    timeout-minutes: 15
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3]

    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 chromium
      - name: Run E2E tests
        run: npx playwright test --shard=${{ matrix.shard }}/3
      - name: Upload test report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report-${{ matrix.shard }}
          path: playwright-report/
          retention-days: 7

Debugging Guide

Trace Viewer

npx playwright test --trace on
npx playwright show-trace test-results/example-LoginPage/trace.zip

Trace replays every action, network request, console log, DOM snapshot — a complete video recording of the test.

Useful Commands

npx playwright test --last-failed          # only re-run failures
npx playwright test tests/login.spec.ts:42 # specific line
npx playwright test --headed               # see the browser
npx playwright test --headed --debug       # step through slowly

FAQ

Q: Playwright vs Cypress — which should I pick?

A: For new projects, go with Playwright. Cypress has a mature community and Dashboard service, but technically — multi-tab support, multi-browser, auto-waiting, parallel execution — Playwright is superior. If your team already uses Cypress heavily and migration is costly, sticking with it is fine.

Q: How much should E2E tests cover?

A: The testing pyramid ratio — 70% unit, 20% integration, 10% E2E. E2E should only cover core user flows: registration/login, core business workflows, payment. Edge cases and error paths belong to unit and integration tests.

Q: What if backend API changes break all frontend tests?

A: Use real APIs for critical flows (true end-to-end verification) and page.route() mocks for non-critical flows. Store mock data centrally in a fixtures directory so API changes only require a single update.


Try these browser-local tools — no sign-up required →

#Playwright#E2E测试#自动化测试#前端#教程