Testing an error thrown by a React component using testing-library and jest

Viewed 19902

Following Kent C Dodds' provider pattern explained in this blog post, I have a context provider component along with a hook to use that context.

The hook guards against the use of it outside of the provider,

export function useUser() {
  const { user } = useContext(UserContext) || {};
  const { switchUser } = useContext(SwitchUserContext) || {};
  if (!user || !switchUser) {
    throw new Error('Cannot use `useUser` outside of `UserProvider`');
  }
  return { user, switchUser };
}

To test the scenario, I create a TestComponent and use the useUser hook inside it.

function TestComponent() {
  const { user, switchUser } = useUser();
  return (
    <>
      <p>User: {user.name}</p>
      <button onClick={switchUser}>Switch user</button>
    </>
  );
}

I test it like this,

  test('should throw error when not wrapped inside `UserProvider`', () => {
    const err = console.error;
    console.error = jest.fn();
    let actualErrorMsg;
    try {
      render(<TestComponent />);
    } catch(e) {
      actualErrorMsg = e.message;
    }
    const expectedErrorMsg = 'Cannot use `useUser` outside of `UserProvider`';
    expect(actualErrorMsg).toEqual(expectedErrorMsg);

    console.error = err;
  });

I currently have to mock console.error and later set it to its original value at the end of the test. It works. But I'd like to make this more declarative and simpler. Is there a good pattern to achieve it? Something using .toThrow() perhaps?

I have a codesandbox for this, the above code can be found in UserContext.js and UserContext.test.js.

Note: Tests can be run in the codesandbox itself under the Tests tab.

3 Answers

As you already mentioned there is expect().toThrow() :)

So in your case:

  test("should throw error when not wrapped inside `UserProvider`", () => {
    expect(() => render(<TestComponent />))
      .toThrow("Cannot use `useUser` outside of `UserProvider`");
  });

Regarding the console.error: Currently there is by design no way to turn off the default error logs. If you want to hide errors, you still need to mock console.error.

When you mock functions like console.error you want to restore them in a afterEach callback so that they are also restored if the test fails.

You could do something like this

test('should throw error when not wrapped inside `UserProvider`', () => {
  component.useUser = jest.fn().mockRejectedValue(new Error('Cannot use `useUser` outside of `UserProvider`'));
  let actualErrorMsg;
  try {
    render(<TestComponent />);
  } catch(e) {
    actualErrorMsg = e.message;
  }
  const expectedErrorMsg = 'Cannot use `useUser` outside of `UserProvider`';
  expect(actualErrorMsg).toEqual(expectedErrorMsg);
});

It might not be as clean of a solution, but it's simple and easy to figure out. This example uses TypeScript but works fine without. It's also fairly easy to set something like this up once and reuse it elsewhere.

it("errs if provider is missing", () => {
  const HookWrapper = ({
    testId,
  }: {
    testId?: string;
  }) => {
    try {
      const data = useCustomHook();

      return <pre data-testid={testId}>{JSON.stringify({ data }, null, 2)}</pre>;
    } catch (err) {
      const error = err as Error;
      const errorPayload = { message: error.message, stack: error.stack };
      return (
        <pre data-testid={testId}>
          {JSON.stringify({ error: errorPayload }, null, 2)}
        </pre>
      );
    }
  };
  
  render(<HookWrapper testId="HookWrapper" />);

  const providedData = JSON.parse(
    screen.getByTestId("HookWrapper").innerHTML
  );
  const error = providedData.error as Error;

  expect(error).toBeDefined();
  expect(error.message).toEqual("SomeProvider Context not initialized");
});
Related