Files
2025-11-29 18:17:04 +08:00

11 KiB

Playwright Best Practices

Official best practices for Playwright test automation, optimized for LLM-assisted development.

Test Structure

Use Page Object Models (POM)

Why: Separates page structure from test logic, improves maintainability

// Good: Page Object Model
// pages/login.page.ts
export class LoginPage {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto('/login');
  }

  async login(username: string, password: string) {
    await this.page.getByLabel('Username').fill(username);
    await this.page.getByLabel('Password').fill(password);
    await this.page.getByRole('button', { name: 'Sign in' }).click();
  }
}

// specs/login.spec.ts
test('user can login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password123');
  await expect(page).toHaveURL('/dashboard');
});

Use Semantic Selectors

Priority order (most stable → least stable):

  1. getByRole - Accessible role (button, heading, textbox, etc.)
  2. getByLabel - Form inputs with associated labels
  3. getByPlaceholder - Input placeholder text
  4. getByText - User-visible text content
  5. getByTestId - data-testid attributes (last resort)
// Best: Role-based (accessible and stable)
await page.getByRole('button', { name: 'Submit' }).click();

// Good: Label-based (for forms)
await page.getByLabel('Email address').fill('user@example.com');

// Acceptable: Text-based
await page.getByText('Continue to checkout').click();

// Avoid: CSS selectors (brittle)
await page.click('.btn-primary');  // ❌ Breaks if class changes

// Last resort: Test IDs (when semantic selectors don't work)
await page.getByTestId('checkout-button').click();

Screenshot Best Practices

When to Capture Screenshots

  1. Initial page load - Baseline visual state
  2. Before interaction - Pre-state for comparison
  3. After interaction - Result of user action
  4. Error states - When validation fails or errors occur
  5. Success states - Confirmation screens, success messages
  6. Test failures - Automatic capture for debugging

Screenshot Naming Convention

// Pattern: {test-name}-{viewport}-{state}-{timestamp}.png

await page.screenshot({
  path: `screenshots/current/login-desktop-initial-${Date.now()}.png`,
  fullPage: true
});

await page.screenshot({
  path: `screenshots/current/checkout-mobile-error-${Date.now()}.png`,
  fullPage: true
});

Full-Page vs Element Screenshots

// Full-page: For layout and overall UI analysis
await page.screenshot({
  path: 'homepage-full.png',
  fullPage: true  // Captures entire scrollable page
});

// Element-specific: For component testing
const button = page.getByRole('button', { name: 'Submit' });
await button.screenshot({
  path: 'submit-button.png'
});

Waiting and Timing

Auto-Waiting

Playwright automatically waits for:

  • Element to be attached to DOM
  • Element to be visible
  • Element to be stable (not animating)
  • Element to receive events (not obscured)
  • Element to be enabled
// This automatically waits for button to be clickable
await page.getByRole('button', { name: 'Submit' }).click();

Explicit Waits (when needed)

// Wait for navigation
await page.waitForURL('/dashboard');

// Wait for network idle (good before screenshots)
await page.waitForLoadState('networkidle');

// Wait for specific element
await page.waitForSelector('img[alt="Profile picture"]');

// Wait for custom condition
await page.waitForFunction(() => window.scrollY === 0);

Avoid Fixed Timeouts

// Bad: Arbitrary delays
await page.waitForTimeout(3000);  // ❌ Flaky, slow

// Good: Wait for specific condition
await expect(page.getByText('Success')).toBeVisible();  // ✅ Fast and reliable

Test Isolation

Independent Tests

Each test should be completely independent:

// Good: Test is self-contained
test('user can add item to cart', async ({ page }) => {
  // Set up: Create user, log in
  await page.goto('/');
  await login(page, 'user@example.com', 'password');

  // Action: Add to cart
  await page.getByRole('button', { name: 'Add to cart' }).click();

  // Assert: Item in cart
  await expect(page.getByTestId('cart-count')).toHaveText('1');

  // Cleanup happens automatically with new page context
});

// Bad: Depends on previous test state
test('user can checkout', async ({ page }) => {
  // ❌ Assumes cart already has items from previous test
  await page.goto('/checkout');
  // ...
});

Use test.beforeEach for Common Setup

test.describe('Shopping cart', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/');
    await login(page, 'user@example.com', 'password');
  });

  test('can add item to cart', async ({ page }) => {
    // Setup already done
    await page.getByRole('button', { name: 'Add to cart' }).click();
    await expect(page.getByTestId('cart-count')).toHaveText('1');
  });

  test('can remove item from cart', async ({ page }) => {
    // Setup already done, fresh state
    await page.getByRole('button', { name: 'Add to cart' }).click();
    await page.getByRole('button', { name: 'Remove' }).click();
    await expect(page.getByTestId('cart-count')).toHaveText('0');
  });
});

Visual Regression Testing

Snapshot Testing

// Basic snapshot
await expect(page).toHaveScreenshot('homepage.png');

// With threshold (allow minor differences)
await expect(page).toHaveScreenshot('homepage.png', {
  maxDiffPixelRatio: 0.05  // Allow 5% difference
});

// Element snapshot
const card = page.getByRole('article').first();
await expect(card).toHaveScreenshot('product-card.png');

Updating Baselines

# Update all snapshots
npx playwright test --update-snapshots

# Update specific test
npx playwright test login.spec.ts --update-snapshots

Baseline Management

  • Store baselines in git - Commit to repository for consistency
  • Review diffs carefully - Not all changes are bugs
  • Update deliberately - Only update when changes are intentional
  • Use CI checks - Fail pipeline on unexpected visual changes

Configuration Best Practices

playwright.config.ts Essentials

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

export default defineConfig({
  testDir: './tests',

  // Timeout for each test
  timeout: 30 * 1000,

  // Global setup/teardown
  globalSetup: require.resolve('./tests/setup/global-setup.ts'),

  // Fail fast on CI, retry locally
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,

  // Parallel execution
  workers: process.env.CI ? 1 : undefined,

  // Reporter
  reporter: process.env.CI ? 'github' : 'html',

  use: {
    // Base URL
    baseURL: 'http://localhost:5173',

    // Screenshot on failure
    screenshot: 'only-on-failure',

    // Trace on first retry
    trace: 'on-first-retry',

    // Video on failure
    video: 'retain-on-failure',
  },

  // Projects for multi-browser testing
  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'] },
    },
    {
      name: 'mobile-safari',
      use: { ...devices['iPhone 13'] },
    },
  ],

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

Debugging

Playwright Inspector

# Debug specific test
npx playwright test --debug login.spec.ts

# Debug from specific line
npx playwright test --debug --grep "user can login"

VS Code Debugger

// .vscode/launch.json
{
  "configurations": [
    {
      "name": "Debug Playwright Tests",
      "type": "node",
      "request": "launch",
      "program": "${workspaceFolder}/node_modules/@playwright/test/cli.js",
      "args": ["test", "--headed", "${file}"],
      "console": "integratedTerminal"
    }
  ]
}

Trace Viewer

# Run with trace
npx playwright test --trace on

# View trace
npx playwright show-trace trace.zip

Performance Optimization

Parallel Execution

// Run tests in parallel (default)
test.describe.configure({ mode: 'parallel' });

// Run tests serially (when needed)
test.describe.configure({ mode: 'serial' });

Reuse Authentication State

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

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

  await page.goto('http://localhost:5173/login');
  await page.getByLabel('Username').fill('admin');
  await page.getByLabel('Password').fill('password');
  await page.getByRole('button', { name: 'Sign in' }).click();

  // Save authentication state
  await page.context().storageState({ path: 'auth.json' });
  await browser.close();
}

// Use in tests
test.use({ storageState: 'auth.json' });

Common Pitfalls to Avoid

1. Not Waiting for Network Idle Before Screenshots

// Bad: Screenshot may capture loading state
await page.goto('/dashboard');
await page.screenshot({ path: 'dashboard.png' });

// Good: Wait for content to load
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await page.screenshot({ path: 'dashboard.png' });

2. Using Non-Stable Selectors

// Bad: Position-based (breaks if order changes)
await page.locator('button').nth(2).click();

// Good: Content-based
await page.getByRole('button', { name: 'Submit' }).click();

3. Not Handling Dynamic Content

// Bad: Assumes content is already loaded
const text = await page.getByTestId('user-name').textContent();

// Good: Wait for element first
await expect(page.getByTestId('user-name')).toBeVisible();
const text = await page.getByTestId('user-name').textContent();

4. Overly Broad Assertions

// Bad: Fails on any minor change
await expect(page).toHaveScreenshot({ maxDiffPixelRatio: 0 });

// Good: Allow reasonable tolerance
await expect(page).toHaveScreenshot({ maxDiffPixelRatio: 0.02 });

Summary Checklist

  • Use Page Object Models for test organization
  • Prefer semantic selectors (getByRole, getByLabel)
  • Capture screenshots at key interaction points
  • Wait for network idle before screenshots
  • Use auto-waiting instead of fixed timeouts
  • Make tests independent and isolated
  • Configure proper retry logic (2-3 retries in CI)
  • Store authentication state for reuse
  • Use trace viewer for debugging
  • Review visual diffs before updating baselines
  • Run tests in parallel for performance
  • Enable screenshot/video on failure
  • Store baselines in version control
  • Use meaningful screenshot names with timestamps
  • Configure appropriate visual diff thresholds

References: