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

4

39

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.

Ambrosane answered 23/2, 2021 at 7:5 Comment(0)
B
53

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.

Bluetongue answered 23/2, 2021 at 8:39 Comment(9)
This doesn't seem to work. I get Error: Uncaught [Error: Cannot use useUser` outside of UserProvider]`Ambrosane
Here you go :) codesandbox.io/s/cool-hofstadter-ksjlw?file=/src/…Bluetongue
You are right, the tests do pass :) An error is still logged which is not caught. Putting in a try catch doesn't work. Isn't there a way to catch the error in the test?Ambrosane
expect(fn).toThrow() catches the error. But before you catch it, it is already logged to the console in your test environment by jsdom.Bluetongue
I was able to suppress the console.log error by mocking it like so: const consoleErrorFn = jest.spyOn(console, 'error').mockImplementation(() => jest.fn()); Don't forget to jest.restoreMocks() if you do go down that path.Inlaw
it's actually mockRestore: consoleErrorFn.mockRestore()Achromatic
You can use the jest --silent option to prevent console output during tests.Renaerenaissance
For some reason I like this option. These get* or find* functions throw if find nothing. But we should not consider it as our program or command crashes. It's just a logical outcome. I think it is perfectly reasonable to write in this way because we might want to have consistency with the other tests where we do expect values and where we want find/get instead of query. This helps to write more concise tests and even skip the expectSeaborne
hmm those logs are actually really annoying and muck up the terminal... wish it were possible to just turn off that output. Polluting the terminal with noise is actually pretty detrimental in generalDynatron
A
2

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);
});
Assemblyman answered 23/2, 2021 at 7:21 Comment(2)
Not sure what component is here?Ambrosane
this didn't work for me because the error is caught inside react library somewhere and console.error'd.Rinna
U
1

For full coverage and a clean console:

  import { render, waitFor } from "@testing-library/react"

  test("should throw error when not wrapped inside `UserProvider`", async() => {
    jest.spyOn(console, 'error').mockImplementation(() => jest.fn());
    await waitFor(() => expect(() => render(<TestComponent />)
      .toThrow("Cannot use `useUser` outside of `UserProvider`")
    );
    jest.restoreAllMocks()
  });
Untold answered 5/9, 2023 at 17:54 Comment(3)
Why do we need waitFor?Ambrosane
This is the same as accepted answer + comments - explanation. Not sure how it adds value?Ambrosane
without the async and await waitFor you still see errors in the consoleUntold
V
0

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");
});
Vouch answered 12/10, 2021 at 19:7 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.