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();
});
});
Step-by-step #
- Make the crash prop-driven.
Bombthrows only whenarmedis true. A component that always throws cannot be used to test recovery, because there is no post-fix render to assert against. - 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.
- 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. - Assert the reporter payload, not just the call.
toHaveBeenCalledTimes(1)catches a detached reporter; checkingscopeandcomponentStackcatches 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. - 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. - 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.
Verifying the tests are worth having #
- 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. - 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. - 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. - 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. - 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.
Related #
- Testing & Fault Injection for Error Boundaries — the harness, chaos toggles and CI gates this test layer plugs into
- Injecting Render Failures in Playwright End-to-End Tests — the same contracts asserted against a real bundle
- Building Reusable React Error Boundary Components with TypeScript — the boundary implementation under test