This walkthrough implements the unit layer described in Testing & Fault Injection for Error Boundaries, where the harness and CI gates around it are laid out in full.

The exact problem #

A team adds a boundary test, sees it go green, and ships. Six weeks later a crash in the pricing widget blanks the whole checkout route. The test was green because it asserted one thing — that a fallback appeared — and the regression was in something else entirely: the boundary had been hoisted above the layout during a refactor, so the fallback appeared instead of the entire page rather than instead of the widget.

The failure to catch that is structural, not accidental. A test that renders only <Boundary><Crash /></Boundary> cannot distinguish containment from replacement, because there is nothing else in the tree to survive. This page builds the four assertions that do distinguish them, in a form you can copy into any React 18 or 19 codebase.

Zero-to-working test file #

// AppBoundary.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AppBoundary } from './AppBoundary';
import * as reporter from './reporter';

/** Throwing is prop-driven so one render can crash and the next can recover. */
function Bomb({ armed }: { armed: boolean }) {
  if (armed) throw new Error('price formatter exploded');
  return <p>€9.00 per month</p>;
}

function Harness({ armed }: { armed: boolean }) {
  return (
    <main>
      <h1>Plans</h1>
      <AppBoundary scope="pricing" fallbackLabel="Pricing is unavailable">
        <Bomb armed={armed} />
      </AppBoundary>
      <footer>Contact support</footer>
    </main>
  );
}

describe('AppBoundary', () => {
  let consoleError: ReturnType<typeof vi.spyOn>;

  beforeEach(() => {
    // Keep the output readable, but keep the calls so we can assert on them.
    consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
  });

  afterEach(() => {
    consoleError.mockRestore();
    vi.restoreAllMocks();
  });

  it('contains the failure to its own subtree', () => {
    render(<Harness armed />);

    expect(screen.getByRole('alert')).toHaveTextContent(/pricing is unavailable/i);
    // The assertions that make this a containment test:
    expect(screen.getByRole('heading', { name: 'Plans' })).toBeInTheDocument();
    expect(screen.getByText('Contact support')).toBeInTheDocument();
  });

  it('reports the crash with a scope and a component stack', () => {
    const send = vi.spyOn(reporter, 'sendCrash').mockImplementation(() => {});

    render(<Harness armed />);

    expect(send).toHaveBeenCalledTimes(1);
    const [payload] = send.mock.calls[0];
    expect(payload.scope).toBe('pricing');
    expect(payload.message).toMatch(/price formatter exploded/);
    expect(payload.componentStack).toMatch(/Bomb/);
  });

  it('logs exactly one React error and nothing unexpected', () => {
    render(<Harness armed />);

    const messages = consoleError.mock.calls.map((c) => String(c[0]));
    expect(messages.filter((m) => /price formatter exploded/.test(m))).toHaveLength(1);
    expect(messages.some((m) => /Warning: Each child/.test(m))).toBe(false);
  });

  it('recovers when retry is pressed after the fault clears', async () => {
    const user = userEvent.setup();
    const { rerender } = render(<Harness armed />);

    expect(await screen.findByRole('alert')).toBeInTheDocument();

    rerender(<Harness armed={false} />);          // the underlying fault is gone…
    await user.click(screen.getByRole('button', { name: /try again/i }));  // …now reset

    expect(screen.queryByRole('alert')).not.toBeInTheDocument();
    expect(screen.getByText('€9.00 per month')).toBeInTheDocument();
  });
});
What each node in the harness proves A main element box contains three children drawn as sibling boxes: a heading labelled Plans, a boundary box containing the crashed component replaced by a fallback, and a footer labelled Contact support. Arrows from the heading and footer point to a note reading these two surviving is the containment assertion. <main> h1 "Plans" must survive AppBoundary scope="pricing" Bomb replaced by role="alert" fallback footer must survive these two surviving is the containment assertion — without siblings the test cannot fail

Step-by-step #

  1. Make the crash prop-driven. Bomb throws only when armed is true. A component that always throws cannot be used to test recovery, because there is no post-fix render to assert against.
  2. Give the boundary siblings. The heading and footer exist purely so the containment assertion has something to observe. Without them, a boundary that replaced the entire application would still pass.
  3. Spy, do not mute. vi.spyOn(console, 'error') with a no-op implementation keeps the terminal clean while preserving the call list. The third test then asserts exactly one matching log — which fails if a second crash appears or if the boundary stops catching and React logs an uncaught error instead.
  4. Assert the reporter payload, not just the call. toHaveBeenCalledTimes(1) catches a detached reporter; checking scope and componentStack catches the subtler regression where the reporter is called but with a payload your dashboard cannot group. The payload shape comes from Error Telemetry & Crash Reporting.
  5. Clear the fault before you reset. The recovery test re-renders with armed={false} before clicking retry. Clicking retry while the fault is still armed re-throws immediately and the test would pass for the wrong reason.
  6. Query by role. getByRole('alert') asserts the fallback is announced to assistive technology as well as visible. A test id would pass even if the fallback lost its ARIA semantics — a regression that only affects screen-reader users and therefore never gets reported.

Edge cases specific to boundary tests #

Situation What actually happens What to do
Error thrown in useEffect body Caught by the boundary — effects run inside React’s commit phase Safe to test the same way; assert the fallback
Error thrown in an async callback inside useEffect Escapes to window.onunhandledrejection; no boundary involved Test the bridge instead, or assert unhandledrejection fired
Error thrown in an event handler Not caught by any boundary in React 18 or 19 Assert your handler’s own try/catch, not the boundary
React 19 onUncaughtError / onCaughtError root options Root-level hooks fire in addition to componentDidCatch Spy on them separately or your call counts will double
StrictMode in the test render Components render twice, so throw counts double Count boundary catches, not Bomb invocations
Boundary state left over between tests A module-level boundary instance keeps its error state Render a fresh tree per test; never share a boundary instance

The StrictMode row bites regularly. If your assertion is expect(sendCrash).toHaveBeenCalledTimes(1) and the test renders under StrictMode, the double render can produce two catches in development builds. Either drop StrictMode from the unit harness or assert on unique fingerprints instead of raw counts.

Which error sources a boundary actually catches Four labelled rows. Render throw and effect body throw are marked caught by the boundary, drawn with solid full-width bars. Async callback rejection and event handler throw are marked not caught, drawn with short dashed bars ending at a vertical boundary line. boundary reach render throw caught effect body throw caught async callback reject escapes to global handler event handler throw stays in the handler Order of operations in a single boundary test Five steps in sequence: install the console spy in beforeEach, arm the fault, render the harness, run the fallback, reporter and log assertions, and finally restore the spy and disarm in afterEach. A note marks that skipping the final step leaks state into the next test. beforeEach spy on console arm the fault prop-driven Bomb render harness siblings included assert ×3 fallback · report · log afterEach restore + disarm omit the last box and the next test inherits a spy and a live fault which is why an order-randomised run is part of the verification list below

Verifying the tests are worth having #

  1. Break the boundary on purpose. Comment out componentDidCatch’s reporter call and re-run. Exactly one test — the reporter test — must fail. If none do, your assertions are too loose; if all of them do, they are coupled.
  2. Hoist the boundary above <main> temporarily. The containment test must fail while the fallback test still passes. That divergence is the whole point of the sibling nodes.
  3. Remove role="alert" from the fallback. The role-based queries must fail, proving the tests protect the accessible semantics described in Building Accessible Error Fallback UI with ARIA Live Regions.
  4. Check for state bleed. Run the suite with --sequence.shuffle (Vitest) or --randomize (Jest). Order-dependent passes mean a boundary or module-level registry is being reused across tests.
  5. Confirm the console assertion has teeth. Add a deliberate console.error('extra') inside the fallback and confirm the log test fails.

Frequently asked questions #

Why does my test suite print a red React error even when the test passes?

React re-logs every caught error through console.error so failures stay visible in development. A passing test with red output means the boundary worked. Spy on console.error to keep output readable, but assert on the calls rather than discarding them.

Can I test an error boundary with a hook-based component?

The boundary itself must still be a class component or a library wrapper around one — getDerivedStateFromError and componentDidCatch have no hook equivalent. Render the library’s boundary exactly as the app does and assert the same contracts.

Why does my boundary not catch the error thrown in useEffect?

It catches synchronous throws from the effect body, but not errors raised inside an async callback — those escape to the global handler. If your test arms an async failure and expects a fallback, you are testing the rejection bridge, which needs a different assertion.