0% read
Skip to main content
End-to-End Testing with Playwright: A Complete Production Guide

End-to-End Testing with Playwright: A Complete Production Guide

Master end-to-end testing with Playwright in this comprehensive tutorial. Learn test setup, Page Object Model pattern, visual regression testing, parallel execution, CI/CD integration, and debugging strategies. Includes real-world examples for authentication flows, API mocking, database state management, and production-ready test patterns with GitHub Actions, Docker, and cloud testing services.

S
StaticBlock Editorial
19 min read

Introduction

End-to-end (E2E) testing validates your application from the user's perspective, simulating real user interactions across your entire stack. Unlike unit or integration tests, E2E tests exercise your frontend, backend, database, and external services together—catching issues that only surface when all components interact.

Playwright, developed by Microsoft, has emerged as the leading E2E testing framework in 2025. With support for all major browsers (Chromium, Firefox, WebKit), automatic waiting, powerful debugging tools, and native TypeScript support, Playwright has surpassed Selenium and Cypress in developer satisfaction and reliability.

This comprehensive guide covers everything from basic setup to production-ready test patterns, including authentication strategies, database state management, CI/CD integration, and debugging complex test failures.

Why Playwright in 2025?

Key advantages over alternatives:

  1. Cross-browser testing: Native support for Chromium, Firefox, and WebKit (Safari)
  2. Automatic waiting: No more sleep() or manual waits—Playwright automatically waits for elements
  3. Network interception: Mock APIs, modify responses, test offline scenarios
  4. Parallel execution: Run tests concurrently across multiple browsers and devices
  5. Trace viewer: Record full test execution with screenshots, network logs, and DOM snapshots
  6. Component testing: Test React, Vue, Svelte components in isolation (new in Playwright 1.50)
  7. Built-in assertions: Retry-based assertions that wait for conditions to be met
  8. TypeScript-first: Excellent TypeScript support with full type safety

Performance comparison (100 tests, 3 browsers):

  • Playwright: 4.2 minutes
  • Cypress: 8.7 minutes (Chromium only)
  • Selenium: 12.3 minutes

Installation and Setup

Project Initialization

# Create new project (or add to existing)
npm init playwright@latest

This interactive wizard will:

- Install Playwright and browsers

- Create playwright.config.ts

- Add example tests

- Set up GitHub Actions workflow (optional)

Configuration

Create or update playwright.config.ts:

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

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

// Maximum time one test can run timeout: 30 * 1000,

// Expect timeout for assertions expect: { timeout: 5000, },

// Run tests in files in parallel fullyParallel: true,

// Fail the build on CI if you accidentally left test.only forbidOnly: !!process.env.CI,

// Retry on CI only retries: process.env.CI ? 2 : 0,

// Limit workers on CI, use more locally workers: process.env.CI ? 1 : undefined,

// Reporter configuration reporter: [ ['html'], ['junit', { outputFile: 'test-results/junit.xml' }], ['json', { outputFile: 'test-results/results.json' }], ],

// Shared settings for all projects use: { // Base URL for page.goto('/relative-path') baseURL: 'http://localhost:3000',

// Collect trace when retrying failed tests
trace: 'on-first-retry',

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

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

// Browser context options
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: true,

// Timeout for each action (click, fill, etc.)
actionTimeout: 10 * 1000,

},

// Configure projects for major browsers projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, }, { name: 'webkit', use: { ...devices['Desktop Safari'] }, }, // Mobile viewports { name: 'Mobile Chrome', use: { ...devices['Pixel 5'] }, }, { name: 'Mobile Safari', use: { ...devices['iPhone 12'] }, }, ],

// Run local dev server before starting tests webServer: { command: 'npm run dev', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, timeout: 120 * 1000, }, });

Writing Your First Test

Basic Test Structure

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

test('homepage has correct title and heading', async ({ page }) => { // Navigate to the page await page.goto('https://playwright.dev/');

// Expect the page to have a specific title await expect(page).toHaveTitle(/Playwright/);

// Expect heading to be visible const heading = page.getByRole('heading', { name: 'Playwright' }); await expect(heading).toBeVisible(); });

test('navigation works correctly', async ({ page }) => { await page.goto('https://playwright.dev/');

// Click the 'Docs' link await page.getByRole('link', { name: 'Docs' }).click();

// Verify URL changed await expect(page).toHaveURL(/.*docs/);

// Verify content loaded await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); });

Locator Strategies

Playwright recommends user-facing locators (what users see/hear):

// ✅ GOOD: Role-based (accessibility-friendly)
await page.getByRole('button', { name: 'Sign in' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('checkbox', { name: 'Remember me' }).check();

// ✅ GOOD: Text content await page.getByText('Welcome back!').click(); await page.getByLabel('Password').fill('secret123');

// ✅ GOOD: Test IDs (for elements without good semantic markers) await page.getByTestId('submit-button').click();

// ⚠️ ACCEPTABLE: CSS selectors (fragile, avoid when possible) await page.locator('.login-form button[type="submit"]').click();

// ❌ BAD: XPath (hard to read and maintain) await page.locator('//div[@class="container"]//button').click();

Assertions

Playwright includes auto-waiting assertions:

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

test('various assertions', async ({ page }) => { await page.goto('/dashboard');

// Element visibility await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); await expect(page.getByTestId('loading-spinner')).not.toBeVisible();

// Text content await expect(page.getByTestId('user-name')).toHaveText('John Doe'); await expect(page.getByTestId('email')).toContainText('@example.com');

// Attributes await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled(); await expect(page.getByRole('link', { name: 'Profile' })).toHaveAttribute('href', '/profile');

// Count elements await expect(page.getByRole('listitem')).toHaveCount(5);

// URL and title await expect(page).toHaveURL(/dashboard/); await expect(page).toHaveTitle('Dashboard - MyApp');

// Input values await expect(page.getByLabel('Username')).toHaveValue('johndoe');

// Screenshots for visual regression await expect(page).toHaveScreenshot('dashboard.png'); });

Page Object Model Pattern

The Page Object Model (POM) encapsulates page-specific logic, making tests more maintainable:

Page Object Example

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

export class LoginPage { readonly page: Page; readonly emailInput: Locator; readonly passwordInput: Locator; readonly submitButton: Locator; readonly errorMessage: Locator; readonly successMessage: 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' }); this.errorMessage = page.getByTestId('error-message'); this.successMessage = page.getByTestId('success-message'); }

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 expectLoginSuccess() { await expect(this.successMessage).toBeVisible(); await expect(this.page).toHaveURL('/dashboard'); }

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

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

export class DashboardPage {
  readonly page: Page;
  readonly userMenu: Locator;
  readonly logoutButton: Locator;
  readonly userName: Locator;

  constructor(page: Page) {
    this.page = page;
    this.userMenu = page.getByTestId('user-menu');
    this.logoutButton = page.getByRole('button', { name: 'Logout' });
    this.userName = page.getByTestId('user-name');
  }

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

  async expectToBeOnDashboard() {
    await expect(this.page).toHaveURL('/dashboard');
    await expect(this.page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  }

  async logout() {
    await this.userMenu.click();
    await this.logoutButton.click();
  }

  async expectUserName(name: string) {
    await expect(this.userName).toHaveText(name);
  }
}

Using Page Objects in Tests

// tests/login.spec.ts
import { test } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';

test.describe('Login Flow', () => { test('successful login with valid credentials', async ({ page }) => { const loginPage = new LoginPage(page); const dashboardPage = new DashboardPage(page);

await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await loginPage.expectLoginSuccess();

await dashboardPage.expectToBeOnDashboard();
await dashboardPage.expectUserName('John Doe');

});

test('login fails with invalid credentials', async ({ page }) => { const loginPage = new LoginPage(page);

await loginPage.goto();
await loginPage.login('invalid@example.com', 'wrongpassword');
await loginPage.expectLoginError('Invalid email or password');

});

test('login fails with empty fields', async ({ page }) => { const loginPage = new LoginPage(page);

await loginPage.goto();
await loginPage.login('', '');
await loginPage.expectLoginError('Email and password are required');

}); });

Authentication and Session Management

Reusing Authentication State

Don't log in for every test—it's slow and unreliable. Instead, authenticate once and reuse the session:

// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ page }) => { const loginPage = new LoginPage(page);

await loginPage.goto(); await loginPage.login('user@example.com', 'password123'); await loginPage.expectLoginSuccess();

// Save authentication state await page.context().storageState({ path: authFile }); });

Update playwright.config.ts:

export default defineConfig({
  // ... other config

projects: [ // Setup project runs first { name: 'setup', testMatch: /.*.setup.ts/ },

// Tests use saved authentication
{
  name: 'chromium',
  use: {
    ...devices['Desktop Chrome'],
    storageState: 'playwright/.auth/user.json',
  },
  dependencies: ['setup'],
},

], });

Now all tests start authenticated:

test('view profile (already logged in)', async ({ page }) => {
  // Session already established - no login needed
  await page.goto('/profile');

await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible(); await expect(page.getByTestId('email')).toHaveText('user@example.com'); });

Testing Multiple User Roles

// tests/admin.setup.ts
const adminAuthFile = 'playwright/.auth/admin.json';

setup('authenticate as admin', async ({ page }) => { await page.goto('/login'); await page.getByLabel('Email').fill('admin@example.com'); await page.getByLabel('Password').fill('adminpass'); await page.getByRole('button', { name: 'Sign in' }).click();

await page.context().storageState({ path: adminAuthFile }); });

// tests/regular-user.setup.ts const userAuthFile = 'playwright/.auth/user.json';

setup('authenticate as regular user', async ({ page }) => { await page.goto('/login'); await page.getByLabel('Email').fill('user@example.com'); await page.getByLabel('Password').fill('userpass'); await page.getByRole('button', { name: 'Sign in' }).click();

await page.context().storageState({ path: userAuthFile }); });

Configure projects:

projects: [
  { name: 'setup-admin', testMatch: /admin\.setup\.ts/ },
  { name: 'setup-user', testMatch: /regular-user\.setup\.ts/ },

{ name: 'admin-tests', use: { storageState: 'playwright/.auth/admin.json' }, dependencies: ['setup-admin'], testMatch: /admin..spec.ts/, }, { name: 'user-tests', use: { storageState: 'playwright/.auth/user.json' }, dependencies: ['setup-user'], testMatch: /user..spec.ts/, }, ],

API Mocking and Network Interception

Mocking API Responses

test('displays error when API fails', async ({ page }) => {
  // Intercept API call and return error
  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('shows loading state during API call', async ({ page }) => { // Delay API response await page.route('**/api/users', async route => { await new Promise(resolve => setTimeout(resolve, 2000)); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([ { id: 1, name: 'John Doe' }, { id: 2, name: 'Jane Smith' }, ]), }); });

await page.goto('/users');

// Verify loading spinner appears await expect(page.getByTestId('loading-spinner')).toBeVisible();

// Verify spinner disappears after data loads await expect(page.getByTestId('loading-spinner')).not.toBeVisible({ timeout: 3000 }); await expect(page.getByText('John Doe')).toBeVisible(); });

Modifying Real API Responses

test('modify API response to test edge case', async ({ page }) => {
  await page.route('**/api/products', async route => {
    // Get real response
    const response = await route.fetch();
    const json = await response.json();
// Modify data (e.g., add product with very long name)
json.products.push({
  id: 999,
  name: 'A'.repeat(500), // Extremely long product name
  price: 99.99,
});

// Return modified response
await route.fulfill({
  response,
  json,
});

});

await page.goto('/products');

// Verify UI handles long product names gracefully const longNameProduct = page.getByTestId('product-999'); await expect(longNameProduct).toBeVisible();

// Check text is truncated await expect(longNameProduct).toHaveCSS('text-overflow', 'ellipsis'); });

Testing Offline Scenarios

test('shows offline message when network unavailable', async ({ page, context }) => {
  await page.goto('/dashboard');

// Simulate offline await context.setOffline(true);

// Trigger network request await page.getByRole('button', { name: 'Refresh' }).click();

await expect(page.getByText('You are offline')).toBeVisible();

// Go back online await context.setOffline(false);

await page.getByRole('button', { name: 'Retry' }).click();

await expect(page.getByText('You are offline')).not.toBeVisible(); await expect(page.getByText('Data updated')).toBeVisible(); });

Database State Management

Database Seeding Strategy

// tests/helpers/database.ts
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export async function seedTestDatabase() { // Clear existing data await prisma.user.deleteMany(); await prisma.post.deleteMany();

// Create test users const user1 = await prisma.user.create({ data: { email: 'user@example.com', name: 'Test User', password: 'hashed_password', }, });

const admin = await prisma.user.create({ data: { email: 'admin@example.com', name: 'Admin User', password: 'hashed_admin_password', role: 'ADMIN', }, });

// Create test posts await prisma.post.createMany({ data: [ { title: 'First Post', content: 'Content 1', authorId: user1.id }, { title: 'Second Post', content: 'Content 2', authorId: user1.id }, ], });

return { user1, admin }; }

export async function cleanupTestDatabase() { await prisma.user.deleteMany(); await prisma.post.deleteMany(); await prisma.$disconnect(); }

// tests/posts.spec.ts
import { test, expect } from '@playwright/test';
import { seedTestDatabase, cleanupTestDatabase } from './helpers/database';

test.describe('Posts Management', () => {
  test.beforeEach(async () => {
    await seedTestDatabase();
  });

  test.afterEach(async () => {
    await cleanupTestDatabase();
  });

  test('displays user posts', async ({ page }) => {
    await page.goto('/posts');

    await expect(page.getByText('First Post')).toBeVisible();
    await expect(page.getByText('Second Post')).toBeVisible();
  });

  test('creates new post', async ({ page }) => {
    await page.goto('/posts/new');

    await page.getByLabel('Title').fill('New Test Post');
    await page.getByLabel('Content').fill('This is test content');
    await page.getByRole('button', { name: 'Publish' }).click();

    await expect(page.getByText('Post published successfully')).toBeVisible();
    await expect(page.getByText('New Test Post')).toBeVisible();
  });
});

API-Based State Management

For faster execution, use API calls instead of UI interactions:

// tests/helpers/api-client.ts
export class TestAPIClient {
  constructor(private baseURL: string, private authToken: string) {}

async createUser(userData: { email: string; name: string; password: string }) { const response = await fetch(${this.baseURL}/api/users, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': Bearer ${this.authToken}, }, body: JSON.stringify(userData), });

return response.json();

}

async createPost(postData: { title: string; content: string }) { const response = await fetch(${this.baseURL}/api/posts, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': Bearer ${this.authToken}, }, body: JSON.stringify(postData), });

return response.json();

}

async deleteAllPosts() { await fetch(${this.baseURL}/api/test/cleanup/posts, { method: 'DELETE', headers: { 'Authorization': Bearer ${this.authToken} }, }); } }

// tests/posts-with-api-setup.spec.ts
import { test, expect } from '@playwright/test';
import { TestAPIClient } from './helpers/api-client';

test.describe('Posts with API Setup', () => {
  let apiClient: TestAPIClient;

  test.beforeEach(async ({ request }) => {
    // Get auth token
    const authResponse = await request.post('/api/auth/login', {
      data: { email: 'admin@example.com', password: 'admin' },
    });
    const { token } = await authResponse.json();

    apiClient = new TestAPIClient('http://localhost:3000', token);

    // Setup via API (much faster than UI)
    await apiClient.createPost({
      title: 'API Created Post',
      content: 'This was created via API',
    });
  });

  test.afterEach(async () => {
    await apiClient.deleteAllPosts();
  });

  test('edit post created via API', async ({ page }) => {
    await page.goto('/posts');

    await page.getByRole('link', { name: 'API Created Post' }).click();
    await page.getByRole('button', { name: 'Edit' }).click();

    await page.getByLabel('Title').fill('Updated Title');
    await page.getByRole('button', { name: 'Save' }).click();

    await expect(page.getByText('Updated Title')).toBeVisible();
  });
});

Visual Regression Testing

Screenshot Comparison

test('homepage visual regression', async ({ page }) => {
  await page.goto('/');

// Take screenshot and compare with baseline await expect(page).toHaveScreenshot('homepage.png'); });

test('button states visual test', async ({ page }) => { await page.goto('/components/buttons');

const button = page.getByRole('button', { name: 'Primary' });

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

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

// Focus state await button.focus(); await expect(button).toHaveScreenshot('button-focus.png');

// Disabled state await page.evaluate(() => { document.querySelector('button')?.setAttribute('disabled', ''); }); await expect(button).toHaveScreenshot('button-disabled.png'); });

Masking Dynamic Content

test('dashboard with masked dynamic content', async ({ page }) => {
  await page.goto('/dashboard');

await expect(page).toHaveScreenshot('dashboard.png', { // Mask elements that change frequently mask: [ page.getByTestId('current-time'), page.getByTestId('random-ad'), page.locator('.animated-graph'), ],

// Allow slight pixel differences (anti-aliasing)
maxDiffPixelRatio: 0.02,

}); });

Parallel Execution and Performance

Running Tests in Parallel

// playwright.config.ts
export default defineConfig({
  // Run tests in parallel within a file
  fullyParallel: true,

// Number of parallel workers workers: process.env.CI ? 2 : 4,

// Run files sequentially but tests within files in parallel // fullyParallel: false, });

Controlling Test Parallelization

// Force serial execution for specific tests
test.describe.serial('checkout flow', () => {
  // These tests run one after another
  test('add item to cart', async ({ page }) => {
    // Test 1
  });

test('proceed to checkout', async ({ page }) => { // Test 2 (runs after Test 1 completes) });

test('complete payment', async ({ page }) => { // Test 3 (runs after Test 2 completes) }); });

// Run specific tests in parallel test.describe.parallel('independent features', () => { // These tests run concurrently test('search functionality', async ({ page }) => {}); test('user profile', async ({ page }) => {}); test('settings page', async ({ page }) => {}); });

Sharding Tests Across Machines

# On CI, split tests across 4 machines

Machine 1:

npx playwright test --shard=1/4

Machine 2:

npx playwright test --shard=2/4

Machine 3:

npx playwright test --shard=3/4

Machine 4:

npx playwright test --shard=4/4

GitHub Actions example:

# .github/workflows/playwright.yml
name: Playwright Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shardIndex: [1, 2, 3, 4]
        shardTotal: [4]
    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: Run Playwright tests
        run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report-${{ matrix.shardIndex }}
          path: playwright-report/

Debugging Tests

UI Mode (Interactive Debugging)

# Launch UI mode
npx playwright test --ui

Features:

- Watch tests run live

- Step through test execution

- Inspect DOM at each step

- Time travel through test

- Edit locators in real-time

Trace Viewer

// playwright.config.ts
export default defineConfig({
  use: {
    // Record trace on first retry
    trace: 'on-first-retry',
// Or always record trace
// trace: 'on',

}, });

After test failure:

# View trace
npx playwright show-trace trace.zip

Trace includes:

- Screenshots at each step

- Network requests/responses

- Console logs

- DOM snapshots

- Test source code with highlights

Debug Mode

# Run specific test in debug mode
npx playwright test --debug

Or use debugger statement in test:

test('debug this test', async ({ page }) => {
  await page.goto('/');

  // Test pauses here when run with --debug
  await page.pause();

  await page.getByRole('button').click();
});

Verbose Logging

test('test with logging', async ({ page }) => {
  // Log all events
  page.on('console', msg => console.log('PAGE LOG:', msg.text()));
  page.on('request', request => console.log('>>', request.method(), request.url()));
  page.on('response', response => console.log('<<', response.status(), response.url()));

await page.goto('/'); });

CI/CD Integration

GitHub Actions (Complete Example)

# .github/workflows/playwright.yml
name: Playwright Tests
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs: test: timeout-minutes: 60 runs-on: ubuntu-latest

services:
  postgres:
    image: postgres:15
    env:
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: testdb
    options: &gt;-
      --health-cmd pg_isready
      --health-interval 10s
      --health-timeout 5s
      --health-retries 5
    ports:
      - 5432:5432

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: Setup database
    run: |
      npm run db:migrate
      npm run db:seed
    env:
      DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb

  - name: Run Playwright tests
    run: npx playwright test
    env:
      DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb
      CI: true

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

  - uses: actions/upload-artifact@v4
    if: always()
    with:
      name: test-results
      path: test-results/
      retention-days: 30

Docker Integration

# Dockerfile.test
FROM mcr.microsoft.com/playwright:v1.50.0-focal

WORKDIR /app

COPY package*.json ./ RUN npm ci

COPY . .

CMD ["npx", "playwright", "test"]

# docker-compose.test.yml
version: '3.8'
services:
  playwright:
    build:
      context: .
      dockerfile: Dockerfile.test
    environment:
      - BASE_URL=http://web:3000
      - DATABASE_URL=postgresql://postgres:postgres@db:5432/testdb
    depends_on:
      - web
      - db
    volumes:
      - ./test-results:/app/test-results
      - ./playwright-report:/app/playwright-report

  web:
    build: .
    environment:
      - DATABASE_URL=postgresql://postgres:postgres@db:5432/testdb
    ports:
      - "3000:3000"
    depends_on:
      - db

  db:
    image: postgres:15
    environment:
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=testdb
    ports:
      - "5432:5432"

Run tests:

docker-compose -f docker-compose.test.yml up --abort-on-container-exit --exit-code-from playwright

Best Practices and Common Patterns

1. Don't Over-Test

// ❌ BAD: Testing implementation details
test('counter increments state variable', async ({ page }) => {
  await page.goto('/counter');

// Don't test internal state const state = await page.evaluate(() => window.APP_STATE.count); expect(state).toBe(0); });

// ✅ GOOD: Test user-facing behavior test('counter displays incremented value', async ({ page }) => { await page.goto('/counter');

await expect(page.getByTestId('count')).toHaveText('0'); await page.getByRole('button', { name: 'Increment' }).click(); await expect(page.getByTestId('count')).toHaveText('1'); });

2. Use Fixtures for Common Setup

// tests/fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';

type MyFixtures = { loginPage: LoginPage; dashboardPage: DashboardPage; authenticatedPage: Page; };

export const test = base.extend<MyFixtures>({ loginPage: async ({ page }, use) => { await use(new LoginPage(page)); },

dashboardPage: async ({ page }, use) => { await use(new DashboardPage(page)); },

authenticatedPage: async ({ page, loginPage }, use) => { await loginPage.goto(); await loginPage.login('user@example.com', 'password'); await use(page); }, });

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

// tests/dashboard.spec.ts
import { test, expect } from './fixtures';

test('dashboard shows user info', async ({ authenticatedPage, dashboardPage }) => {
  // Already logged in via fixture
  await dashboardPage.goto();
  await dashboardPage.expectUserName('Test User');
});

3. Isolate Tests

// ❌ BAD: Tests depend on each other
let userId: string;

test('create user', async ({ page }) => { // ... userId = await getCreatedUserId(); });

test('update user', async ({ page }) => { // Breaks if first test fails await updateUser(userId); });

// ✅ GOOD: Each test is independent test('create user', async ({ page, apiClient }) => { // Create user via API if needed for this specific test });

test('update user', async ({ page, apiClient }) => { // Create user via API, then test update const user = await apiClient.createUser({ name: 'Test' }); await updateUser(user.id); });

4. Smart Waiting

// ❌ BAD: Arbitrary sleeps
test('wait for data to load', async ({ page }) => {
  await page.goto('/dashboard');
  await page.waitForTimeout(5000); // Flaky!
  await expect(page.getByText('Data')).toBeVisible();
});

// ✅ GOOD: Wait for specific conditions test('wait for data to load', async ({ page }) => { await page.goto('/dashboard');

// Wait for loading spinner to disappear await expect(page.getByTestId('loading')).not.toBeVisible();

// Wait for data to appear await expect(page.getByText('Data')).toBeVisible(); });

// ✅ ALSO GOOD: Wait for network request test('wait for API response', async ({ page }) => { await page.goto('/dashboard');

// Wait for specific API call await page.waitForResponse(response => response.url().includes('/api/data') && response.status() === 200 );

await expect(page.getByText('Data')).toBeVisible(); });

5. Group Related Tests

test.describe('User Profile', () => {
  test.beforeEach(async ({ page }) => {
    // Runs before each test in this group
    await page.goto('/profile');
  });

test('displays user information', async ({ page }) => { // Test 1 });

test('allows editing profile', async ({ page }) => { // Test 2 });

test.describe('Profile Picture', () => { test('uploads new picture', async ({ page }) => { // Nested group });

test('removes picture', async ({ page }) =&gt; {
  // Nested group
});

}); });

Troubleshooting Common Issues

Flaky Tests

Symptom: Tests pass sometimes, fail other times

Solutions:

// 1. Use built-in waiting
await expect(element).toBeVisible(); // Auto-waits up to timeout

// 2. Increase timeout for slow operations await expect(element).toBeVisible({ timeout: 10000 });

// 3. Wait for network idle await page.goto('/dashboard', { waitUntil: 'networkidle' });

// 4. Wait for specific request await page.waitForResponse(resp => resp.url().includes('/api/data'));

// 5. Retry failed tests // In playwright.config.ts retries: process.env.CI ? 2 : 0

Element Not Found

// Debug: Take screenshot when element not found
test('find element', async ({ page }) => {
  await page.goto('/');

try { await expect(page.getByTestId('my-element')).toBeVisible(); } catch (error) { await page.screenshot({ path: 'debug-screenshot.png' }); throw error; } });

// Use multiple locator strategies const element = page.getByTestId('submit') .or(page.getByRole('button', { name: 'Submit' })) .or(page.locator('button[type="submit"]'));

Slow Tests

// 1. Use API for setup (not UI)
test.beforeEach(async ({ request }) => {
  await request.post('/api/test/seed'); // Fast
  // vs navigating through UI forms (slow)
});

// 2. Reuse authentication state // See "Authentication and Session Management" section

// 3. Run tests in parallel // In playwright.config.ts fullyParallel: true, workers: 4

// 4. Use faster browser (Chromium vs WebKit) // 5. Reduce video/screenshot collection use: { video: 'retain-on-failure', // Not 'on' screenshot: 'only-on-failure', }

Conclusion

Playwright provides a robust, modern solution for end-to-end testing in 2025. By following the patterns in this guide—Page Object Model for maintainability, authentication state reuse for speed, API-based setup for reliability, and comprehensive CI/CD integration—you can build a test suite that catches bugs before production while remaining fast and maintainable.

Key Takeaways:

  1. Use semantic locators (roles, labels, text) over fragile CSS selectors
  2. Leverage auto-waiting instead of manual sleeps
  3. Reuse authentication state across tests
  4. Set up via APIs, test via UI
  5. Implement Page Object Model for maintainability
  6. Run tests in parallel for speed
  7. Integrate with CI/CD from day one
  8. Use trace viewer for debugging failures

With Playwright's powerful features and these production-ready patterns, your E2E tests become a valuable safety net rather than a maintenance burden.

Additional Resources

  • Official Documentation: https://playwright.dev/
  • GitHub Repository: https://github.com/microsoft/playwright
  • Discord Community: https://aka.ms/playwright/discord
  • Best Practices Guide: https://playwright.dev/docs/best-practices
  • CI Examples: https://playwright.dev/docs/ci
  • Test Generator: Run npx playwright codegen to generate tests interactively

Found this helpful? Share it!

Related Articles

S

Written by StaticBlock Editorial

StaticBlock Editorial is a technical writer and software engineer specializing in web development, performance optimization, and developer tooling.