This guide extends the boundary scoping model defined in Frontend Error Boundary Architecture & Fundamentals: once you have decided where boundaries sit, the next engineering problem is proving they behave the way the architecture assumes.

The problem: boundaries that are never exercised before production #

An error boundary is the only part of a frontend that, by design, never runs during a normal day. Every other code path gets exercised by ordinary use — the boundary sits idle until something has already gone wrong, which means a broken boundary is discovered at exactly the moment it was supposed to help. The failure signature is familiar to anyone who has read a post-incident timeline: a component throws, the boundary catches it, and the fallback renders a blank card because a fallback prop was renamed three sprints ago and nothing type-checked the string. The crash was contained. The recovery was not.

Three regressions account for most of these incidents, and all three are invisible to a conventional test suite:

  • The boundary moved. A refactor lifted a provider above the boundary, so a crash that used to take out one widget now unmounts the whole route. Nothing failed — the tree just re-shaped.
  • The reporter silently detached. The telemetry call in componentDidCatch throws or is no longer wired, so every production crash is caught, contained, and never reported. Dashboards look healthier than the product is.
  • The reset never re-mounts. The fallback renders, the retry button calls a stale reset closure, and the subtree stays dead until a full reload. Users see a permanent error card on an otherwise healthy page.

Testing boundaries means deliberately manufacturing failures at chosen points and asserting on all three contracts — containment, reporting, and recovery. That requires a failure surface you can arm from a test, which is what the harness below provides.

Prerequisites #


Core implementation: an armable fault registry #

The harness has one job: let a test say “the pricing widget throws on its next render” without the pricing widget importing anything test-specific. A tiny registry keyed by a string union does this, and because the union is closed, a renamed fault point fails the type-check instead of silently never firing.

// faults.ts — the only module that knows how to manufacture a failure
export type FaultPoint =
  | 'pricing-widget:render'
  | 'cart:hydrate'
  | 'checkout:submit'
  | 'dashboard:panel-fetch';

type FaultKind = 'throw' | 'reject' | 'hang';

interface ArmedFault {
  kind: FaultKind;
  message: string;
  /** Fire once and disarm (default) or on every visit. */
  repeat: boolean;
  fired: number;
}

const armed = new Map<FaultPoint, ArmedFault>();

/** Arming is a test-only capability: production builds compile this to a no-op. */
export function arm(
  point: FaultPoint,
  opts: Partial<Omit<ArmedFault, 'fired'>> = {},
): void {
  if (!__FAULTS_ENABLED__) return;
  armed.set(point, {
    kind: opts.kind ?? 'throw',
    message: opts.message ?? `injected fault at ${point}`,
    repeat: opts.repeat ?? false,
    fired: 0,
  });
}

export function disarm(point?: FaultPoint): void {
  if (point) armed.delete(point);
  else armed.clear();
}

/**
 * Call at the fault point. Synchronous by design: a render-phase check must
 * throw during render for a boundary to see it at all.
 */
export function checkpoint(point: FaultPoint): void {
  const fault = armed.get(point);
  if (!fault) return;
  fault.fired += 1;
  if (!fault.repeat) armed.delete(point);

  if (fault.kind === 'throw') {
    const err = new Error(fault.message);
    err.name = 'InjectedFault';
    throw err;
  }
  if (fault.kind === 'reject') {
    // Escapes the render call stack — only a global handler or an async-aware
    // hook will route this into a boundary.
    void Promise.reject(new Error(fault.message));
  }
  // 'hang' intentionally does nothing: the caller awaits a promise that never
  // settles, which is how you test loading-state timeouts.
}

export function timesFired(point: FaultPoint): number {
  return armed.get(point)?.fired ?? 0;
}

Wiring a component into the harness costs one line, placed where the real failure would occur:

// PricingWidget.tsx
import { checkpoint } from './faults';

export function PricingWidget({ plan }: { plan: Plan }) {
  checkpoint('pricing-widget:render'); // no-op unless a test armed it
  const price = formatPrice(plan.amount, plan.currency);
  return <output className="price">{price}</output>;
}

The diagram below shows where each fault kind enters the runtime, and which of them a synchronous boundary can actually see — the distinction that decides whether a test is meaningful or theatre.

Where each injected fault kind enters the runtime Three horizontal lanes. The top lane runs from a throw checkpoint to the render phase and into the error boundary. The middle lane runs from a reject checkpoint into the global unhandled rejection handler, which forwards into the same boundary via a re-injection step. The bottom lane runs from a hang checkpoint into a pending promise that only a timeout guard resolves. checkpoint() kind: throw render phase stack still attached error boundary fallback + report checkpoint() kind: reject unhandledrejection stack detached re-injection hook setState(() => throw) checkpoint() kind: hang pending promise never settles timeout guard no boundary involved

Only the top lane is a boundary test. The middle lane is really a test of your rejection plumbing — the mechanism described in Error Propagation Strategies — and the bottom lane is a loading-state test wearing a boundary costume. Labelling them correctly in the suite prevents the most common false confidence: a green “boundary catches async errors” test that only proves a promise rejected somewhere.

Architecture note: why the checkpoint must be synchronous #

React’s boundary contract keys off the render call stack. When a component throws during render, React unwinds to the nearest boundary, marks the subtree as errored, and calls getDerivedStateFromError followed by componentDidCatch. Everything about that flow depends on the throw happening while React is on the stack — which is why checkpoint() throws synchronously rather than returning a rejected promise.

An injected fault that fires from a setTimeout, a then callback, or an event handler escapes that window entirely. It surfaces as an uncaught exception on the global object, and unless you have explicitly bridged it back into a component (setState(() => { throw err; }) is the usual trick), no boundary will ever see it. Tests that inject asynchronously and then assert a fallback appears are usually passing for an unrelated reason: the component rendered an error state from its own catch block, not the boundary’s. The async catching hooks exist precisely to make that bridge explicit and therefore testable.

Unit-level: asserting all three boundary contracts #

A single test that renders a fallback is not enough. The suite below asserts containment, reporting, and recovery separately, so a regression in any one of them names itself.

// boundary.contract.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { arm, disarm } from './faults';
import { AppBoundary } from './AppBoundary';
import { PricingWidget } from './PricingWidget';
import * as reporter from './reporter';

let consoleSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
  // React re-logs caught render errors; capture rather than mute so an
  // unexpected SECOND error still shows up as a failed assertion below.
  consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});

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

test('contains the crash to the widget subtree', async () => {
  arm('pricing-widget:render', { message: 'boom' });

  render(
    <main>
      <h1>Plans</h1>
      <AppBoundary scope="pricing">
        <PricingWidget plan={{ amount: 900, currency: 'EUR' }} />
      </AppBoundary>
    </main>,
  );

  // The sibling heading must survive: containment, not just catching.
  expect(screen.getByRole('heading', { name: 'Plans' })).toBeInTheDocument();
  expect(await screen.findByRole('alert')).toHaveTextContent(/pricing is unavailable/i);
});

test('forwards the error to the reporter with a component stack', () => {
  const send = vi.spyOn(reporter, 'sendCrash').mockImplementation(() => {});
  arm('pricing-widget:render', { message: 'boom' });

  render(
    <AppBoundary scope="pricing">
      <PricingWidget plan={{ amount: 900, currency: 'EUR' }} />
    </AppBoundary>,
  );

  expect(send).toHaveBeenCalledTimes(1);
  const payload = send.mock.calls[0][0];
  expect(payload.scope).toBe('pricing');
  expect(payload.name).toBe('InjectedFault');
  expect(payload.componentStack).toMatch(/PricingWidget/);
});

test('retry re-mounts the subtree once the fault is disarmed', async () => {
  const user = userEvent.setup();
  arm('pricing-widget:render'); // single-shot: disarms itself after firing

  render(
    <AppBoundary scope="pricing">
      <PricingWidget plan={{ amount: 900, currency: 'EUR' }} />
    </AppBoundary>,
  );

  await user.click(await screen.findByRole('button', { name: /try again/i }));

  expect(screen.queryByRole('alert')).not.toBeInTheDocument();
  expect(screen.getByText('€9.00')).toBeInTheDocument();
});

The third test is the one teams most often lack, and it is the one that catches a stale-closure reset. Because the fault is single-shot, the retry path meets a healthy component — exactly the production sequence where a transient network blip resolves itself.

Failure modes this suite catches #

Failure mode Symptom in production Assertion that catches it
Boundary hoisted above a provider One widget’s crash blanks the whole route Sibling content still present after the fault fires
Reporter detached or throwing Crash dashboards go quiet while support tickets rise Reporter spy called exactly once, with a component stack
Reset closure captured stale props Retry button does nothing; card stays dead Retry click removes the alert and restores real content
Fallback references a renamed prop Empty or unstyled fallback card Fallback queried by role and accessible name, not by test id
Async rejection assumed to be caught Silent failure with no fallback at all Separate test asserting the re-injection hook, not the boundary

The middle column is what an on-call engineer actually sees; the right column is the cheapest place to notice it first. Pair this table with the granularity decisions in Choosing a Boundary Granularity Strategy — the coarser the boundary, the more the first row costs you.

Blast radius with a local boundary versus a hoisted boundary Two side-by-side trees. On the left a route node contains a layout node, a sidebar node and a boundary wrapping only the pricing widget, so the fallback replaces the widget alone. On the right the boundary sits above the layout node, so the fallback replaces the route, the sidebar and the widget together. Boundary at the widget Boundary above the layout Route Layout + sidebar (alive) fallback pricing widget only blast radius: 1 widget Route fallback layout, sidebar and widget all replaced blast radius: whole route

Advanced: chaos toggles for a staging environment #

Unit tests prove the wiring; a staging chaos switch proves the experience. The extension below arms faults from a query parameter, but only when the build flag allows it and only on a non-production origin — so the same bundle can be exercised by QA without ever exposing an arming surface to real users.

// chaos.ts
import { arm, disarm, type FaultPoint } from './faults';

const ALLOWED_HOSTS = new Set(['localhost', 'staging.example.com', '127.0.0.1']);

const KNOWN: readonly FaultPoint[] = [
  'pricing-widget:render',
  'cart:hydrate',
  'checkout:submit',
  'dashboard:panel-fetch',
];

/** Call once, as early as possible in the client entry point. */
export function initChaosFromLocation(loc: Location = window.location): void {
  if (!__FAULTS_ENABLED__) return;               // stripped from prod builds
  if (!ALLOWED_HOSTS.has(loc.hostname)) return;  // belt and braces at runtime

  const params = new URLSearchParams(loc.search);
  const requested = params.getAll('fault');
  if (requested.length === 0) return;

  disarm();
  for (const raw of requested) {
    const [point, kind = 'throw'] = raw.split(':').length > 2
      ? [raw.slice(0, raw.lastIndexOf(':')), raw.slice(raw.lastIndexOf(':') + 1)]
      : [raw, 'throw'];

    if (!KNOWN.includes(point as FaultPoint)) {
      console.warn(`[chaos] unknown fault point: ${point}`);
      continue;
    }
    arm(point as FaultPoint, {
      kind: kind === 'reject' || kind === 'hang' ? kind : 'throw',
      repeat: params.get('repeat') === '1',
    });
  }
}

With that in place, ?fault=cart:hydrate&repeat=1 reproduces a hydration crash on demand — the exact scenario covered in Hydration Mismatch & State Recovery — and a designer can review the real fallback UI without a developer patching a branch.

CI: failing the build when a boundary regresses #

The browser layer runs the same fault points against a real bundle. Arming happens in an init script so the fault exists before hydration, which is the only way to test the server-rendered path honestly.

// e2e/boundary.spec.ts
import { expect, test } from '@playwright/test';

test.describe('boundary contracts', () => {
  test('a crashing panel does not take down the dashboard', async ({ page }) => {
    const crashes: string[] = [];
    page.on('console', (msg) => {
      if (msg.type() === 'error') crashes.push(msg.text());
    });

    await page.addInitScript(() => {
      // runs on every new document, before any app code evaluates
      window.__ARM_FAULT__ = 'dashboard:panel-fetch';
    });

    await page.goto('/dashboard');

    // The panel degrades…
    await expect(page.getByRole('alert')).toContainText(/panel is unavailable/i);
    // …and everything around it still works.
    await expect(page.getByRole('navigation')).toBeVisible();
    await expect(page.getByRole('heading', { name: 'Revenue' })).toBeVisible();
    // …and the crash was actually reported, not swallowed.
    const beacons = await page.evaluate(() => window.__SENT_CRASHES__ ?? []);
    expect(beacons).toHaveLength(1);
    expect(beacons[0].scope).toBe('dashboard-panel');
  });
});

Two assertions in that test are non-obvious and worth keeping. Asserting the navigation is still visible is what turns “the app caught an error” into “the app contained an error”. Asserting the beacon count is what stops a silent reporter regression, which is otherwise indistinguishable from a quiet week.

Where boundary assertions sit in a CI pipeline Five pipeline stages left to right: unit contract tests, staging build with faults enabled, browser fault runs on critical routes, a boundary coverage check on the route manifest, and finally deploy. A note under the coverage stage says the build fails if any route has no boundary between the outlet and its leaves. unit contract tests build with faults enabled browser fault runs per route boundary coverage check deploy fails the build when a route renders no boundary between its outlet and any leaf component

The coverage stage is a static check, not a test run: walk the route manifest, render each route shallowly, and assert that at least one boundary component appears between the router outlet and the first leaf. It is the cheapest defence against the “boundary moved during a refactor” regression, because it fails on the pull request that moves it rather than on the incident three weeks later.

Deep dives in this guide #

Each page below takes one layer of the suite above and works through a complete, runnable example:

Frequently asked questions #

Why does my error boundary test pass but the console is full of red output?

React deliberately re-throws caught render errors to console.error so the failure is visible in development. The test passing means the boundary worked; the noise is expected. Assert on it rather than muting it globally — spy on console.error, check that exactly one call mentions your component, then restore the spy. A blanket mock hides genuine regressions such as key warnings or a second, unrelated crash.

Should fault injection code ship to production?

The registry can ship; the faults must not arm themselves. Keep the arming path behind an explicit build flag and an origin allowlist so a production bundle can never read a fault name from a query parameter. A registry that is present but permanently empty costs a few hundred bytes and lets the end-to-end suite run against a staging build that is byte-identical to production apart from one flag.

How do I test that a boundary resets rather than staying stuck in its fallback?

Arm the fault, render, assert the fallback, then disarm and trigger the reset path the UI actually offers. Re-assert that the original subtree is back and its effects re-ran. A boundary that renders its fallback forever passes a naive “fallback appears” test and still ships a dead UI.

Can Playwright or Cypress catch errors that only happen during hydration?

Yes, but only if the fault is armed before the client bundle evaluates. Use an init script that runs on every new document rather than a post-load evaluate, otherwise hydration has already finished and you are testing a client-side re-render instead of the hydration path.

What is a reasonable CI budget for boundary tests?

Unit-level contract tests are milliseconds each, so cover every distinct boundary there. Keep the browser layer to one injected fault per critical route — the goal is proving the wiring survives a real bundle. A dozen browser assertions plus per-boundary unit tests typically adds well under a minute to a pipeline.