A Quick Guide to Testing Custom React Hooks
Harsh Choudhary
Posted on December 10, 2021
The developer on the cover is clearly not looking happy. No no this has nothing to do with Testing.
Testing is Fun ;), and if you hate it maybe I can change your mind, stay with me.
We are utilising our super cool useAsync()
hook that we developed in the previous part of the series but you don't need to read them in order to move ahead. This can be treated as a standalone blog on its own but I am categorising it as part 3 of our useAsync()
hook series.
The useAsync
hook looks like this:
function useSafeDispatch(dispatch) {
const mounted = React.useRef(false)
React.useLayoutEffect(() => {
mounted.current = true
return () => (mounted.current = false)
}, [])
return React.useCallback(
(...args) => (mounted.current ? dispatch(...args) : void 0),
[dispatch],
)
}
const defaultInitialState = {status: 'idle', data: null, error: null}
function useAsync(initialState) {
const initialStateRef = React.useRef({
...defaultInitialState,
...initialState,
})
const [{status, data, error}, setState] = React.useReducer(
(s, a) => ({...s, ...a}),
initialStateRef.current,
)
const safeSetState = useSafeDispatch(setState)
const setData = React.useCallback(
data => safeSetState({data, status: 'resolved'}),
[safeSetState],
)
const setError = React.useCallback(
error => safeSetState({error, status: 'rejected'}),
[safeSetState],
)
const reset = React.useCallback(
() => safeSetState(initialStateRef.current),
[safeSetState],
)
const run = React.useCallback(
promise => {
if (!promise || !promise.then) {
throw new Error(
`The argument passed to useAsync().run must be a promise. Maybe a function that's passed isn't returning anything?`,
)
}
safeSetState({status: 'pending'})
return promise.then(
data => {
setData(data)
return data
},
error => {
setError(error)
return Promise.reject(error)
},
)
},
[safeSetState, setData, setError],
)
return {
isIdle: status === 'idle',
isLoading: status === 'pending',
isError: status === 'rejected',
isSuccess: status === 'resolved',
setData,
setError,
error,
status,
data,
run,
reset,
}
}
export {useAsync}
Give it a read, it is fairly simple to understand and if you want to deep dive into a particular part, check part 2 here which is a thorough guide covering ideation to implementation and optimization.
We want to write a test for this so we can maintain confidence that as we make changes and fixes bugs we don't break existing functionality. To get the maximum confidence we need, we should ensure that our tests resemble the way the software will be used. The software is all about automating things that we don't want to or cannot do manually. Tests are no different, so consider how you would test this manually, then write your test to do the same thing.
You might be saying "Well hooks are functions only, we can unit test them."
Can we?
- Hooks are not ** pure** functions, else if it was pure, then it would be a simple task of calling it and asserting on the output.
- Also by simply calling the function in a test, we'll break the rules of hooks and we'll be greeted with
Error: Invalid hook call
.
Kent (creator of the react-testing library) advises to not test custom hooks separately and recommends running them as part of integration tests with other components. Doing this will also help avoid our natural tendency to over-abstract our custom hook to support things that your components don't actually need. However, highly reusable or complex hooks can really benefit from a solid suite of tests dedicated to them specifically.
Taking his words, we should stay away from testing hooks separately if they are fairly simple and try to cover them in the integration tests while testing the component. But ours is not that simple and the component that we need to write can be pretty complicated and we can end up getting test failures not because the hook is broken, but because of the example, we wrote.
Hell Yeah! Now we know what to test and why to test!
We have two approaches:
Create a test component that uses the hook in the typical way the hook would
be used by consumers and test that component.
In this blog, we will cover the second approach using react-testing-library
.
Before we jump straight into testing let's create a helper deferred method for mocking JavaScript Promise
behavior.
function deferred() {
let resolve, reject
const promise = new Promise((res, rej) => {
resolve = res
reject = rej
})
return {promise, resolve, reject}
}
It is a simple method with which we can imperatively resolve or reject whenever we want.
const {promise, resolve,reject} = deferred()
//resolve
const fakeResolvedValue = Symbol('some resolved value')
run(promise)
resolve(resolvedValue)
await promise
//reject
const rejectedValue = Symbol('rejected value')
run(promise)
reject(rejectedValue)
await promise.catch(() => {
/* ignore error */
})
You would have got a fair idea, things will get more clear once we start testing.
Testing Time
import {renderHook} from '@testing-library/react-hooks'
import {useAsync} from '../hooks'
test('calling run with a promise which resolves', async () => {
const {promise, resolve} = deferred()
//this is how we can render the hook using the library
const {result} = renderHook(() => useAsync())
//try console logging result.current and see what exactly is the result object
console.log(result)
}
This is what it prints:
{
isIdle: true,
isLoading: false,
isError: false,
isSuccess: false,
setData: [Function (anonymous)],
setError: [Function (anonymous)],
error: null,
status: 'idle',
data: null,
run: [Function (anonymous)],
reset: [Function (anonymous)]
}
This looks like what our hook will assign upon initialisation or we can say default state.
Function(anonymous)
is not of our concern, basically, it says that it is some function and we don't need to know much more than that. So, we will assert them using expect.any(Function)
and our job is done.
Also, let's create a default, pending, resolved and rejected state object for our ease.
const defaultState = {
status: 'idle',
data: null,
error: null,
isIdle: true,
isLoading: false,
isError: false,
isSuccess: false,
run: expect.any(Function),
reset: expect.any(Function),
setData: expect.any(Function),
setError: expect.any(Function),
}
const pendingState = {
...defaultState,
status: 'pending',
isIdle: false,
isLoading: true,
}
const resolvedState = {
...defaultState,
status: 'resolved',
isIdle: false,
isSuccess: true,
}
const rejectedState = {
...defaultState,
status: 'rejected',
isIdle: false,
isError: true,
}
Now everything is set, so let's complete our tests.
TEST 1: Calling run with a promise which resolves
test('calling run with a promise which resolves', async () => {
const {promise, resolve} = deferred()
const {result} = renderHook(() => useAsync())
expect(result.current).toEqual(defaultState)
/* we will pass our promise to run method and check if we are getting
pending state or not */
let p
act(() => {
p = result.current.run(promise)
})
expect(result.current).toEqual(pendingState)
/* We are resolving our promise and asserting if the value is
equal to resolvedValue */
const resolvedValue = Symbol('resolved value')
await act(async () => {
resolve(resolvedValue)
await p
})
expect(result.current).toEqual({
...resolvedState,
data: resolvedValue,
})
// asserting if reset method is working or not
act(() => {
result.current.reset()
})
expect(result.current).toEqual(defaultState)
})
What is the act
here?
In short,
act() makes sure that anything that might take time - rendering, user events, data fetching - within it is completed before test assertions are run.
Yay! our first test on its own has made us so confident in our hook. It has tested the complete happy path from initialisation to resolution and even resetting of state.
But we will be more confident when the hook passes the promise rejection test, the sad path :(.
TEST 2: Calling run with a promise which rejects
test('calling run with a promise which rejects', async () => {
const {promise, reject} = deferred()
const {result} = renderHook(() => useAsync())
expect(result.current).toEqual(defaultState)
let p
act(() => {
p = result.current.run(promise)
})
expect(result.current).toEqual(pendingState)
/* same as our first test till now but now we will reject the promise
assert for rejectedState with our created rejected value */
const rejectedValue = Symbol('rejected value')
await act(async () => {
reject(rejectedValue)
await p.catch(() => {
/* ignore error */
})
})
expect(result.current).toEqual({...rejectedState, error: rejectedValue})
})
Notice that how our tests are resembling with how our software will be used in the real world. This way we are making sure that we're focusing our efforts in the right place and not testing at too low of a level unnecessarily.
I will not write all the tests as it will make the blog too long, feel free to check all the tests implementation on Github.
Try implementing the below tests yourself and if you face any doubt or problems, feel free to ask in the comments or DM me:
- TEST 3: can specify an initial state
- TEST 4: can set the data
- TEST 5: can set the error
- TEST 6: No state updates happen if the component is unmounted while pending
- TEST 7: calling "run" without a promise results in an early error
A little about me, I am Harsh and I love to code, I feel at home while building web apps in React. I am currently learning Remix.
If you liked this blog, I am planning to bring more such blogs in Future, Let's keep in touch!
Check my portfolio: harshkc.tech
Posted on December 10, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.