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
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.
Don't panic, it's all right here in GitHub. Give the repo a star before cloning.
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
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.
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 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.
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.
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.
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.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.
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.
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)
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.