End-to-End Testing React Apps with Playwright

Learn to write reliable end-to-end tests with Playwright that automate a real browser through complete user journeys — login, navigation, form submission — including API mocking, screenshots, trace capture for debugging, CI integration, and strategies for eliminating flaky tests.

Component tests verify a piece in isolation; end-to-end (E2E) tests verify that the whole system — your React app, your routing, your build output, and often a real or mocked backend — actually works when driven through a real browser exactly as a user would drive it. Playwright is a modern browser-automation framework from Microsoft that controls Chromium, Firefox, and WebKit through a single API, auto-waits for elements to become actionable before interacting with them (eliminating most of the manual sleep() calls that plagued older tools like Selenium), and ships first-class debugging tools: screenshots, video, and 'traces' — a full timeline recording of the test run you can step through after the fact. This lesson covers writing full user-journey tests, mocking APIs at the network layer, capturing artifacts for debugging, wiring tests into CI, and the specific techniques for keeping E2E suites from becoming flaky.

Anatomy of a Playwright test and browser automation basics

A Playwright test receives a page fixture representing one browser tab. Every test gets a fresh, isolated browser context by default (its own cookies, localStorage, and cache), so tests don't leak state into each other even when run in parallel — a critical property for E2E suites, which are otherwise notorious for cross-test pollution.

Locators (page.getByRole, page.getByLabel, page.getByText, page.getByTestId) describe an element without querying for it immediately; the actual DOM lookup happens at the moment of interaction, and Playwright auto-waits — retrying the lookup and waiting for the element to be visible, stable, and enabled — before clicking, typing, or asserting. This auto-waiting is what eliminates the majority of manual timing hacks that make older Selenium suites brittle.

Locators mirror RTL's accessible-query philosophy deliberately: getByRole and getByLabel are preferred over CSS selectors for the same reason — they describe intent and survive markup refactors, and they double-check that the app is usable by assistive technology.

• page.goto(url) — navigates the browser tab • page.getByRole(role, { name }) — the primary locator, matching RTL's philosophy • page.getByLabel(text) — form fields by their associated label • locator.click() / .fill() / .check() / .selectOption() — auto-waiting interactions • expect(locator).toBeVisible() / .toHaveText() — auto-retrying, web-first assertions

Full user journeys: login and navigation

A user journey test chains multiple pages and interactions into one scenario that mirrors a real task: log in, land on the dashboard, navigate to a settings page, make a change, and see it persisted. These tests are inherently more expensive to run than unit or component tests, so a typical suite has relatively few of them, reserved for the critical paths that would be catastrophic to break (checkout, sign-up, authentication) rather than exhaustive coverage of every UI branch.

For anything requiring authentication, Playwright supports saving browser storage state (cookies + localStorage) after logging in once, then reusing that saved state across many tests via the storageState option — this avoids re-running a slow login flow in every single test file and is the standard way to keep an E2E suite fast.

Playwright's Page Object Model pattern — wrapping a page's locators and actions in a class (e.g. a LoginPage class exposing .login(email, password)) — keeps journey tests readable and centralizes selector maintenance: if a selector changes, you fix it in one place instead of in every test that touches that page.

Testing forms end-to-end

Form tests at the E2E layer focus on the full round trip: filling fields with locator.fill(), submitting, and asserting the resulting navigation or confirmation UI actually appears — including real client-side validation and, if not mocked, a real (or realistically mocked) server response. Unlike a component test, an E2E form test is also implicitly checking that your routing, global layout, and any providers (auth context, theme, i18n) are all correctly wired together in the actual built app.

Use expect(page).toHaveURL(...) after submission to confirm navigation happened, and prefer role-based locators for both fields and buttons so the test remains stable if the form's markup is restyled.

API mocking with page.route

page.route(urlPattern, handler) intercepts network requests matching a URL pattern before they reach the real network, letting you fulfill them with a canned JSON response, delay them to simulate slow networks, or abort them to simulate failures — all without needing a real backend running or being at the mercy of real backend flakiness and data.

Mocking is especially valuable for edge cases that are hard to reproduce against a real API on demand: a 500 error, an empty result set, a request that never resolves (to test a loading spinner), or a specific payload shape you want to lock in a contract against. For true full-stack confidence, keep some E2E tests hitting a real (test) backend and use mocked routes for edge cases and speed-sensitive tests.

• await page.route('/api/users', route => route.fulfill({ json: [...] })) — canned success response • await page.route('/api/users', route => route.fulfill({ status: 500 })) — simulate a server error • await page.route('**/api/users', route => route.abort()) — simulate a network failure • route.continue() — let the real request through unmodified after inspecting it

Screenshots, video, and traces for debugging

page.screenshot({ path: ... }) captures a PNG at any point in a test, useful for asserting visual regressions (via toHaveScreenshot() pixel comparison) or simply for leaving evidence when a test fails. Playwright's config can also be set to automatically capture a screenshot only on failure, keeping CI artifacts small on green runs.

Traces are Playwright's signature debugging feature: when enabled (commonly trace: 'on-first-retry' in config), a trace records a full timeline of the test — DOM snapshots at every action, network requests, console logs, and screenshots — which you open afterward with npx playwright show-trace trace.zip to scrub through the run frame by frame, exactly reproducing what happened in CI without needing to reproduce the failure locally.

Video recording (video: 'retain-on-failure') captures a literal screen recording of the browser tab, useful as a fallback when a trace alone doesn't make a timing-sensitive bug obvious.

Running in CI

Playwright is designed to run headless in CI by default, and its official GitHub Action / Docker image bundles the exact browser binaries a given Playwright version expects, avoiding the classic 'works on my machine, fails in CI' browser-version mismatch. A typical CI job installs dependencies, runs npx playwright install --with-deps to fetch browser binaries, then npx playwright test.

Because CI machines are often slower and more resource-constrained than a developer's laptop, CI configs commonly reduce parallelism (workers), set a retries count (e.g. 2) specifically for CI, and enable trace/video capture only on failure so artifacts don't bloat storage. The HTML reporter (reporter: 'html') produces a browsable report — including embedded traces — that's typically uploaded as a CI artifact for anyone to inspect after a failed run.

Flaky tests: causes and fixes

A flaky test passes and fails intermittently without any code change — the single biggest threat to an E2E suite's credibility, because once a team stops trusting failures, they start ignoring real ones too ('just rerun it'). Most flakiness in Playwright suites traces back to a handful of causes: asserting before an async UI update has settled, relying on fixed sleep() delays instead of auto-waiting assertions, tests that depend on shared/mutable backend state and interfere with each other, and animations or transitions that shift element positions mid-interaction.

Playwright's own auto-waiting and 'web-first' assertions (expect(locator).toBeVisible(), which retries under the hood) eliminate the majority of manual-timing flakiness — the fix for almost any 'sometimes fails' Playwright test is to replace a raw assertion on a snapshot value with a retrying expect(locator)... assertion, and to replace any page.waitForTimeout(ms) with waiting on a specific condition (an element appearing, a network response, a URL change) instead.

For unavoidable environmental flakiness, Playwright's retries config re-runs a failed test automatically before marking it failed, and combining that with trace: 'on-first-retry' means you get full debugging artifacts exactly on the runs that failed once, without paying the tracing cost on every green run.

Core Playwright test shape

Every Playwright test fixture (page, context, browser) is provided by the test function's argument object. expect(locator)... assertions are auto-retrying by default — no manual polling needed. Tests run in isolated browser contexts by default, so ordering between test files should never matter.

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

test('descriptive name of the user journey', async ({ page }) => {
  await page.goto('/login');

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

  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByRole('heading', { name: 'Welcome, Ada' })).toBeVisible();
});

Code Examples

The setup project logs in once and persists cookies/localStorage to a file. Every subsequent test that opts into that storageState starts already authenticated, which turns a slow multi-step login into a single page.goto, dramatically speeding up a suite with many authenticated tests.

test.use({ storageState: 'playwright/.auth/user.json' });

This test chains three real pages (dashboard -> settings -> back to a heading reflecting the change) and exercises real client-side routing, form handling, and the confirmation UI together — exactly the kind of integrated confidence an E2E test provides that a component test cannot.

await expect(page.getByRole('status')).toHaveText('Profile updated');

page.route intercepts requests matching the URL glob before they hit the real network. Each test fulfills the same endpoint differently — error, empty array, and an artificially delayed response — to deterministically exercise error, empty, and loading UI states without needing a real backend to misbehave on command.

await page.route('**/api/orders', (route) => route.fulfill({ status: 500, json: { message: 'Internal error' } }));

trace: 'on-first-retry' means tracing overhead is paid only on the retry of a test that just failed — exactly when you need the debugging data — not on every passing run. toHaveScreenshot performs a pixel-diff visual regression check against a committed baseline image. After a CI failure, npx playwright show-trace trace.zip opens an interactive timeline: DOM snapshots, network calls, and console logs for every action in the failed run.

trace: 'on-first-retry', page.waitForTimeout with a fixed duration is a guess: too short and the test fails under CI load, too long and the whole suite slows down. expect(locator).toHaveText(...) is a web-first assertion that polls the DOM until the condition is true or a timeout elapses, adapting automatically to however long the update actually takes.

Common mistake: Using page.waitForTimeout(ms) as a substitute for waiting on a real, observable condition — the single most common source of flaky Playwright suites.

// auth.setup.ts — runs once before the suite
import { test as setup } from '@playwright/test';

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

  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});

// dashboard.spec.ts — reuses saved auth, skips the login flow entirely
import { test, expect } from '@playwright/test';

test.use({ storageState: 'playwright/.auth/user.json' });

test('shows the dashboard for a logged-in user', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page.getByRole('heading', { name: 'Welcome, Ada' })).toBeVisible();
});
import { test, expect } from '@playwright/test';

test.use({ storageState: 'playwright/.auth/user.json' });

test('user can update their profile name', async ({ page }) => {
  await page.goto('/dashboard');

  await page.getByRole('link', { name: 'Settings' }).click();
  await expect(page).toHaveURL('/settings');

  const nameField = page.getByLabel('Display name');
  await nameField.fill('Ada Lovelace');
  await page.getByRole('button', { name: 'Save changes' }).click();

  await expect(page.getByRole('status')).toHaveText('Profile updated');
  await expect(page.getByRole('heading', { name: 'Ada Lovelace' })).toBeVisible();
});
import { test, expect } from '@playwright/test';

test('shows an error banner when the orders API fails', async ({ page }) => {
  await page.route('**/api/orders', (route) =>
    route.fulfill({ status: 500, json: { message: 'Internal error' } })
  );

  await page.goto('/orders');

  await expect(page.getByRole('alert')).toHaveText(/couldn't load orders/i);
});

test('shows an empty state with zero orders', async ({ page }) => {
  await page.route('**/api/orders', (route) => route.fulfill({ json: [] }));

  await page.goto('/orders');

  await expect(page.getByText(/no orders yet/i)).toBeVisible();
});

test('shows a loading spinner while the request is slow', async ({ page }) => {
  await page.route('**/api/orders', async (route) => {
    await new Promise((r) => setTimeout(r, 2000));
    await route.fulfill({ json: [] });
  });

  await page.goto('/orders');

  await expect(page.getByRole('status')).toBeVisible();
});
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 4 : undefined,
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  reporter: [['html', { open: 'never' }]],
});

// inside a test, an explicit screenshot for a visual check:
test('checkout page matches its expected layout', async ({ page }) => {
  await page.goto('/checkout');
  await expect(page).toHaveScreenshot('checkout.png');
});
// FLAKY: fixed delay guesses how long an async update takes
test('flaky: adds item to cart', async ({ page }) => {
  await page.goto('/products/1');
  await page.getByRole('button', { name: 'Add to cart' }).click();
  await page.waitForTimeout(1000); // guess — sometimes too short, sometimes wastes time
  const count = await page.getByTestId('cart-count').textContent();
  expect(count).toBe('1');
});

// RELIABLE: retrying, web-first assertion waits for the real condition
test('reliable: adds item to cart', async ({ page }) => {
  await page.goto('/products/1');
  await page.getByRole('button', { name: 'Add to cart' }).click();
  await expect(page.getByTestId('cart-count')).toHaveText('1');
});

Playwright trace debugging loop

Tracing only on the retry keeps green-run overhead near zero while guaranteeing debugging data for every real failure.

CI run fails
       |
       v
  retries: 2 triggers a re-run
       |
       v
  trace: 'on-first-retry' captures
  DOM snapshots + network + console
  for that retry only
       |
       v
  trace.zip uploaded as CI artifact
       |
       v
  npx playwright show-trace trace.zip
       |
       v
  Step through every action locally,
  see exact DOM + network state
  at the moment of failure

Important Rules

Prefer getByRole/getByLabel locators over CSS selectors, mirroring RTL's accessible-query philosophy, so tests survive markup refactors.

Reserve full multi-page E2E journeys for critical paths (auth, checkout, sign-up) — keep the suite small and fast, not exhaustive.

Reuse authenticated storageState across tests instead of logging in through the UI in every single test.

Mock the network with page.route for edge cases (errors, empty states, slow responses) that are hard to trigger reliably against a real backend.

Never use page.waitForTimeout(ms) as a substitute for a real condition — always wait on a locator, a URL, or a network response.

Enable trace capture at least on-first-retry so every genuine CI failure leaves a fully replayable debugging artifact.

Give the suite a nonzero retries count in CI (but not locally) to absorb genuine environmental flakiness without masking real, reproducible bugs.

Why this matters in production

E2E suites are usually the last line of defense before a regression reaches users — they're what catches a broken deploy where the app builds fine, every unit and component test passes, but a routing change silently broke the checkout flow. Because they're also the slowest and most expensive tests to run and maintain, mature teams keep them deliberately small (the 'testing trophy' shape: many unit/component tests, fewer integration tests, a handful of E2E journeys for only the business-critical paths) and invest heavily in eliminating flakiness, because a suite developers don't trust gets ignored — silently defeating its entire purpose. Playwright's trace viewer is frequently cited as the single biggest factor in that trust: instead of an unreproducible 'failed in CI, passed locally' report, engineers get an exact, replayable recording of what the browser actually did.

await page.waitForTimeout(2000); expect(await locator.textContent()).toBe('Saved');

await expect(locator).toHaveText('Saved');

A fixed sleep either wastes time (slowing the whole suite) or isn't long enough under CI load (causing a flaky failure). The web-first assertion polls until the real condition is met or a sensible timeout elapses.

test('login', async ({ page }) => { /* full UI login flow */ }); // repeated in every spec file

Run a setup project that logs in once and saves storageState; reuse it via test.use({ storageState })

Re-running a full UI login in every single test multiplies suite runtime and multiplies the chance of a flaky login step blocking unrelated tests.

page.getByRole('button', { name: 'Submit' }) Selectors tied to a UI library's generated class names or CSS-in-JS hashes break on any style/library upgrade even when the button's behavior hasn't changed at all.

Letting every test hit the real, shared staging backend with no route mocking for error/empty cases

Use page.route to deterministically fulfill error, empty, and slow-response scenarios; reserve real-backend tests for happy-path journeys

Real backends are slow, can be down, and rarely let you trigger a 500 or an empty dataset on demand — mocking those specific edge cases is the only reliable way to test them every run.

[mcq] What is the primary benefit of saving and reusing storageState for authenticated Playwright tests? A. It encrypts cookies for security B. It avoids repeating the full UI login flow in every test, speeding up the suite C. It is required for page.route to work D. It automatically retries failed tests

B. It avoids repeating the full UI login flow in every test, speeding up the suite — storageState captures cookies/localStorage after logging in once; reusing it across tests skips the slow, multi-step UI login flow for every test that needs an authenticated user, which meaningfully speeds up a large suite.

[mcq] Why is page.waitForTimeout(ms) considered an anti-pattern in Playwright tests? A. It is deprecated and throws an error B. It only works in headed mode C. It waits a fixed duration regardless of the actual condition, causing flakiness (too short) or wasted time (too long) instead of waiting on a real, observable state D. It cannot be used inside page.route handlers

C. It waits a fixed duration regardless of the actual condition, causing flakiness (too short) or wasted time (too long) instead of waiting on a real, observable state — A fixed sleep is a guess about timing rather than a wait on an actual condition. Web-first assertions like expect(locator).toHaveText(...) retry automatically until the real condition holds, which is both faster on average and far less flaky.

[mcq] What does setting trace: 'on-first-retry' in playwright.config.ts accomplish? A. It records a trace for every test run, always B. It records a trace only on a test's first retry after failing, giving full debugging data for real failures without overhead on green runs C. It disables retries entirely D. It takes a screenshot instead of a full trace

B. It records a trace only on a test's first retry after failing, giving full debugging data for real failures without overhead on green runs — on-first-retry captures the expensive trace data specifically on the retry that follows an initial failure, so passing runs stay fast and lightweight while every genuine failure still leaves a full, replayable trace.zip.

[mcq] Which technique lets you deterministically test how a component behaves when an API returns a 500 error, without depending on a real backend misbehaving on command? A. Manually taking the staging server offline during the test run B. Using page.route to intercept the request and fulfill it with { status: 500 } C. Adding a try/catch around page.goto D. Increasing the test's retries count

B. Using page.route to intercept the request and fulfill it with { status: 500 } — page.route intercepts matching network requests before they reach the real network and lets the test decide exactly how to respond — including simulating a 500 error — making error-path tests deterministic and independent of any real backend's actual behavior.

Write a Playwright test suite for a checkout flow: (1) a journey test that adds an item to the cart, navigates to checkout, fills in shipping details, and asserts the confirmation page shows an order number, using a saved storageState for an already-logged-in user; (2) a mocked test asserting that a 500 response from the payment API surfaces a role='alert' error message instead of proceeding to confirmation; (3) a config change that captures a trace only on first retry and sets CI-specific retries to 2.

test.use({ storageState: 'playwright/.auth/user.json' }); test('completes checkout and shows an order number', async ({ page }) => { await page.goto('/products/42'); await page.getByRole('button', { name: 'Add to cart' }).click(); await page.getByRole('link', { name: 'Checkout' }).click(); await page.getByLabel('Shipping address').fill('123 Main St'); await page.getByRole('button', { name: 'Place order' }).click(); await expect(page.getByText(/order #\d+/i)).toBeVisible(); }); test('shows an error when payment fails', async ({ page }) => { await page.route('**/api/payment', (route) => route.fulfill({ status: 500 })); await page.goto('/checkout'); await page.getByRole('button', { name: 'Place order' }).click(); await expect(page.getByRole('alert')).toHaveText(/payment failed/i); }); // playwright.config.ts export default defineConfig({ retries: process.env.CI ? 2 : 0, use: { trace: 'on-first-retry' }, });

For the mocked failure test, use page.route('**/api/payment', route => route.fulfill({ status: 500 })) before calling page.goto or triggering the action that fires that request, since routes must be registered before the request is made.