Testing React Component Error Boundaries

I’ve been working with error boundaries in React. The basic idea is that you can catch any thrown errors during the render phase of your application without disrupting the entire render. This can be helpful to avoid error messages that would otherwise disrupt the user’s flow:

Throw error in the middle of a page

You might structure your code to look like this:

<main>
    <Header />

    <Navigation />

    <ErrorBoundary>
        <Child />
    </ErrorBoundary>
</main>

With this structure, if any of the child components throw an error during rendering, the error boundary will catch it.

It’s worth noting that error boundaries won’t catch errors inside event handlers.

I won’t go into how to create an error boundary component here since there’s a great example in the docs.

One challenge I faced was testing the component that throws the error in the first place.

Testing a thrown error on render

We can test an error that’s thrown during the initial render:

const Foo: FC = () => {
	throw new Error('Oh no');
};

Like so:

test('component should throw', () => {
    expect(() => render(<Foo />)).toThrow();
});

The test will pass but there will be some noise in the output:

Error: Uncaught [Error: Oh no]

So we need to spyOn the console error:

test('component should throw', () => {
    vi.spyOn(console, 'error').mockImplementation(() => null);

    expect(() => render(<Foo />)).toThrow();
});

Our test should then pass.

Testing an asynchronously thrown error

If your component throws an error asynchronously, our approach will differ. For example:

const Foo: FC = () => {
	try {
        await fetch('foo-endpoint');
    } catch (error) {
        throw new Error(error.message);
    }
};

If we try to test it in the same was as the synchronous example, we’ll hit this error:

AssertionError: expected [Function] to throw an error

This is because the error happens after the initial render. In a browser, this will be caught by our error boundary so that’s how we can test it:

test('async component should throw', async () => {
    vi.spyOn(console, 'error').mockImplementation(() => null);

    render(
        <ErrorBoundary
            ErrorComponent={<div>Error Boundary</div>}
        >
            <Foo />
        </ErrorBoundary>
    );

    await waitFor(() => {
        expect(screen.getByText('Error Boundary')).toBeVisible();
    });
});

In this example, we still need to spyOn the console because the test will pass, but we’ll run into the same issue as the synchronous example. We then wrap our component in an error boundary with a mocked component. We then use react-testing-librarys waitFor to wait for the async method to throw and update the component.