Playwright E2E Testing Practical Guide - Modern Browser Test Automation

intermediate | 60 min read | 2025.12.02

Playwright is a next-generation browser test automation tool by Microsoft. It supports Chromium, Firefox, and WebKit, enabling fast and reliable E2E (End-to-End) testing. This article covers Playwright from basics to practical testing patterns through real code.

What You’ll Learn

  1. Playwright environment setup
  2. Writing basic tests
  3. Page Object Model implementation
  4. Authentication flow testing
  5. API mocking and interception
  6. CI/CD integration

Environment Setup

Installation

# For new projects
npm init playwright@latest

# Adding to existing project
npm install -D @playwright/test
npx playwright install

The following options will be displayed during initialization.

? Do you want to use TypeScript or JavaScript? › TypeScript
? Where to put your end-to-end tests? › tests
? Add a GitHub Actions workflow? › true
? Install Playwright browsers? › true

Project Structure

project/
├── playwright.config.ts      # Configuration file
├── tests/
│   ├── example.spec.ts       # Test files
│   └── fixtures/             # Custom fixtures
│       └── auth.ts
├── pages/                    # Page Objects
│   ├── login.page.ts
│   └── dashboard.page.ts
├── test-results/             # Test results (auto-generated)
└── playwright-report/        # HTML report (auto-generated)

Configuration File

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

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

  // Parallel execution
  fullyParallel: true,

  // Set retries in CI environment
  retries: process.env.CI ? 2 : 0,

  // Number of workers
  workers: process.env.CI ? 1 : undefined,

  // Reporter settings
  reporter: [
    ['html', { open: 'never' }],
    ['list'],
  ],

  // Common settings
  use: {
    // Base URL
    baseURL: 'http://localhost:3000',

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

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

    // Video recording
    video: 'on-first-retry',
  },

  // Browser settings
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    // Mobile devices
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 13'] },
    },
  ],

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

Writing Basic Tests

First Test

// tests/example.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Homepage', () => {
  test('title is displayed correctly', async ({ page }) => {
    await page.goto('/');

    // Verify title
    await expect(page).toHaveTitle(/My App/);
  });

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

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

    // Verify URL
    await expect(page).toHaveURL('/about');

    // Verify content
    await expect(page.getByRole('heading', { level: 1 })).toHaveText('About Us');
  });
});

Locator Strategy

In Playwright, using robust locators (element identification methods) is important.

// Recommended: User-perspective locators
page.getByRole('button', { name: 'Submit' });           // ARIA role
page.getByLabel('Email');                               // Label
page.getByPlaceholder('example@email.com');             // Placeholder
page.getByText('Login successful');                     // Text
page.getByTestId('submit-button');                      // data-testid

// Not recommended: Implementation-dependent locators
page.locator('#submit-btn');                            // ID (easily changed)
page.locator('.btn-primary');                           // Class (breaks on style changes)
page.locator('div > button:nth-child(2)');              // Structure dependent

Assertions

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

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

  // Visibility
  await expect(page.getByTestId('loading')).toBeVisible();
  await expect(page.getByTestId('loading')).not.toBeVisible();

  // Text content
  await expect(page.getByRole('heading')).toHaveText('Product List');
  await expect(page.getByRole('heading')).toContainText('Product');

  // Attributes
  await expect(page.getByRole('link')).toHaveAttribute('href', '/cart');

  // Count
  await expect(page.getByTestId('product-card')).toHaveCount(10);

  // Enabled/Disabled
  await expect(page.getByRole('button', { name: 'Buy' })).toBeEnabled();
  await expect(page.getByRole('button', { name: 'Buy' })).toBeDisabled();

  // Form values
  await expect(page.getByLabel('Quantity')).toHaveValue('1');
});

Form Testing

Input and Submit

test.describe('Contact Form', () => {
  test('form submission succeeds', async ({ page }) => {
    await page.goto('/contact');

    // Form input
    await page.getByLabel('Name').fill('John Doe');
    await page.getByLabel('Email').fill('john@example.com');
    await page.getByLabel('Subject').selectOption('inquiry');
    await page.getByLabel('Message').fill('Enter your inquiry here.');

    // Checkbox
    await page.getByLabel('I agree to the privacy policy').check();

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

    // Verify success message
    await expect(page.getByRole('alert')).toHaveText('Submission completed');
  });

  test('validation errors are displayed', async ({ page }) => {
    await page.goto('/contact');

    // Submit empty
    await page.getByRole('button', { name: 'Submit' }).click();

    // Verify error messages
    await expect(page.getByText('Name is required')).toBeVisible();
    await expect(page.getByText('Email is required')).toBeVisible();
  });
});

Page Object Model

For large-scale tests, use the Page Object Model pattern to improve maintainability.

Creating Page Objects

// pages/login.page.ts
import { 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: '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.submitButton.click();
  }
}

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

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

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

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

Usage in Tests

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

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

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

    // Verify redirect to dashboard
    await expect(page).toHaveURL('/dashboard');
    await expect(dashboardPage.welcomeMessage).toContainText('Welcome');
  });

  test('error is displayed with incorrect credentials', async ({ page }) => {
    const loginPage = new LoginPage(page);

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

    await expect(loginPage.errorMessage).toHaveText('Email or password is incorrect');
    await expect(page).toHaveURL('/login');
  });
});

Authentication State Management

Saving and Reusing Authentication State

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

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

setup('authentication', async ({ page }) => {
  // Login process
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Login' }).click();

  // Verify login success
  await expect(page).toHaveURL('/dashboard');

  // Save authentication state
  await page.context().storageState({ path: authFile });
});
// playwright.config.ts
export default defineConfig({
  projects: [
    // Authentication setup
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },

    // Authenticated tests
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

Custom Fixtures

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

type AuthFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
  authenticatedPage: DashboardPage;
};

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

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

  authenticatedPage: async ({ page }, use) => {
    // Auto login
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('user@example.com', 'password123');

    const dashboardPage = new DashboardPage(page);
    await use(dashboardPage);
  },
});

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

test('authenticated user can access dashboard', async ({ authenticatedPage }) => {
  await expect(authenticatedPage.welcomeMessage).toBeVisible();
});

API Mocking and Interception

Mocking API Responses

test.describe('Product List', () => {
  test('fetches and displays data from API', async ({ page }) => {
    // Mock API response
    await page.route('**/api/products', async route => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify([
          { id: 1, name: 'Product A', price: 1000 },
          { id: 2, name: 'Product B', price: 2000 },
          { id: 3, name: 'Product C', price: 3000 },
        ]),
      });
    });

    await page.goto('/products');

    // Verify mocked data is displayed
    await expect(page.getByTestId('product-card')).toHaveCount(3);
    await expect(page.getByText('Product A')).toBeVisible();
  });

  test('error message is displayed on API error', async ({ page }) => {
    await page.route('**/api/products', async route => {
      await route.fulfill({
        status: 500,
        contentType: 'application/json',
        body: JSON.stringify({ error: 'Internal Server Error' }),
      });
    });

    await page.goto('/products');

    await expect(page.getByText('Failed to fetch data')).toBeVisible();
  });
});

Request Interception and Verification

test('correct data is sent to API on form submission', async ({ page }) => {
  let capturedRequest: any = null;

  // Intercept request
  await page.route('**/api/contact', async route => {
    capturedRequest = route.request().postDataJSON();
    await route.fulfill({ status: 200, body: JSON.stringify({ success: true }) });
  });

  await page.goto('/contact');
  await page.getByLabel('Name').fill('John Doe');
  await page.getByLabel('Email').fill('john@example.com');
  await page.getByRole('button', { name: 'Submit' }).click();

  // Verify submitted data
  expect(capturedRequest).toEqual({
    name: 'John Doe',
    email: 'john@example.com',
  });
});

Visual Regression Testing

Screenshot Comparison

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

  // Full page screenshot
  await expect(page).toHaveScreenshot('homepage.png');
});

test('component screenshot', async ({ page }) => {
  await page.goto('/components');

  // Specific element only
  const card = page.getByTestId('feature-card');
  await expect(card).toHaveScreenshot('feature-card.png');
});

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

  // Desktop
  await page.setViewportSize({ width: 1280, height: 720 });
  await expect(page).toHaveScreenshot('homepage-desktop.png');

  // Tablet
  await page.setViewportSize({ width: 768, height: 1024 });
  await expect(page).toHaveScreenshot('homepage-tablet.png');

  // Mobile
  await page.setViewportSize({ width: 375, height: 667 });
  await expect(page).toHaveScreenshot('homepage-mobile.png');
});

CI/CD Integration

GitHub Actions Configuration

# .github/workflows/playwright.yml
name: Playwright Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    timeout-minutes: 60
    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: Run Playwright tests
        run: npx playwright test

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

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

Debugging Techniques

Debugging with UI Mode

# Run in UI mode
npx playwright test --ui

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

Trace Viewer

// playwright.config.ts
use: {
  trace: 'on-first-retry',  // Trace only on failure
  // trace: 'on',           // Always trace
}
# Open trace file
npx playwright show-trace test-results/example-spec-ts/trace.zip

Displaying console.log

test('debug output', async ({ page }) => {
  // Capture browser console.log
  page.on('console', msg => console.log('Browser log:', msg.text()));

  await page.goto('/');

  // Playwright pause (debugger)
  await page.pause();
});

Test Execution Commands

# Run all tests
npx playwright test

# Specific file
npx playwright test tests/login.spec.ts

# Specific browser
npx playwright test --project=chromium

# Headed mode (display browser)
npx playwright test --headed

# Control parallelism
npx playwright test --workers=4

# Re-run failed tests only
npx playwright test --last-failed

# Filter by tag
npx playwright test --grep @smoke

# Open HTML report
npx playwright show-report

Summary

Key points for E2E testing with Playwright.

Best Practices

  1. Locators: Prefer user-perspective locators (getByRole, getByLabel)
  2. Page Object: Adopt POM for maintainability in large-scale tests
  3. Authentication: Reuse authentication state with storageState
  4. API Mocking: Backend-independent tests with route()
  5. CI Integration: Automated testing with GitHub Actions

Next Steps

E2E testing greatly improves development quality. Leverage Playwright’s powerful features to build reliable tests.

← Back to list