Testing Hooks in React Native
Since React Hooks have been introduced, code can be much easier extracted from your components. Common business logic, listeners, state management or data fetching can be moved to custom hooks. Finally, hooks can and need to be tested. That will be covered in this article.
I explain with an app how to test React Hooks. Use the code “reime005-medium” to get 70% discount on my Ultimate React Native Testing Guide on Gumroad.
There are a few conventions that you have to follow:
- Hooks need to start with “use”
- Hooks need to be used in functional components only
- Hooks should not be called conditionally
Having an enforcement for the naming of hooks makes sense. They don’t behave like other JavaScript functions. With hooks, you “hook” into the lifecycle of a component. Usually asynchronous, they are being executed after the render phase of a component. There are exceptions, like the useLayoutEffect
, which synchronously updates the UI. This can ensure a smoother view transitions and may be useful for animations.
The simplest hook is useState
, which is for state management. Its return is an array of value and dispatch for changing the value. Take a look at the following example, that is a simple counter state:
const [counter, setCounter] = useState<number>(0);
You can control hooks to get active, using their second array parameter. Passing a changing prop will reflect in a trigger of the hook. For example, the useEffect
hook handles side effect in your app. Take a look at the following code, which resets the counter
to 0, once it reaches 10. There is a timeout of 250 milliseconds, to make a visual difference and show the use case of the return function:
useEffect(() => {
if (counter > 9) {
const timer = setTimeout(() => {
setCounter(0);
}, 250);
return () => clearTimeout(timer);
}
}, [counter]);
Please note that clicking the button fast (faster than 250ms) can result in a counter greater than 10. You could fix that by adding a debounce for the function call, or add a condition so that the counter cannot be increased further:
const increaseCounter = () => {
setCounter(currentCounter => {
if (currentCounter > 9) {
return 10;
}
return currentCounter + 1;
});
};
The increaseCounter
function can be invoked on click of a button, for example. If you take a look at the setCounter
call, you might notice that this is the dispatch (set state action) from the useState
hook. This action should be preferred instead of using the counter
variable, since it is definitely the current value and you cannot run into context / closure issues. Returning a value will reflect in the counter
to change accordingly.
Finally, let’s extract the useEffect
and useState
part into a custom hook, called useCounter
, to make it testable:
const useCounter = () => {
// useState…
// useEffect…
return { counter, setCounter };
};
Testing Hooks
In order to test React Hooks directly, you need to be able to execute them in a specific environment and evaluate its output. You could also test them indirectly, for example by testing a component that consumes the hook. But that would be an integration test, which might not be what you want.
The react-native-testing-library provides two important functions: render a hook (renderHook
) and wait for a condition to be true (waitFor
). Please note that the specific syntax, including the imports, have changed with React 18. So if you use another version, you may need to check the documentation.
A simple test case, that checks whether the counter has the initial value of 7, looks like the following:
it('should have an initial value of 7', async () => {
const hook = renderHook(() => useCounter());
await waitFor(() => expect(hook.result.current.counter).toEqual(7));
});
Now, as you want to test the counter-reset behavior, the act
function from the react-native-testing-library is needed. You pass a callback to that function, that causes updates and re-rendering of the component/hook. Testing the setting of the counter to 10 and then expecting a reset of to 0, looks like following:
it('should trigger a reset of the counter to 0, when the counter reaches 10', async () => {
const hook = renderHook(() => useCounter());
await act(() => {
hook.result.current.setCounter(10);
});
await waitFor(() => expect(hook.result.current.counter).toEqual(0));
});
Finally, to mention is that you can also test the unmount
and rerender
behavior of a hook, so that you could pass different input values, if your hook provides that.