33

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.

Bhargav Shah
  • 737
  • 1
  • 7
  • 15

3 Answers3

42

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.

  • 3
    This doesn't seem to work. I get `Error: Uncaught [Error: Cannot use `useUser` outside of `UserProvider`]` – Bhargav Shah Feb 23 '21 at 08:50
  • 1
    Here you go :) https://codesandbox.io/s/cool-hofstadter-ksjlw?file=/src/UserContext.test.js – Philipp Fritsche Feb 23 '21 at 09:04
  • 1
    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? – Bhargav Shah Feb 23 '21 at 09:37
  • `expect(fn).toThrow()` catches the error. But before you catch it, it is already logged to the console in your test environment by jsdom. – Philipp Fritsche Feb 23 '21 at 09:54
  • 10
    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. – Samir K Jun 08 '21 at 01:20
  • 2
    it's actually mockRestore: ```consoleErrorFn.mockRestore()``` – okram Feb 07 '22 at 15:04
  • You can use the `jest --silent` option to prevent console output during tests. – tim-phillips Aug 03 '22 at 00:34
  • 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 expect – Paul Pacurar Sep 22 '22 at 12:53
  • 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 general – ICW Dec 25 '22 at 15:50
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);
});
Yadab
  • 1,767
  • 1
  • 10
  • 16
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");
});
David R.
  • 281
  • 2
  • 14