Testing React Hooks

James Fulford
Jan 6 · 7 min read

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 React hooks is simple, too. We’ll learn about:

  • Philosophy of Automated Testing (in any application!)
  • React (hooks, testing, asynchronous nature of rendering, React context API)
  • And some useful tools for unit testing (Sinon.js clocks, react context, react-hooks-testing-library, mocking in Jest),

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.

const { result } = renderHook(() => useTime(100));expect(result.current).toBe('mockNow');

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.

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.

Image for post
Image for post
  • 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, 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 Struggle of Testing: The Mocking Spectrum

The value of automated testing seems obvious. We want to validate that changes do not break existing functionality. They help preserve business value in the midst of change. However, testing can fall into certain traps that add costs or fail to provide value. Therefore, testing strategy is, above all, about economics. The best tests are cheap to maintain and valuable: they provide confidence without blocking new development.

When it comes to testing anything, from a web application to — yes — a React hook, there are different levels at which we can test, each having implications to cost. 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 and have to write a lot more for more coverage. Lower-level tests are the first tests to be rendered useless by major refactors. At worst, low-level tests can creep into testing implementation instead of behavior, failing when implementations change instead of functionality breaking. “Unit” tests are at this extreme end of the mocking spectrum. Think “Jest”.

However, if we mock too few, the sheer number of moving parts cause our tests to be slower, less reliable (“flakiness”), and harder to debug/pinpoint failures. However, they provide more confidence and cover more ground than a unit test. “End-to-end” or “System” tests own this extreme of the mocking spectrum. Think “Selenium”.

On the mocking spectrum, everything between testing units and testing end-to-end is called “integration tests”, or sometimes “functional tests” in API contexts. There are many shades in this spectrum, from multi-unit tests in a unit test framework to mostly end-to-end tests with a couple mocks (for the latter, cypress.io opens a lot of options. Give it a try!). A spectrum is a more useful way to think about this.

The prevailing wisdom is called “left-shifting”: when both high- and low-level tests are viable options, choose low-level options because they are more reliable and better equipped to test specific issues. I would underscore that this does not mean you must have more unit tests (as the “testing pyramid” idea perpetuates). Tests should lay where the business value lies. I find that is usually in integration tests, except for exhaustive testing of critical business logic or algorithms (unit tests) and a login test and some common use cases (end-to-end).

If you want to learn more about managing software quality and automated testing, check out these 2014 slides from Google engineering.

Wait, wasn’t this article about 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 your hook and not somewhere else.

If you’re using a hook in only 1 component, you should do a low-level integration test by testing it within that component instead of unit testing the hook. That should give your test more value and confidence. Focus your hook unit tests on reused and critical hooks.

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 as mocks, just different terminology) does a nice job teaching this concept and showing examples. In Jest, use 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 or instability.

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. There is a lot of power built-in here (promises, mock implementations, etc.), so I advise reading more in the Jest docs.

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 mocks into React hooks. Some of the principles apply outside of hooks, too.

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 interface 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). Here’s the signature (warning, some TypeScript ahead):

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:

(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.

Notice how the `useTime` usage does not specify _getTime.

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.

I don’t need to import `getTime` to run this test, since `useTime` imports it. However, I import it for some assertions.

While this example works, I find this approach does not scale well with complexity as a test file grows (and can be prone to intra-test mutations, which leads to race conditions and instability in your test code). Tests should be easy. Be advised. Remember, starting another test file for the same unit is always an option.

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.

This hook uses context to get the URL to someone’s resume. Default development values are used, but ConfigurationContext.Provider can be used to set different values in our app.

Here's a sample usage:

Notice there is no direct way to tell useResumeURL what context values to use here.

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:

Made a quick testing factory to build wrappers, so future tests would not be tied to a specific set of values. This keeps tests independent, which is a best practice.

Unconventional Mocking

Some dependencies can be controlled in unconventional ways. setTimeout and setInterval (generally, the concept of time) can be controlled using Jest (and Sinon.js, used in this example). Usually, these instrumentation tools are called “clocks”.

Through experimentation, I found the passage of time has to occur inside act callbacks for the hook to register the effects properly.

Another common unconventional dependency mock wraps the XMLHttpRequest (XHR) object so you can block and assert on outgoing network requests, plus control the responses and test the resulting behavior (very nice for testing uncommon API failure scenarios). However, this can be framework-specific (looking at you, $httpBackend) or covered by mocking functions.

JavaScript In Plain English

New JavaScript + Web Development articles every day.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch

Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore

Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store