DEV Community

loading...

Sanely Testing React Hooks

Dave Cooper
Hi there - I'm Dave! I love to build and break all the things! Drop me a line if you want to chat about the things!
Originally published at davecooper.org ・4 min read

Hi there 👋 Let's talk about how to test React hooks.

Suppose we have a React application (with TypeScript) that uses Redux for state management.

Suppose inside said application you have a hook that does the following:

  1. Dispatch an action which ends up make an API call to get a thing and put it into state.
  2. Returns that thing from state.

It might even look like this:

useThing.ts

import { useSelector, useDispatch } from "react-redux";
import { useEffect } from "react";
import { getThingStart } from "./redux/actions";

const useThing = () => {
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch(getThingStart());
  }, [dispatch]);

  return useSelector(state => state.thing);
};

export { useThing };

We can then use this hook inside a component:

MyComponent.tsx

import React from "react";
import { useThing } from "./useThing";

const MyComponent = () => {
  const { thing } = useThing();

  if (!thing) {
    return <div>Loading...</div>;
  }

  return <div>This is your thing: {thing}</div>;
};

We might even have many components that use this hook.

We probably want to test that this hook behaviour works as expected.

How do we do this? What would good tests for this look like?

The most common way I see custom hooks being tested is by testing a component that uses the custom hook. I'm really not a fan of this as component can have so many things going on inside them that could effect the internal state of a component. This effects the confidence we can have in the test which isn't really what we're aiming for.

Since we can't call hooks outside of components (with some exceptions), I also see people wrapping their hooks with dummy components. I'm not sure if this is better or worse than the previously mentioned strategy, but it still doesn't make me happy. There are also cases of when things don't go according to plan within the component that probably aren't being catered for in a simple dummy component.

Why don't we try treat testing hooks as closely as we can to unit testing a regular JavaScript function? After all, hooks are just functions...

Fortunately, we can write tests for our hooks in this style thanks to react-hook-testing-library. It provides a renderHook function which lets us pass in our hook and execute it. Under the hood, renderHook is using the hook within a dummy component, but the difference here is:

  • To the test-writer, it appears that we are just executing a function with a callback - not an uncommon thing to do.
  • The dummy component is very defensively programmed and can handle pretty much any error/exception case gracefully (it's actually somewhat complicated to do)
    • I took a look through the source code for this function and I'm really glad it wasn't me that had to write it...

Let's see what tests for this hook might look like (using Jest):

useThing.spec.ts

import { renderHook } from "@testing-library/react-hooks";
import { getThingStart } from "./redux/actions";
import { useThing } from "./useThing";

jest.mock("react-redux", () => ({
  useSelector: jest.fn(),
  useDispatch: jest.fn()
}));

const mockUseSelector = useSelector as jest.Mock;
const mockUseDispatch = useDispatch as jest.Mock;
const mockDispatch = jest.fn();

describe("useThing hook", () => {
  it("calls dispatch and retrieves our thing", () => {
    mockUseDispatch.mockImplementation(() => mockDispatch);
    mockUseSelector.mockImplementation(
      callback => callback({ thing: "this is our thing" }) // This is our mocked state.
    );

    const { result } = renderHook(() => useThing()); // Call our hook.

    expect(result.current).toBe("this is our thing"); // Make sure hook returns our slice of state.
    expect(mockDispatch).toHaveBeenCalledWith(getThingsStart()); // Make sure the right action was dispatched.
  });
});

Lovely.

To break down what the test is doing...

jest.mock("react-redux", () => ({
  useSelector: jest.fn(),
  useDispatch: jest.fn()
}));

const mockUseSelector = useSelector as jest.Mock;
const mockUseDispatch = useDispatch as jest.Mock;
const mockDispatch = jest.fn();

These lines set up our mocked behaviour for useSelector, useDispatch and dispatch. We need to be able to mock implementations for useSelector and useDispatch and we need to spy on what dispatch was called with.

mockUseDispatch.mockImplementation(() => mockDispatch);
mockUseSelector.mockImplementation(callback =>
  callback({ thing: "this is our thing" })
);

These lines tell the useDispatch hook to return our mocked dispatch function and for the useSelector hook to call a callback containing a mocked state object.

const { result } = renderHook(() => useThing());

This line calls renderHook and tells it to run our useThing hook. renderHook returns a result object.

expect(result.current).toBe("this is our thing");
expect(mockDispatch).toHaveBeenCalledWith(getThingsStart());

Finally, we make our assertions! We first assert that the useThing hook returned the right value. Next we make sure that dispatch was called with the right action to dispatch.

Final thoughts

We now have a hook that we've concisely and confidently tested 🎉

I'm really happy with this pattern of testing hooks and I think that people should consider treating their hook tests more like their unit tests.

I'd love to hear any thoughts about this, so please feel free to reach out to me about it :)

-Dave

Discussion (4)

Collapse
mpeyper profile image
Michael Peyper • Edited

Hey, author or react-hooks-testing-library here...

I took a look through the source code for this function and I'm really glad it wasn't me that had to write it...

I'm so sorry!

Seriously though, I'm really glad you liked it 😃

Collapse
grug profile image
Dave Cooper Author

Hey Michael - thanks so much for responding to the post :) I love your library - it must have taken ages to work out all of the edge cases for running hooks within a dummy environment. How long did it take you to come out with a stable release?

Also, what do you think of my opinions about how hooks should be tested? Do you have any alternative suggestions/thoughts?

If you ever find yourself in London, hit me up - I definitely owe you a beer 🍺

Collapse
mpeyper profile image
Michael Peyper • Edited

How long did it take you to come out with a stable release?

Good question. The API was more or less stable after about 3 months, but I made a lot of internal changes for another 3 or so months after that. I felt like we were out of alpha versions and ready for a v1 release about 8 months after the first release.

To be clear though, a lot of the complexity came from trying to support more use cases such as async state updates and suspense. If you have a basic hook, the component could simplified a lot.

Also, what do you think of my opinions about how hooks should be tested?

Obviously I'm a little baised in thinking that it can be very useful to test hooks in isolation. Kent C. Dodds (author of react-testing-library and react testing advocate) will tell you that testing the components is the best way, and in many ways I agree with him, especially his points about testing implementation details, but I do think that some complex hooks, especially those that rely on useEffect can get complicated to test through a component (I bet there are lots of people that aren't unmounting their components to test that their effects get cleaned up correctly).

One of the great features of custom hooks is that they can hide away a tonne of complexity behind a simple API for the component to consume. When you test through the component, it's easy to miss those edge cases, especially when the component is consuming multiple complex custom hooks and passing values between them.

At the end of the day, testing is all about confidence. You do as much or as little as you need to to be confident your code works. If testing the hook separately gives you that, then my opinion is that you should do it.

Do you have any alternative suggestions/thoughts?

Personally, I find mocking out things like a Redux store to be a bit futile sometimes. You can just as easily create a real store (or just import your apps own store) and assert the resulting state from the dispatched actions. You can add a Provider using the wrapper option of renderHook (example).

Admittedly, this crosses the line from unit test to integration test, but it will often result in tests that break less often when refactoring the code.

If you ever find yourself in London, hit me up - I definitely owe you a beer 🍺

Cheers mate! If you find yourself in Melbourne, I'll let you buy me a beer here too 😉

Thread Thread
grug profile image
Dave Cooper Author

Thanks so much for the in-depth response. It's very cool getting to discuss this stuff with you.

(I bet there are lots of people that aren't unmounting their components to test that their effects get cleaned up correctly).

One of the great features of custom hooks is that they can hide away a tonne of complexity behind a simple API for the component to consume. When you test through the component, it's easy to miss those edge cases, especially when the component is consuming multiple complex custom hooks and passing values between them.

Couldn't agree more about this!

Personally, I find mocking out things like a Redux store to be a bit futile sometimes.

Me too - I'm trying to find something that exists to make life a bit easier when it comes to either mocking the Redux store for tests or just a nice pattern that allows people to use a provider in their tests (not just when it comes to testing hooks)