My Journey
As I started to use hooks more and more, I also started to create my own hooks to generalize functionality into common code. Naturally, the next logical step would be to test. But, I soon discovered this process wasn’t as easy as I had pictured in my head. Calling a hook directly in a test would trigger this error:
Invalid hook call. Hooks can only be called inside of the body of a function component.
But how do I do this in my test?
React Hooks Testing Library
React Hooks Testing Library to the rescue. It provides a renderHook API that wraps the hook inside a function component to resolve the error. This API will return helpful utilities that enable you to fully test your hook.
Example
Let’s take a simple hook that connects React with localStorage as an example:
import { useCallback, useState } from 'react';
export default function useLocalStorage(key, initialState) {
const [value, setValue] = useState(localStorage.getItem(key) ?? initialState);
const updatedSetValue = useCallback(
newValue => {
if (newValue === initialState || typeof newValue === 'undefined') {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, newValue);
}
setValue(newValue ?? initialState);
},
[initialState, key]
);
return [value, updatedSetValue];
}
This simple hooks acts like React’s native useState hook and syncs the state with localStorage.
Now, with React Hooks Testing Library, let’s first start off with a few simple tests to ensure that the outputs are correct.
import { act, renderHook } from '[@testing](http://twitter.com/testing)-library/react-hooks';
import useLocalStorage from './useLocalStorage';
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
const key = 'KEY';
test('should return localStorage value', () => {
localStorage.setItem(key, 'value');
const { result } = renderHook(() => useLocalStorage(key));
expect(result.current).toStrictEqual(
['value', expect.any(Function)]
);
});
test('should return the default value', () => {
const initialState = 'default';
const { result } = renderHook(
() => useLocalStorage(key, initialState)
);
expect(result.current).toStrictEqual(
['default', expect.any(Function)]
);
});
renderHook accepts a function that will call your hook. This returns a result.current that contains the hook return value. This value is then tested to ensure that it matches what we expect. Pretty simple right?
Interactions
The useLocalStorage hook also returns a setter that updates the state value. Interacting with this setter is also simple with React Hooks Testing Library.
import { act, renderHook } from '[@testing](http://twitter.com/testing)-library/react-hooks';
const key = 'KEY';
test('should update when set is called', () => {
const initialState = 'default';
const { result } = renderHook(
() => useLocalStorage(key, initialState)
);
expect(result.current).toStrictEqual(
['default', expect.any(Function)]
);
const [, setValue] = result.current;
act(() => {
setValue('value');
});
expect(localStorage.getItem(key)).toEqual('value');
expect(result.current).toStrictEqual(
['value', expect.any(Function)]
);
});
Here, setValue is called with a new value and localStorage and result.current are checked to ensure the states are correctly updated. setValue is wrapped with an act call so the state update can happen.
Note: React Testing Library simply mutates the original result.current from the renderHook call with the updated value. Because of this, doing the following will not work:
const [value, setValue] = result.current;
act(() => {
setValue('value');
});
expect(value).toEqual('value');
RenderHook API
Input
The renderHook function also supports an options object as the 2nd parameter.
wrapper is used to wrap the hook in an additional React component. This is used to give your hook the additional context it needs. For example, if your hook requires Redux’s dispatch, you would pass Redux’s Provider as a wrapper so useDispatch will be work.
import { createStore } from 'redux';
import { Provider, useDispatch } from 'react-redux'
function hook() {
const dispatch = useDispatch();
...
}
test('should work', () => {
const wrapper = ({ children }) => (
<Provider store={createStore()}>
{children}
</Provider>
);
const { result } = renderHook(() => hook(), { wrapper });
});
initialProps is used to set the initial hook values. It’s often used in conjunction with renderHook’s rerender to simulate prop changes.
function hook(value) {
...
return value;
}
test('should return updated value', () => {
const { rerender, result } = renderHook(
({ value }) => hook(value),
{ initialProps: { value: 'initial' }}
);
expect(result.current).toEqual('initial');
rerender({ value: 'updated' });
expect(result.current).toEqual('updated');
});
Output
The renderHook function outputs an object. You’re already familiar with result but there are also other helpful methods that can help test the full hook lifecycle.
rerender is a function that can be called to re-render the hook with new props which are passed in as a parameter. See above for the initialProps example.
unmount is a function that can be called to simulate the component being removed from the DOM. It is used to trigger useEffect cleanup effects.
import { useEffect } from 'react';
function hook() {
...
useEffect(() => {
console.log('mounted');
return () => console.log('unmounting');
}, []);
};
test('should trigger cleanup effect', () => {
jest.spyOn(console, 'log');
const { unmount } = renderHook(() => hook());
expect(console.log).toHaveBeenCalledWith('mounted');
unmount();
expect(console.log).toHaveBeenCalledWith('unmounting');
});
Final Thoughts
React Hooks Testing Library provides a simple framework to fully test your custom React hooks and achieve complete test coverage. With it, you can easily test the full lifecycle of a hook from mount to update to unmount. Now that you’ve learned this, go out and make sure your React hooks are bug-free!