| |

React 87 ⚛️ End-to-End Testing React Apps with Playwright

⏱️ Estimated read: 13 min | |

End-to-end testing is the only layer of the test pyramid that exercises the entire application the way a user does: the browser loads the real bundle, the real router resolves the real URL, the real API calls go over the network, and the real DOM is asserted on after the real React render cycle. Unit tests with Vitest and component tests with Vue Test Utils or React Testing Library stop at the module boundary; Playwright starts at the browser and does not stop until the assertion passes or the timeout fires. It drives Chromium, Firefox, and WebKit through a single API, which means a test written once runs against all three engines unless you tell it otherwise .

The distinction between component testing and E2E testing in Playwright is one import. @playwright/experimental-ct-react gives you a mount fixture that renders a single React component in an isolated page; @playwright/test gives you a page fixture that navigates to a real URL . The same locators, the same auto-waiting assertions, and the same trace tooling serve both. This chapter focuses on the E2E form: the page.goto workflow, the baseURL and webServer configuration, the locator strategies that survive a React refactor, network mocking for error states, and the trace viewer that turns a failed CI run into a debuggable recording.

Key point: Playwright E2E tests run against a real browser and a real server. Configure webServer to start the app before the test run and baseURL to make page.goto('/') resolve against it. Use role-based locators and auto-waiting assertions instead of hard-coded sleeps; enable trace: 'on-first-retry' so a failed CI run produces a trace file that can be opened with npx playwright show-trace.


Why Playwright E2E exists alongside component tests

The integration gap. A component test with React Testing Library mounts the component in isolation. The router is mocked, the API is mocked, and the CSS may not even be loaded. It verifies that the component’s logic works, not that the application works. Playwright E2E navigates to the real URL, waits for the real bundle to load, and asserts on the real rendered page. It catches the failures that only appear when the pieces are wired together: a missing route, a broken environment variable, a CORS misconfiguration, a bundle that fails to parse in Firefox .

The cross-browser problem. A React application that works in Chrome may fail in WebKit because of a CSS property that is not supported, a JavaScript API that is not implemented, or a difference in how the engine handles a layout. Playwright’s projects configuration runs the same test suite against Chromium, Firefox, and WebKit, and the report shows exactly which browser failed . The alternative is manual cross-browser testing, which is slower and less consistent.

The flakiness problem. The classic E2E failure mode is the hard-coded sleep: the test calls page.waitForTimeout(3000) and hopes the API responds in time. On a slow CI runner it fails; on a fast local machine it passes but wastes three seconds. Playwright’s locators auto-wait for the element to be actionable, and its expect assertions retry until the condition passes or the timeout expires . The findBy pattern from React Testing Library has a direct equivalent: await expect(page.getByText('Welcome')).toBeVisible() retries internally without an explicit wait.

The debugging problem. A failed E2E test in CI is a black box: the log says the assertion failed, but not why. Playwright’s trace viewer records the DOM snapshot before and after every action, the network requests and responses, the console logs, and the source location. Opening the trace with npx playwright show-trace trace.zip shows the exact page state at the moment of failure, which turns a debugging session into a reading exercise .


a. The configuration and the server lifecycle

The playwright.config.ts file is the entry point. It defines the baseURL, the browsers to test, and the webServer that starts the application before the tests run.

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

export default defineConfig({
  // The base URL for page.goto('/') and page.goto('/about')
  // Without it, every goto needs the full http://localhost:3000 prefix
  use: {
    baseURL: 'http://localhost:3000',
    // Record a trace only when a test fails and is retried
    // 'on-first-retry' is the recommended default: small files, useful debugging
    trace: 'on-first-retry',
    // Capture a screenshot only on failure
    screenshot: 'only-on-failure',
  },
  // Run the test suite against all three browser engines
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
  // Start the application before the tests run
  // Playwright waits until the URL responds before running the first test
  webServer: {
    command: 'npm run start',
    url: 'http://localhost:3000',
    // In CI, always start a fresh server
    // Locally, reuse the dev server if it is already running
    reuseExistingServer: !process.env.CI,
  },
})

The webServer option is the mechanism that eliminates the “start the server in another terminal” step. Playwright runs the command, polls the URL until it responds, and only then starts the tests . The reuseExistingServer: !process.env.CI pattern is the standard: in CI, the server must be started fresh; locally, the developer’s running dev server is reused, which makes the test loop faster.

The trace: 'on-first-retry' setting is the balance between disk usage and debuggability. Recording a trace for every test produces large files on a green run. Recording only on failure is the retain-on-failure mode, but it requires the test to be re-run to capture the trace. The on-first-retry mode records on the first retry, which means a flaky test that fails on the first attempt and passes on the retry still produces a trace of the failure .


b. The locators and the auto-waiting assertions

Playwright’s locators are the replacement for React Testing Library’s queries. The getByRole, getByLabel, getByText, and getByTestId methods map to the same accessibility-first strategy, and the locators auto-wait for the element to appear and become actionable .

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

test('signs in and redirects to the dashboard', async ({ page }) => {
  // Navigate to the login page using the baseURL
  await page.goto('/login')

  // Fill the username and password fields by their labels
  // fill() waits for the input to be editable before typing
  await page.getByLabel('Username').fill('alice')
  await page.getByLabel('Password').fill('secret')

  // Click the sign-in button by its accessible name
  await page.getByRole('button', { name: 'Sign in' }).click()

  // Wait for the URL to change
  await expect(page).toHaveURL('/dashboard')

  // Wait for the welcome text to be visible
  // toBeVisible retries until the element appears or the timeout expires
  await expect(page.getByText('Welcome, Alice')).toBeVisible()
})

The fill method is the Playwright equivalent of setValue. It focuses the element, clears the current value, and types the new value. The click method waits for the element to be visible, enabled, and stable before dispatching the click. The expect assertions are the auto-waiting part: toBeVisible retries the check until it passes or the default timeout (5 seconds) expires, which eliminates the need for waitForTimeout .

The getByRole locator is the most resilient to a React refactor. It queries the accessibility tree, so a change from a <div> to a <button> with the same accessible name still matches. The getByTestId locator is the escape hatch for elements that have no role or label, and it is the last resort, not the first choice.


c. The network mocking and the trace viewer

Playwright can intercept network requests at the browser level, which is the tool for testing error states and empty data without a real backend. The page.route method registers a handler for a URL pattern, and the handler can fulfill the request with a custom response.

// tests/error-state.spec.ts
import { test, expect } from '@playwright/test'

test('shows an error when the API returns 500', async ({ page }) => {
  // Intercept the users API endpoint
  // The ** matches any origin and path segment
  await page.route('**/api/users', async (route) => {
    // Fulfill the request with a 500 status and a JSON body
    await route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Internal server error' }),
    })
  })

  // Navigate to the page that calls the API
  await page.goto('/users')

  // The component's error state renders
  await expect(page.getByRole('alert')).toContainText('Error')
})

The route.fulfill method is the mocked response. The route.continue() method would pass the request to the real network, and route.abort() would fail it. The page.route is the Playwright E2E equivalent of MSW’s server.use, but it operates at the browser’s network layer rather than the Node.js process .

The trace viewer is the debugging tool for the failed test. The config’s trace: 'on-first-retry' records the trace, and the npx playwright show-trace trace.zip command opens it. The viewer shows the action timeline, the DOM snapshot before and after each action, the network requests with their status codes, and the console logs . The typical workflow is to open the trace, find the failed action, and inspect the snapshot to see whether the element was present, visible, or covered by an overlay.


Complete Example Session

The following session builds an E2E test for a search page with a debounced API call, a network mock for the empty state, and a trace configuration for debugging.

// ============================================
// PART 1: THE CONFIG
// ============================================
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  // The base URL makes page.goto('/search') resolve to localhost:3000/search
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  // Run against Chromium only for speed; add firefox and webkit for coverage
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
  // Start the app before the tests
  webServer: {
    command: 'npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})
// ============================================
// PART 2: THE TEST
// ============================================
// tests/search.spec.ts
import { test, expect } from '@playwright/test'

test('shows search results after typing', async ({ page }) => {
  // Navigate to the search page
  await page.goto('/search')

  // Type into the search input by its label
  // fill is the Playwright equivalent of setValue
  await page.getByLabel('Search').fill('react')

  // Wait for the debounced API call to resolve and render
  // toBeVisible retries until the result appears
  await expect(page.getByRole('listitem').first()).toContainText('React')
})

test('shows an empty state when there are no results', async ({ page }) => {
  // Intercept the search API and return an empty array
  await page.route('**/api/search*', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([]),
    })
  })

  await page.goto('/search')
  await page.getByLabel('Search').fill('nothing')

  // The empty state renders
  await expect(page.getByText('No results found')).toBeVisible()
})

The three parts cover the config with baseURL and webServer, the success test with fill and toBeVisible, and the empty-state test with page.route and route.fulfill. What the session demonstrates is that the E2E test drives the real browser against a real server, and the network mock is the tool for the states the real backend cannot easily produce.


Quick Reference

Configuration Options

OptionPurpose
baseURLResolves relative URLs in page.goto
webServer.commandStarts the app before tests
webServer.urlPolls until the app responds
reuseExistingServerReuse local dev server in non-CI
trace'on-first-retry', 'on', 'retain-on-failure'
projectsBrowsers to test against

Locators

LocatorUse
page.getByRoleAccessible role (most resilient)
page.getByLabelForm field by label
page.getByTextText content
page.getByTestIdTest ID (last resort)

Actions and Assertions

MethodEffect
locator.fill(value)Clear and type
locator.click()Click when actionable
expect(locator).toBeVisible()Retry until visible
expect(page).toHaveURL()Retry until URL matches

Network Mocking

MethodEffect
page.route(pattern, handler)Intercept matching requests
route.fulfill(response)Return a custom response
route.continue()Pass through to the real network
route.abort()Fail the request

Best Practices

✅ Do This:

// Use baseURL and relative paths
await page.goto('/login')

// Use role-based locators
await page.getByRole('button', { name: 'Sign in' }).click()

// Use auto-waiting assertions
await expect(page.getByText('Welcome')).toBeVisible()

// Record traces on first retry
trace: 'on-first-retry'

❌ Don’t Do This:

// Hard-code full URLs
await page.goto('http://localhost:3000/login')

// Use CSS selectors tied to the DOM structure
await page.locator('.form > div:nth-child(2) button').click()

// Use fixed sleeps
await page.waitForTimeout(3000)

// Record traces for every test
trace: 'on'

Common Pitfalls

PitfallWhy It HappensFix
Test fails on CI, passes locallyServer not started in CIwebServer with reuseExistingServer: !CI
Element not foundHard-coded sleep too shortUse expect(...).toBeVisible()
Test flaky on WebKit onlyCSS or JS engine differenceRun projects matrix in CI
Trace file missingtrace not setSet trace: 'on-first-retry'
Network mock not matchedURL pattern wrongCheck **/api/path*

Real-World Examples

1. Navigate and assert URL

await page.goto('/about')
await expect(page).toHaveURL('/about')

2. Fill a form

await page.getByLabel('Email').fill('a@b.com')

3. Click a button

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

4. Assert text

await expect(page.getByText('Success')).toBeVisible()

5. Mock an error

await page.route('**/api/data', (r) => r.fulfill({ status: 500 }))

6. Mock an empty list

await page.route('**/api/items', (r) => r.fulfill({ body: '[]' }))

7. Start the server

webServer: { command: 'npm start', url: 'http://localhost:3000' }

8. Run against three browsers

projects: ['chromium', 'firefox', 'webkit'].map(...)

9. Open a trace

npx playwright show-trace trace.zip

10. Run a single test

npx playwright test login.spec.ts

Visual

E2E Test Flow

┌──────────────────────────────────────────────┐
│  webServer starts the app                    │
│         │                                    │
│         ▼                                    │
│  page.goto('/login')                         │
│         │                                    │
│         ▼                                    │
│  getByLabel fill / getByRole click           │
│         │                                    │
│         ▼                                    │
│  expect(...).toBeVisible retries             │
│         │                                    │
│         ▼                                    │
│  test passes or trace recorded               │
└──────────────────────────────────────────────┘

Trace Viewer

┌──────────────────────────────────────────────┐
│  Timeline (left)                             │
│  ├── goto /login                             │
│  ├── fill Username                           │
│  ├── fill Password                           │
│  └── click Sign in  ← failed                 │
│         │                                    │
│         ▼                                    │
│  Snapshot (center)                           │
│  DOM before and after each action            │
│         │                                    │
│         ▼                                    │
│  Console / Network (right)                   │
│  JS errors, API responses                    │
└──────────────────────────────────────────────┘

Locator Priority

┌──────────────────────────────────────────────┐
│  getByRole      most resilient               │
│  getByLabel     form fields                  │
│  getByText      non-interactive content      │
│  getByTestId    last resort                  │
└──────────────────────────────────────────────┘

Summary

ItemValue
Test runner@playwright/test
Component testing@playwright/experimental-ct-react
BrowsersChromium, Firefox, WebKit
Base URLuse.baseURL
Server startupwebServer
Traceuse.trace
LocatorsgetByRole, getByLabel
AssertionsAuto-waiting expect
Network mockpage.route
Debugnpx playwright show-trace

Key takeaways:

  • Playwright E2E tests the real browser against the real server. The webServer config starts the app, and baseURL makes page.goto('/') resolve against it .
  • Locators auto-wait; assertions retry. The fill, click, and expect(...).toBeVisible() methods wait for the element to be actionable and for the condition to pass, which eliminates hard-coded sleeps .
  • The projects matrix runs the same suite on multiple browsers. A test that passes in Chromium but fails in WebKit reveals an engine-specific bug .
  • page.route mocks the network at the browser level. It is the tool for testing error states and empty data without a real backend .
  • The trace viewer is the debugging tool. The trace: 'on-first-retry' config records the DOM snapshots, network, and console for a failed test, and npx playwright show-trace opens the recording .

Remember: A Playwright E2E test is not a unit test with a browser attached. It is a test of the whole system: the bundle, the router, the API, the render cycle, and the DOM. The webServer starts the system, the baseURL makes navigation relative, the locators drive the real elements, and the assertions wait for the real state. The trace setting records the failure so it can be debugged after the fact. The test that passes locally but fails in CI is a test that depends on a local server or a local cache; the webServer config with reuseExistingServer: !CI is the fix. The test that fails intermittently is a test that uses waitForTimeout; the auto-waiting assertions are the fix. The test that fails in production but passes in tests is a test that mocks too much; the real network and the real browser are the fix.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!