React E2E Testing with Playwright (2026)

Playwright is the gold standard for end-to-end testing of React applications. It runs real browsers (Chromium, Firefox, WebKit) in parallel, handles async UIs without manual waits, and comes with a built-in test runner, tracing, screenshot comparison and component testing. This guide covers production Playwright patterns for React and Next.js apps.

Setup

npm init playwright@latest

# Installs: @playwright/test, browsers, playwright.config.ts, example tests
// 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'], ['list']],

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

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'mobile', use: { ...devices['iPhone 14'] } },
  ],

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

Locators

Playwright locators are auto-waiting and auto-retrying. Prefer role-based locators — they are the most resilient and match how assistive technologies find elements:

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

test('locator examples', async ({ page }) => {
  await page.goto('/dashboard')

  // Role locators — most resilient (match ARIA semantics)
  const submitBtn = page.getByRole('button', { name: /submit/i })
  const emailInput = page.getByRole('textbox', { name: /email/i })
  const heading = page.getByRole('heading', { name: 'Dashboard', level: 1 })
  const navLink = page.getByRole('link', { name: 'Settings' })

  // Text locators
  const label = page.getByText('Total Revenue')
  const exactLabel = page.getByText('Total Revenue', { exact: true })

  // Label locators — finds input by its associated label
  const passwordInput = page.getByLabel('Password')

  // Placeholder locators
  const searchInput = page.getByPlaceholder('Search users...')

  // Test ID — last resort, for components with no accessible name
  const widget = page.getByTestId('revenue-widget')

  // Chaining — scope to a container
  const table = page.getByRole('table')
  const firstRow = table.getByRole('row').first()
  const nameCell = firstRow.getByRole('cell').first()

  // nth() — zero-indexed
  const secondCard = page.getByRole('article').nth(1)
})

Assertions

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

  // Element visibility
  await expect(page.getByRole('form')).toBeVisible()
  await expect(page.getByText('Loading...')).toBeHidden()

  // Text content
  await expect(page.getByRole('heading')).toHaveText('Sign In')
  await expect(page.getByRole('heading')).toContainText('Sign')

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

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

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

  // Attribute
  await expect(page.getByRole('button', { name: 'Submit' })).toBeDisabled()
  await expect(page.getByRole('checkbox')).toBeChecked()

  // CSS class
  await expect(page.getByRole('tab', { name: 'Overview' })).toHaveClass(/active/)

  // Wait for network idle after action
  await page.getByRole('button', { name: 'Load Data' }).click()
  await expect(page.getByRole('table')).toBeVisible()   // Auto-waits up to 30s
})

Page Object Model

Page Object Model encapsulates page interactions into reusable classes — tests become readable prose:

// e2e/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

  constructor(page: Page) {
    this.page = page
    this.emailInput = page.getByLabel('Email')
    this.passwordInput = page.getByLabel('Password')
    this.submitButton = page.getByRole('button', { name: /sign in/i })
    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()
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message)
  }
}

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

test.describe('Authentication', () => {
  test('successful login redirects to dashboard', async ({ page }) => {
    const loginPage = new LoginPage(page)
    await loginPage.goto()
    await loginPage.login('user@example.com', 'password123')
    await expect(page).toHaveURL('/dashboard')
  })

  test('wrong password shows error', async ({ page }) => {
    const loginPage = new LoginPage(page)
    await loginPage.goto()
    await loginPage.login('user@example.com', 'wrong')
    await loginPage.expectError('Invalid credentials')
  })
})

Authentication

// e2e/fixtures/auth.ts — save auth state once, reuse across tests
import { test as base, Page } from '@playwright/test'

export const test = base.extend<{ authenticatedPage: Page }>({
  authenticatedPage: async ({ page }, use) => {
    // Log in once per worker
    await page.goto('/login')
    await page.getByLabel('Email').fill('admin@example.com')
    await page.getByLabel('Password').fill('secret')
    await page.getByRole('button', { name: 'Sign In' }).click()
    await page.waitForURL('/dashboard')
    await use(page)
  },
})

// playwright.config.ts — global setup for auth state
import { chromium } from '@playwright/test'

async function globalSetup() {
  const browser = await chromium.launch()
  const page = await browser.newPage()
  await page.goto('http://localhost:3000/login')
  await page.getByLabel('Email').fill('admin@example.com')
  await page.getByLabel('Password').fill('secret')
  await page.getByRole('button', { name: 'Sign In' }).click()
  await page.waitForURL('**/dashboard')
  // Save storage state (cookies + localStorage)
  await page.context().storageState({ path: 'e2e/.auth/admin.json' })
  await browser.close()
}

export default globalSetup

// In playwright.config.ts
storageState: 'e2e/.auth/admin.json'   // Reuse auth for all tests

API Mocking

test('shows empty state when no products', async ({ page }) => {
  // Intercept API call and return custom response
  await page.route('/api/products', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ products: [], total: 0 }),
    })
  })

  await page.goto('/products')
  await expect(page.getByText('No products found')).toBeVisible()
})

test('handles API errors gracefully', async ({ page }) => {
  await page.route('/api/users', route => route.fulfill({
    status: 500,
    body: JSON.stringify({ error: 'Internal Server Error' }),
  }))

  await page.goto('/users')
  await expect(page.getByRole('alert')).toContainText('Failed to load users')
})

// Abort specific requests
await page.route('**/*.{png,jpg,webp}', route => route.abort())  // Block images for speed

// Modify request before it goes out
await page.route('/api/checkout', async route => {
  const request = route.request()
  const body = JSON.parse(request.postData() ?? '{}')
  await route.continue({ postData: JSON.stringify({ ...body, coupon: 'TEST10' }) })
})

Visual Comparisons

test('dashboard matches snapshot', async ({ page }) => {
  await page.goto('/dashboard')
  await page.waitForLoadState('networkidle')

  // Full page screenshot comparison
  await expect(page).toHaveScreenshot('dashboard.png', {
    maxDiffPixels: 100,   // Allow minor anti-aliasing differences
  })

  // Element screenshot
  const chart = page.getByTestId('revenue-chart')
  await expect(chart).toHaveScreenshot('revenue-chart.png')
})

// Update snapshots: npx playwright test --update-snapshots

Component Testing

// Mount React components directly — no browser navigation needed
// @playwright/experimental-ct-react

import { test, expect } from '@playwright/experimental-ct-react'
import { Button } from '@/components/Button'

test('Button renders and handles click', async ({ mount }) => {
  let clicked = false
  const component = await mount(
    <Button variant="primary" onClick={() => { clicked = true }}>
      Submit
    </Button>
  )

  await expect(component).toBeVisible()
  await expect(component).toHaveText('Submit')
  await component.click()
  expect(clicked).toBe(true)
})

CI Integration

# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npm run build
      - run: npx playwright test
        env:
          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7