Testing Custom React Hooks with Jest

Hooks in React are a new, popular, and extensible way to organize side-effects and statefulness in React components. By composing the base hooks provided by React, developers can build their own custom hooks for use by others.

Redux, ApolloClient, and Callstack have distributed custom hooks to access your app's store, client, and themes using the useContext hook. You can also compose useEffect and useState to wrap API requests or wrap the concept of time.

It's powerful. It's simple. These two statements are not a coincidence, either: the power is the simplicity.

The same goes for testing custom React hooks. By the end of this write-up, you'll think testing hooks as simple, too. We'll learn about React hooks, React in general, some basics of unit testing, and the bigger picture when it comes to automated testing.

Where's the Code?

Don't panic, it's all right here in GitHub. Give the repo a star before cloning.

Running a React Hook

React hooks are not quite normal functions. They need to run in a React render context, otherwise, they will give you an annoying error. Other literature on this topic suggests building a React component in the test and hacking that to test your hook's functionality using component testing tools like Enzyme. I found this approach to be brittle (who wants to maintain an unused component for every hook change?) and unnecessary.

Try using the react-hooks-testing-library. It makes testing React hook behavior, parameters, and return values a breeze. Much easier than dealing with Enzyme, for example.

Key concepts are the renderHook and act utilities. The first is where to specify your React Hook, context, and parameters. For instance, here is how to set up and test a React hook's parameters and return value.

src/hooks/useTime/index.module.test.ts

The act utility is for triggering side-effects for your hook to respond, like events or changing props. It is the same as the act provided by React. We'll see it in action later when we learn (spoiler alert) time control.

Mocking

What is Mocking? (By Analogy)

Mocking is like the beginning of an Indiana Jones movie. It's like filling a bag with sand so when we swap the sandbag with the treasure, the detector doesn't trigger the trap.

  • The bag is called a mock and is an object or a function similar to the dependency (treasure)

  • The detector is the unit we are trying to test. For this article, it's our React Hook

  • The trap is the unintended side-effect, like sending HTTP requests to a backend server

The detector has to not see the difference between the mock and the real thing. For example, you can mock axios so your hook does all the steps to send an HTTP request without actually sending a request. Plus, because you control the mock of axios, you can decide when the promise resolves or rejects.

In React, dependencies can be your package.json dependencies or the exports of other source modules through import/requires, provided through context, or defined elsewhere in the file. If your hook depends on it in order to work, then it's a dependency.

The Core Question of Testing

When it comes to testing anything, from a web application to - yes - a React hook, there are different levels at which we can test. The core question of automated testing is this: how much should we mock? If we mock too many dependencies, we lose confidence that the system works as a whole. However, if we mock too few, the sheer number of moving parts cause our tests to be slower, less reliable, and harder to debug/pinpoint failures. If you want to learn more about managing software quality and automated testing, check out these 2014 slides from Google engineering.

The prevailing wisdom is to have more small tests with mocks and a few large and valuable end-to-end tests. This is often visualized using a pyramid, called the "testing pyramid". In essence, most of your tests should mock a lot (unit tests), then some tests should mock less and ensure lower-level units are wired up together properly (integration tests), and then fewer tests mock even less and ensure the mid-size units interact properly (also integration tests). At the top of the pyramid, tests should have no mocks yet be very few in number (end-to-end tests).

To use another analogy: if you were testing a car, you would test if the sparkplug works, then make sure the assembled piston works after testing its parts, then make sure the engine works after testing its parts, then make sure the whole car starts and survives a lap around the track after testing the gearbox, chassis, brakes, etc.

What does this have to do with testing React hooks?

Your hooks rely on dependencies to get almost anything done. Maybe your hook reads state from a Redux store, or perhaps your hook triggers HTTP requests or GraphQL queries. When unit testing React hooks, you want to avoid depending on anything outside of your UI code, like backend or browser APIs. That way, your tests failing means the problem is with the UI and not somewhere else.

How do I mock my hook's dependencies?

Test frameworks give the ability to create mock functions. I find the Sinon.js documentation for stubs (same idea, different terminology) does a nice job teaching this concept and showing examples. In Jest, the key is jest.fn() or jest.spyOn() for mocking methods.

For our purposes, mocking allows us to fashion a fake function or object without calling the true implementation, avoiding unwanted side-effects.

For example, if I have a class controlling an alert noise in my application, I can call jest.spyOn(NoiseService, 'playLoudHorn') to have that function call, when tested, not try to make a real noise. If the method is supposed to return a boolean to indicate success/failure, you can do .mockReturnValue(true) on the end of a mock to have the mocked function return true for this test.

The hard part about stubbing/mocking is getting access to the dependency so we can spyOn it or so we can replace it entirely with a mock.

The rest of this article is about several ways to inject our mocks into a React hook.

Make Hook Pure, Pass Mocks Through Parameters

In pure functions, all dependencies are passed as arguments to the function. The benefit of this approach is it makes providing (injecting) dependencies during tests straight-forward. Outside of testing, if the dependencies are given default values, the original functionality can be preserved.

In this useTime() hook (read the Medium article for implementation details), a _getTime parameter is exposed to allow specifying a different function to get the current time. (I used Luxon for handling DateTimes, I like it more than momentjs)

src/hooks/useTime/index.ts

Suppose I want to control what time my useTime() hook thinks it currently is. Instead of mocking the Date object or spying on DateTime.local , I can simply pass in a mock function as _getTime . Here's a test where we do exactly that:

src/hooks/useTime/index.pure.test.ts

(For this test, the actual return value of _getTime is not critical, so I used a string)

Of course, by specifying a default function, I can avoid specifying the getTime option when consuming the hook in my components. Here's a sample usage of the useTime hook, without any _getTime specified.

src/hooks/useTime/Countdown.tsx

Mocking Imports

An alternative way to control/spy/mock an import is to use the Jest module mocking tools (or tools like proxyquire) to inject mocks through the module system. Just specify the exact string used for require-ing the dependency, then provide your own mock before importing the unit under test.

src/hooks/useTime/index.module.test.ts

While this example works, I find with larger test files it can be prone to mutation by tests and can, as a result of mutation behavior, become more complicated. Tests should be easy.

Mock React Context

Dependencies in React can be provided through the React Context API. This is how components connected to Redux store are able to access state, for example. Let's look at a trivial hook which accesses a context to build a URL string based on a configuration object.

src/hooks/useConfiguration/index.ts

We're going to test useResumeURL(). Here's a sample usage:

src/hooks/useConfiguration/ConfigSection.tsx

In our test, we can rely on default context, or we can specify a new context using the wrapper option from @testing-library/react-hooks to loop in the context's Provider to inject a mock context value. Let's do both:

src/hooks/useConfiguration/index.wrapper.test.tsx