Resource library

QA How-To

Test React Error Boundaries with Playwright

Learn to test React error boundaries with Playwright using runnable component tests for fallback UI, reset recovery, error reporting, rejected loads, and CI.

22 min read | 2,725 words

TL;DR

Wrap a deterministic failing child with the production React error boundary, mount it through Playwright Component Testing, and assert the visible fallback and recovery path. Inject a harmless reporter, distinguish render errors from rejected promises, and capture only the exact browser error expected by each test.

Key Takeaways

  • Mount the real error boundary around a small component that can fail deterministically.
  • Assert the accessible fallback UI instead of treating the browser exception as the test result.
  • Use a stateful test host to prove that reset actions can render healthy content again.
  • Inject an error reporter and verify its serializable payload without calling production telemetry.
  • Model rejected asynchronous work as rendered state because error boundaries do not catch arbitrary promise rejections.
  • Capture only expected browser errors and never suppress unrelated page errors globally.
  • Run failure-path tests repeatedly and in CI because recovery bugs often involve stale state.

To test react error boundaries with playwright, mount the production boundary around a child that throws during rendering, then assert the fallback, recovery action, and error-reporting contract in a real browser. The important result is not merely that JavaScript threw. It is that users receive a usable fallback and can recover when the design promises recovery.

This tutorial builds a small ProfilePanel failure path and tests it with Playwright Component Testing for React. For setup choices, test-layer boundaries, and suite architecture, start with the complete Playwright Component Testing for React guide.

You will keep failures deterministic, avoid production telemetry, and separate synchronous render errors from asynchronous request failures. The result is a focused suite that proves resilience without starting the whole application.

What You Will Build

You will create and verify a production-shaped resilience flow with:

  • A class-based AppErrorBoundary that catches descendant render errors.
  • An accessible fallback with a concise message and Try again button.
  • A ProfilePanel that can fail predictably from a prop.
  • A stateful host that changes the failing condition when the user retries.
  • An injected reporter that receives the error message and component stack.
  • A separate asynchronous loader that renders rejected requests as error state.
  • Playwright CT commands for local debugging and CI stability.

The suite tests behavior through the browser DOM. It does not inspect private boundary state, spy on React internals, or rely on a deployed application.

Prerequisites

Use Node.js 20 or 22 LTS, npm, TypeScript, and React 18 or newer. Install the official React component-testing integration from the project root:

npm init playwright@latest -- --ct
npx playwright install chromium

Choose React and TypeScript when prompted. Keep @playwright/experimental-ct-react, @playwright/test, and any direct Playwright package on compatible versions selected by the initializer. Commit the lockfile. The component-testing package name remains experimental, so review its release notes during upgrades.

Use a dedicated command in package.json:

{
  "scripts": {
    "test:ct": "playwright test -c playwright-ct.config.ts"
  }
}

This tutorial assumes the generated playwright/index.html and playwright/index.tsx harness already load. If you need that foundation first, follow the step-by-step React component mounting tutorial.

Step 1: Configure the React Component Test Project

Create a focused CT configuration. Keep resilience specs beside the component or in a dedicated component-test directory, but do not let the end-to-end configuration discover them.

// playwright-ct.config.ts
import { defineConfig, devices } from '@playwright/experimental-ct-react';

export default defineConfig({
  testDir: './src',
  testMatch: '**/*.ct.spec.tsx',
  fullyParallel: true,
  reporter: [['list'], ['html', { open: 'never' }]],
  use: {
    ctPort: 3100,
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
});

Import defineConfig from the CT React package. That package supplies the component-specific fixtures and Vite-backed mount environment.

Ensure the generated harness loads application-wide styles without starting production services:

// playwright/index.tsx
import '../src/index.css';

Do not initialize analytics, service workers, authentication refresh loops, or the production error reporter in this harness. Each makes the isolated failure path less deterministic. Inject those boundaries where the component needs them.

Verify the step: run npm run test:ct -- --list. The configuration should load without an unknown fixture, missing browser, or unresolved harness error. An empty list is acceptable until the first spec exists.

Step 2: Build an Accessible React Error Boundary

React error boundaries are class components because the boundary contract uses getDerivedStateFromError and componentDidCatch. Create a small reusable implementation:

// src/components/AppErrorBoundary.tsx
import {
  Component,
  type ErrorInfo,
  type ReactNode,
} from 'react';

type ErrorReport = {
  message: string;
  componentStack: string;
};

type Props = {
  children: ReactNode;
  onReset?: () => void;
  reportError?: (report: ErrorReport) => void;
};

type State = { hasError: boolean };

export class AppErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false };

  static getDerivedStateFromError(): State {
    return { hasError: true };
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    this.props.reportError?.({
      message: error.message,
      componentStack: info.componentStack ?? '',
    });
  }

  private reset = () => {
    this.setState({ hasError: false });
    this.props.onReset?.();
  };

  render() {
    if (this.state.hasError) {
      return (
        <section role="alert" aria-labelledby="error-title">
          <h2 id="error-title">We could not load this section</h2>
          <p>Your other work is safe. Try loading this section again.</p>
          <button type="button" onClick={this.reset}>
            Try again
          </button>
        </section>
      );
    }

    return this.props.children;
  }
}

The fallback says what failed at a useful scope, reassures the user only about a fact the application can guarantee, and provides an action. role="alert" announces the failure to assistive technology. The injected reporter keeps telemetry outside the component and gives tests a safe seam.

Do not render raw error messages or stack traces to users. They may contain implementation details or sensitive values. Keep diagnostic data in the reporting path and keep fallback copy stable and actionable.

Verify the step: run npm run build. TypeScript should accept ErrorInfo, the reporter payload, and the component state. Confirm the fallback button is a native button with an accessible name.

Step 3: Create a Deterministic Failing Child

Build a component whose failure condition is an explicit input. This is clearer than mocking console.error, modifying a module, or hoping a network call fails at the right moment.

// src/components/ProfilePanel.tsx
type ProfilePanelProps = {
  name: string;
  shouldFail?: boolean;
};

export function ProfilePanel({
  name,
  shouldFail = false,
}: ProfilePanelProps) {
  if (shouldFail) {
    throw new Error('Profile data is invalid');
  }

  return (
    <section aria-labelledby="profile-title">
      <h2 id="profile-title">Profile</h2>
      <p>Signed in as {name}</p>
    </section>
  );
}

Keep the throwing component below the boundary. A boundary cannot catch an error thrown in its own render method. It also does not catch errors from event handlers, server-side rendering, or arbitrary asynchronous callbacks. Those failure types need different handling and tests.

Failure source Caught by a descendant error boundary Testing approach
Descendant render or lifecycle Yes Mount inside boundary and assert fallback
Boundary's own render No Place another boundary above it
Click handler No Assert the handler's local error behavior
Rejected fetch promise No, not by itself Convert rejection into rendered state
Server rendering No Test the server framework's error path

Verify the step: run the TypeScript build again. Then temporarily render <ProfilePanel name="Ari" /> in an existing development story or host and confirm it shows Signed in as Ari. Remove the temporary usage after checking it.

Step 4: Test React Error Boundaries with Playwright

Write the first component spec. The browser may emit an expected page error or React console output while React transfers control to the boundary. The contract you own is the visible fallback.

// src/components/AppErrorBoundary.ct.spec.tsx
import { expect, test } from '@playwright/experimental-ct-react';
import { AppErrorBoundary } from './AppErrorBoundary';
import { ProfilePanel } from './ProfilePanel';

test('shows an accessible fallback when a child render fails', async ({
  mount,
}) => {
  const component = await mount(
    <AppErrorBoundary>
      <ProfilePanel name="Ari Patel" shouldFail />
    </AppErrorBoundary>,
  );

  const alert = component.getByRole('alert');
  await expect(alert).toBeVisible();
  await expect(
    alert.getByRole('heading', { name: 'We could not load this section' }),
  ).toBeVisible();
  await expect(
    alert.getByRole('button', { name: 'Try again' }),
  ).toBeEnabled();
  await expect(component).not.toContainText('Signed in as Ari Patel');
});

React development builds can log caught errors deliberately. Do not globally ignore pageerror or console events just to make output quiet. A global filter can hide unrelated crashes in healthy scenarios. If your locked React and CT versions surface the caught render exception as a test failure, capture only the known event inside this test, as shown in troubleshooting.

Verify the step: run npm run test:ct -- AppErrorBoundary.ct.spec.tsx --project=chromium. Expect one passing test with the fallback visible in the HTML report or UI mode. Change the expected heading briefly and confirm the failure shows the actual accessible content, then restore it.

Step 5: Verify Reset and Successful Recovery

Resetting boundary state alone immediately rerenders the same broken child and throws again. The retry action must also change the condition that caused the failure, remount a corrected subtree, or trigger a fresh load. Create a stateful host that models a successful retry:

// src/components/RecoverableProfile.tsx
import { useState } from 'react';
import { AppErrorBoundary } from './AppErrorBoundary';
import { ProfilePanel } from './ProfilePanel';

export function RecoverableProfile() {
  const [shouldFail, setShouldFail] = useState(true);

  return (
    <AppErrorBoundary onReset={() => setShouldFail(false)}>
      <ProfilePanel name="Ari Patel" shouldFail={shouldFail} />
    </AppErrorBoundary>
  );
}

Add the recovery test:

import { RecoverableProfile } from './RecoverableProfile';

test('recovers after the user retries', async ({ mount }) => {
  const component = await mount(<RecoverableProfile />);

  await expect(component.getByRole('alert')).toBeVisible();
  await component.getByRole('button', { name: 'Try again' }).click();

  await expect(
    component.getByRole('heading', { name: 'Profile' }),
  ).toBeVisible();
  await expect(component).toContainText('Signed in as Ari Patel');
  await expect(component.getByRole('alert')).toHaveCount(0);
});

This test proves a complete user outcome: failure appears, retry is operable, healthy content replaces the alert, and stale fallback content disappears. It is stronger than asserting that onReset was called because it verifies the callback actually repairs the rendered tree.

For a data-backed component, make onReset invalidate a query or recreate a resource rather than flipping a synthetic Boolean. Wrap query clients or contexts per mount. The React context mocking tutorial for Playwright component tests shows how to keep those provider values isolated.

Verify the step: run the spec with --repeat-each=3. Every repetition must begin at the fallback and end at the profile. A repetition that starts healthy indicates state leaked outside the mounted host.

Step 6: Verify Error Reporting Without Production Telemetry

An error boundary often reports failures to an observability service. Do not send component-test errors to the production project. Inject a callback, collect its serializable payload, and assert the useful fields.

test('reports the caught error with component context', async ({ mount }) => {
  const reports: Array<{ message: string; componentStack: string }> = [];

  await mount(
    <AppErrorBoundary reportError={(report) => reports.push(report)}>
      <ProfilePanel name="Ari Patel" shouldFail />
    </AppErrorBoundary>,
  );

  await expect.poll(() => reports.length).toBe(1);
  expect(reports[0].message).toBe('Profile data is invalid');
  expect(reports[0].componentStack).toContain('ProfilePanel');
});

Playwright CT marshals callback props between the browser mount environment and the test. Keep payloads plain and serializable. expect.poll repeatedly reads test-worker state until the browser callback arrives. Do not use a fixed delay.

Assert stable reporting semantics, not the complete component stack. React formatting can change across versions and builds. One message and a meaningful component name provide a durable contract. If your production reporter adds release, route, user, or correlation metadata, test that adapter separately with scrubbed values. Never put tokens or personal production data in fixtures.

Verify the step: run only this case with npm run test:ct -- AppErrorBoundary.ct.spec.tsx -g "reports". Expect one report. Temporarily render two separate failing boundaries and confirm that expecting one report fails, then restore the single boundary.

Step 7: Test Rejected Asynchronous Work Correctly

A rejected promise does not automatically enter the nearest error boundary. Model expected loading failures as component state and render a recoverable error message. Reserve thrown render errors for exceptional rendering conditions.

// src/components/ProfileLoader.tsx
import { useEffect, useState } from 'react';

type Profile = { name: string };

type Props = { loadProfile: () => Promise<Profile> };

export function ProfileLoader({ loadProfile }: Props) {
  const [profile, setProfile] = useState<Profile | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let active = true;
    loadProfile().then(
      (value) => active && setProfile(value),
      () => active && setError('Profile service is unavailable'),
    );
    return () => { active = false; };
  }, [loadProfile]);

  if (error) return <p role="alert">{error}</p>;
  if (!profile) return <p role="status">Loading profile</p>;
  return <p>Signed in as {profile.name}</p>;
}

Test the rejected load directly:

import { ProfileLoader } from './ProfileLoader';

test('renders a controlled message for a rejected profile load', async ({
  mount,
}) => {
  const component = await mount(
    <ProfileLoader
      loadProfile={() => Promise.reject(new Error('HTTP 503'))}
    />,
  );

  await expect(component.getByRole('status')).toContainText('Loading');
  await expect(component.getByRole('alert')).toHaveText(
    'Profile service is unavailable',
  );
  await expect(component).not.toContainText('HTTP 503');
});

The test verifies that technical details remain hidden and expected service failure has a designed state. In applications that use Suspense-capable data libraries or React framework error primitives, follow that library's documented throwing and reset contract. Do not force every rejected request through a generic class boundary.

For real HTTP behavior, register page.route before mounting and fulfill the endpoint with a controlled 503. An injected loader is appropriate when the goal is component state. Network routing is appropriate when request URL, method, or response parsing is part of the contract. Review Playwright API testing practices for service checks, and use the Playwright locator guide to keep fallback assertions accessible and stable.

Verify the step: run the loader case three times. It should always show loading first and the friendly alert next. No unhandled-rejection error should appear in the report.

Step 8: Harden the Failure Suite for CI

Run the complete file repeatedly and in parallel with other component specs:

npm run test:ct -- AppErrorBoundary.ct.spec.tsx --repeat-each=3
npm run test:ct -- --fully-parallel --project=chromium

Each test must create its reporter array, stateful host, and failing input locally. Never store shouldFail, reports, or a query client in mutable module scope. A boundary preserves its error state until reset or remount, so shared wrappers can make order-dependent failures especially confusing.

Use traces on the first retry and screenshots only on failure, as configured earlier. In UI mode, inspect the action timeline and DOM snapshot:

npm run test:ct -- AppErrorBoundary.ct.spec.tsx --ui

Add the CT command to CI after the application build and dependency install. Cache package downloads, not stale test results. On a Linux runner that lacks browser dependencies, install the selected browser with:

npx playwright install --with-deps chromium

For a complete pipeline, artifact retention, and Vite configuration, use the Playwright component tests with Vite and CI tutorial.

Verify the step: run the full component project with --repeat-each=2. Every failure-path case must pass in any order. Open one intentionally failed trace, confirm it shows the fallback DOM and action, then restore the assertion before committing.

Test React Error Boundaries with Playwright: Design Decisions

Choose assertions according to the contract you own. The table helps prevent a single component test from becoming an unreliable substitute for every layer.

Contract Best assertion Avoid
User sees a safe fallback Role, heading, and message locators Full DOM snapshot
User can recover Click retry and assert healthy content Callback count only
Error reaches app reporter Inject callback and inspect stable fields Calling production telemetry
Request failure is expected Render explicit rejected state Assuming a boundary catches promises
Error event is browser-level Capture one exact event locally Global page-error suppression
Backend remained available API or integration test Inferring it from component UI

Place boundaries at meaningful isolation points. A route-level boundary can preserve navigation. A widget-level boundary can protect the rest of a dashboard. Do not wrap every leaf because fragmented fallback messages can produce a confusing experience and excessive reporting. Test the same scope that production users encounter.

Keep fallback copy honest. Do not say data is saved unless the application knows it is. Give users a retry only when retry can change the condition. Otherwise offer navigation, reload, or support context appropriate to the failure.

Troubleshooting

The mount rejects before the fallback assertion runs -> Your locked React and CT versions may surface the render exception through mount. Start const errorPromise = page.waitForEvent('pageerror') before mounting, capture only the expected message, and adjust the mount expectation to that documented version behavior. Keep the fallback assertion if React still commits it.

The Try again button immediately returns to the fallback -> Resetting boundary state rerendered the same broken child. Change the failing input, invalidate and reload the resource, or remount a healthy subtree as part of onReset.

The reporter test times out -> Pass the callback directly in mounted JSX, keep its payload serializable, and use expect.poll. Confirm the error is thrown by a descendant during rendering and not by the boundary itself.

React prints noisy errors although the test passes -> Development React can log caught exceptions. Do not silence all console or page errors globally. Filter only an exact expected event inside the intentional failure test if your runner requires it.

A rejected fetch never shows the boundary fallback -> Error boundaries do not catch arbitrary promise rejections. Catch the rejection and render explicit error state, or use the documented error mechanism of your data framework.

The case passes alone but fails in the suite -> Move reporter arrays, state, stores, and clients inside each test or host. Run --fully-parallel --repeat-each=3 and remove mutable module defaults.

Where To Go Next

Return to the Playwright Component Testing for React complete guide to decide which resilience risks belong at component, API, integration, and end-to-end layers. Strengthen the foundation with the React component mounting walkthrough if callbacks, updates, or unmount behavior are new to your team.

When a failing descendant depends on authentication, theme, routing, or a query client, use the React context mocking tutorial to build a small typed provider harness. Then move the stable suite into pull requests with the Vite and CI component-testing tutorial.

Add one production boundary scenario at a time. Start with the highest-impact widget or route, verify its honest fallback, prove recovery, and confirm reporting uses scrubbed diagnostic context.

Interview Questions and Answers

Q: How do you test a React error boundary with Playwright?

Mount the production boundary around a deterministic child that throws during rendering. Assert the accessible fallback, then trigger recovery and verify healthy content returns. Inject the reporter so the test can verify stable diagnostic fields without external telemetry.

Q: Why not assert only that the child throws?

A thrown exception proves the fixture failed, not that the product handled failure well. The boundary's contract includes safe fallback content, preserved surrounding UI, an appropriate action, and optional reporting. Browser assertions verify those user-visible outcomes.

Q: Do React error boundaries catch rejected promises?

Not arbitrary rejected promises. Expected request failures should normally become explicit loading and error state, unless a framework or data library intentionally integrates thrown promises and errors with its boundary model. Test the actual mechanism your application uses.

Q: How do you test error-boundary reset behavior?

Mount a host that owns the failure condition. Click the fallback's reset action, change or reload that condition, and assert the healthy subtree replaces the alert. Resetting only the boundary while leaving the child broken will throw again.

Q: Should tests suppress console errors from intentional failures?

Do not suppress them globally. Capture or filter only the exact expected error inside the intentional test when required by the locked React and Playwright versions. Global suppression can conceal unrelated application crashes.

Q: What belongs in an error-reporting assertion?

Assert stable, useful fields such as the sanitized message and a meaningful component name. Avoid matching the complete stack because formatting can change. Verify vendor delivery separately at an integration boundary.

Best Practices and Common Mistakes

  • Mount the real production boundary at the same scope users experience.
  • Trigger render failure with a deterministic, local input.
  • Assert accessible fallback content and the absence of sensitive technical details.
  • Prove recovery by checking healthy content, not only a callback.
  • Create reporter state, stores, and clients per test.
  • Keep callback payloads serializable and scrubbed.
  • Use web-first assertions instead of fixed sleeps.
  • Do not expect boundaries to catch event-handler errors or arbitrary rejected promises.
  • Do not throw from the boundary itself and expect it to catch its own error.
  • Do not send intentional test failures to production observability.
  • Do not silence all browser errors to accommodate one deliberate exception.
  • Do not offer a retry action unless it can change or reload the failing condition.

Test React Error Boundaries with Playwright: Conclusion

The reliable way to test react error boundaries with playwright is to create a controlled descendant render failure and verify the complete browser contract: safe fallback, accessible action, successful recovery, and sanitized reporting. Keep asynchronous request failures explicit unless your framework deliberately routes them through a boundary.

Run the cases repeatedly with isolated state, retain traces for CI failures, and capture only expected error events locally. You will get resilience tests that explain real user behavior instead of tests that merely confirm JavaScript can throw.

Interview Questions and Answers

How would you test a React error boundary with Playwright?

I would mount the production boundary around a deterministic descendant that throws during rendering. I would assert an accessible, safe fallback and then exercise the recovery action until healthy content returns. I would inject the reporting seam and verify sanitized stable fields without contacting production telemetry.

Why is asserting a thrown error insufficient for an error-boundary test?

It proves only that the fixture failed. The product contract is how failure is contained and communicated, including fallback content, preserved surrounding UI, recovery, and reporting. Playwright is valuable because it verifies those outcomes in the browser.

Which errors can a React error boundary catch?

It catches errors thrown by descendants during rendering and relevant lifecycle work. It does not catch its own render errors, event-handler errors, server-rendering errors, or arbitrary asynchronous rejections. I choose a matching handling and test strategy for each category.

How would you verify error-boundary recovery?

I would use a stateful host that owns the failure condition. The fallback action would change or reload that condition and reset the boundary. The test would click the action, assert healthy content appears, and assert the stale alert disappears.

How do you handle expected browser errors in these tests?

I keep handling local to the intentional case and match the exact expected message when the runner exposes it. I never suppress all page or console errors globally because that can hide unrelated crashes. I still assert the visible fallback as the primary product contract.

How would you test componentDidCatch reporting safely?

I inject a reporter callback and capture a small serializable report in test-worker state. I wait with `expect.poll` and assert stable values such as the scrubbed message and component name. External vendor delivery belongs in a separate integration test.

Frequently Asked Questions

Can Playwright test React error boundaries?

Yes. Playwright Component Testing can mount the real boundary around a deterministic failing child in a browser. Assert the accessible fallback, retry behavior, recovered content, and any injected reporting contract.

How do I make a React component throw in a Playwright test?

Use a small deterministic fault seam, such as a fixture component that throws during render or a controlled invalid input that naturally triggers the failure. Keep it below the boundary and avoid global module mutations or timing-dependent failures.

Why does resetting my error boundary show the fallback again?

The same broken child is rendering after boundary state resets. Make the reset action change the failing input, reload the resource, or remount a corrected subtree before expecting healthy content.

Do React error boundaries catch fetch errors?

They do not automatically catch arbitrary rejected fetch promises. Catch expected request failures and render an explicit error state, or follow the documented error-boundary integration of your framework or data library.

Should I ignore page errors in error-boundary tests?

Do not ignore them globally. If the locked React and Playwright versions expose an expected caught error, capture only that exact event inside the intentional test so unrelated browser errors still fail the suite.

How do I test error reporting from componentDidCatch?

Inject a reporter callback into the boundary and collect a plain serializable payload. Use `expect.poll` to await it, then assert stable fields such as the sanitized message and a relevant component name.

Related Guides