E2E Testing React Apps with Playwright: A Complete Guide

Shunku

End-to-end (E2E) tests verify your application works correctly from a user's perspective, testing the full stack including the browser, API, and database. Playwright is a modern E2E testing framework that makes writing reliable tests straightforward.

Why E2E Testing?

flowchart TD
    subgraph Testing["Testing Pyramid"]
        A[E2E Tests] --> B[Integration Tests]
        B --> C[Unit Tests]
    end

    subgraph Coverage["What They Test"]
        D[Full user flows]
        E[Component interactions]
        F[Individual functions]
    end

    A -.-> D
    B -.-> E
    C -.-> F

    style A fill:#ef4444,color:#fff
    style B fill:#f59e0b,color:#fff
    style C fill:#10b981,color:#fff

E2E tests:

  • Test real user workflows
  • Catch integration issues
  • Verify critical paths work
  • Run in real browsers

Setting Up Playwright

Installation

npm init playwright@latest

This creates:

  • playwright.config.ts - Configuration file
  • tests/ - Test directory
  • tests-examples/ - Example tests

Basic Configuration

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

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',

  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],

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

Writing Your First Test

// e2e/home.spec.ts
import { test, expect } from '@playwright/test';

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

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

test('navigates to about page', async ({ page }) => {
  await page.goto('/');

  await page.click('text=About');

  await expect(page).toHaveURL('/about');
  await expect(page.locator('h1')).toContainText('About Us');
});

Running Tests

# Run all tests
npx playwright test

# Run in headed mode (see the browser)
npx playwright test --headed

# Run specific file
npx playwright test home.spec.ts

# Run in debug mode
npx playwright test --debug

# Show HTML report
npx playwright show-report

Locators and Selectors

Playwright provides various ways to locate elements:

Recommended Locators

// By role (accessibility-first)
page.getByRole('button', { name: 'Submit' });
page.getByRole('heading', { level: 1 });
page.getByRole('textbox', { name: 'Email' });

// By label
page.getByLabel('Email address');

// By placeholder
page.getByPlaceholder('Enter your email');

// By text
page.getByText('Welcome');
page.getByText(/welcome/i); // Case-insensitive

// By test ID
page.getByTestId('submit-button');

CSS and XPath (Less Preferred)

// CSS selector
page.locator('.submit-btn');
page.locator('#email-input');

// XPath
page.locator('xpath=//button[@type="submit"]');

Chaining Locators

// Find button within a specific section
const section = page.locator('section.user-profile');
const editButton = section.getByRole('button', { name: 'Edit' });

// Or chain directly
page.locator('section.user-profile').getByRole('button', { name: 'Edit' });

Actions

Click Actions

// Basic click
await page.click('button');
await page.getByRole('button').click();

// Double click
await page.dblclick('button');

// Right click
await page.click('button', { button: 'right' });

// Click with modifiers
await page.click('button', { modifiers: ['Shift'] });

Form Interactions

// Type text
await page.fill('input[name="email"]', 'test@example.com');
await page.getByLabel('Email').fill('test@example.com');

// Clear and type
await page.getByLabel('Email').clear();
await page.getByLabel('Email').fill('new@example.com');

// Type with delay (simulates real typing)
await page.getByLabel('Email').pressSequentially('test@example.com', { delay: 100 });

// Select option
await page.selectOption('select[name="country"]', 'us');
await page.getByLabel('Country').selectOption({ label: 'United States' });

// Check/uncheck
await page.check('input[type="checkbox"]');
await page.uncheck('input[type="checkbox"]');
await page.getByLabel('Accept terms').check();

Keyboard Actions

// Press key
await page.keyboard.press('Enter');
await page.keyboard.press('Control+a');

// Type text
await page.keyboard.type('Hello');

// Key combinations
await page.keyboard.down('Shift');
await page.keyboard.press('ArrowDown');
await page.keyboard.up('Shift');

Assertions

Page Assertions

// URL
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveURL(/.*dashboard/);

// Title
await expect(page).toHaveTitle('Dashboard | My App');
await expect(page).toHaveTitle(/Dashboard/);

Element Assertions

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

// Visibility
await expect(button).toBeVisible();
await expect(button).toBeHidden();

// Enabled/Disabled
await expect(button).toBeEnabled();
await expect(button).toBeDisabled();

// Text content
await expect(button).toHaveText('Submit');
await expect(button).toContainText('Sub');

// Attributes
await expect(button).toHaveAttribute('type', 'submit');

// CSS
await expect(button).toHaveClass(/primary/);
await expect(button).toHaveCSS('background-color', 'rgb(0, 128, 0)');

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

// Input values
await expect(page.getByLabel('Email')).toHaveValue('test@example.com');

Soft Assertions

Continue test execution even if assertion fails:

await expect.soft(page.getByText('Error')).not.toBeVisible();
await expect.soft(page.getByRole('button')).toBeEnabled();

// Test continues even if above assertions fail
await page.click('button');

Testing User Flows

Login Flow

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Authentication', () => {
  test('user can log in', async ({ page }) => {
    await page.goto('/login');

    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('password123');
    await page.getByRole('button', { name: 'Log in' }).click();

    // Should redirect to dashboard
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByText('Welcome back')).toBeVisible();
  });

  test('shows error for invalid credentials', async ({ page }) => {
    await page.goto('/login');

    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('wrong-password');
    await page.getByRole('button', { name: 'Log in' }).click();

    await expect(page.getByRole('alert')).toContainText('Invalid credentials');
    await expect(page).toHaveURL('/login');
  });
});

Shopping Cart Flow

test('complete purchase flow', async ({ page }) => {
  // Browse products
  await page.goto('/products');
  await page.getByRole('link', { name: /laptop/i }).click();

  // Add to cart
  await page.getByRole('button', { name: 'Add to Cart' }).click();
  await expect(page.getByText('Added to cart')).toBeVisible();

  // Go to cart
  await page.getByRole('link', { name: 'Cart (1)' }).click();
  await expect(page).toHaveURL('/cart');

  // Proceed to checkout
  await page.getByRole('button', { name: 'Checkout' }).click();

  // Fill shipping info
  await page.getByLabel('Address').fill('123 Main St');
  await page.getByLabel('City').fill('New York');
  await page.getByRole('button', { name: 'Continue' }).click();

  // Complete payment
  await page.getByLabel('Card number').fill('4242424242424242');
  await page.getByRole('button', { name: 'Pay' }).click();

  // Verify success
  await expect(page.getByRole('heading')).toContainText('Order Confirmed');
});

Page Object Model

Organize tests with Page Object pattern:

// e2e/pages/LoginPage.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: 'Log in' });
    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();
  }
}
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test('user can log in', async ({ page }) => {
  const loginPage = new LoginPage(page);

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

  await expect(page).toHaveURL('/dashboard');
});

Handling Authentication

Store Authentication State

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

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

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

  await expect(page).toHaveURL('/dashboard');

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

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

API Testing

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

test('API returns users', async ({ request }) => {
  const response = await request.get('/api/users');

  expect(response.ok()).toBeTruthy();

  const users = await response.json();
  expect(users).toHaveLength(10);
  expect(users[0]).toHaveProperty('email');
});

test('creates new user', async ({ request }) => {
  const response = await request.post('/api/users', {
    data: {
      name: 'Alice',
      email: 'alice@example.com',
    },
  });

  expect(response.status()).toBe(201);

  const user = await response.json();
  expect(user.name).toBe('Alice');
});

Visual Testing

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

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

  // Element screenshot
  await expect(page.getByRole('navigation')).toHaveScreenshot('nav.png');
});

Update screenshots:

npx playwright test --update-snapshots

Best Practices

1. Use Reliable Selectors

// Fragile - depends on structure
page.locator('div > div > button');

// Robust - uses accessibility
page.getByRole('button', { name: 'Submit' });

2. Wait for Elements Properly

// Auto-waiting built in
await page.getByRole('button').click(); // Waits automatically

// Explicit wait when needed
await page.waitForSelector('.dynamic-content');
await page.waitForLoadState('networkidle');

3. Isolate Tests

test.beforeEach(async ({ page }) => {
  // Reset state before each test
  await page.goto('/');
});

4. Use Test Hooks

test.beforeAll(async () => {
  // Run once before all tests
});

test.afterEach(async ({ page }) => {
  // Clean up after each test
});

Summary

Concept Description
page.goto() Navigate to URL
page.getByRole() Find by accessibility role
page.click() Click element
page.fill() Enter text in input
expect(page) Page assertions
expect(locator) Element assertions

Key takeaways:

  • Use accessibility-based selectors (getByRole, getByLabel)
  • Playwright has built-in auto-waiting for elements
  • Use Page Object Model for maintainable tests
  • Store authentication state to speed up tests
  • Run tests in multiple browsers with projects
  • Use visual testing for UI regression detection

E2E tests give confidence that your application works from the user's perspective. Playwright makes writing reliable, fast E2E tests straightforward with its modern API and powerful features.

References