React 87 ⚛️ End-to-End Testing React Apps with Playwright
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
| Option | Purpose |
|---|---|
baseURL | Resolves relative URLs in page.goto |
webServer.command | Starts the app before tests |
webServer.url | Polls until the app responds |
reuseExistingServer | Reuse local dev server in non-CI |
trace | 'on-first-retry', 'on', 'retain-on-failure' |
projects | Browsers to test against |
Locators
| Locator | Use |
|---|---|
page.getByRole | Accessible role (most resilient) |
page.getByLabel | Form field by label |
page.getByText | Text content |
page.getByTestId | Test ID (last resort) |
Actions and Assertions
| Method | Effect |
|---|---|
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
| Method | Effect |
|---|---|
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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Test fails on CI, passes locally | Server not started in CI | webServer with reuseExistingServer: !CI |
| Element not found | Hard-coded sleep too short | Use expect(...).toBeVisible() |
| Test flaky on WebKit only | CSS or JS engine difference | Run projects matrix in CI |
| Trace file missing | trace not set | Set trace: 'on-first-retry' |
| Network mock not matched | URL pattern wrong | Check **/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
| Item | Value |
|---|---|
| Test runner | @playwright/test |
| Component testing | @playwright/experimental-ct-react |
| Browsers | Chromium, Firefox, WebKit |
| Base URL | use.baseURL |
| Server startup | webServer |
| Trace | use.trace |
| Locators | getByRole, getByLabel |
| Assertions | Auto-waiting expect |
| Network mock | page.route |
| Debug | npx playwright show-trace |
Key takeaways:
- Playwright E2E tests the real browser against the real server. The
webServerconfig starts the app, andbaseURLmakespage.goto('/')resolve against it . - Locators auto-wait; assertions retry. The
fill,click, andexpect(...).toBeVisible()methods wait for the element to be actionable and for the condition to pass, which eliminates hard-coded sleeps . - The
projectsmatrix runs the same suite on multiple browsers. A test that passes in Chromium but fails in WebKit reveals an engine-specific bug . page.routemocks 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, andnpx playwright show-traceopens 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.
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!