This site runs best with JavaScript enabled.

How to test custom React hooks

Photo by Grant Durr


Get confidence your custom React hooks work properly with solid tests.

If you're using react@>=16.8, then you can use hooks and you've probably written several custom ones yourself. You may have wondered how to be confident that your hook continues to work over the lifetime of your application. And I'm not talking about the one-off custom hook you pull out just to make your component body smaller and organize your code (those should be covered by your component tests), I'm talking about that reusable hook you've published to github/npm (or you've been talking with your legal department about it).

Let's say we've got this custom hook called useUndo (inspired by useUndo by Homer Chen):

(Note, it's not super important that you understand what it does, but you can expand this if you're curious):

useUndo implementation
1import React from 'react'
2
3const UNDO = 'UNDO'
4const REDO = 'REDO'
5const SET = 'SET'
6const RESET = 'RESET'
7
8function undoReducer(state, action) {
9 const {past, present, future} = state
10 const {type, newPresent} = action
11
12 switch (action.type) {
13 case UNDO: {
14 if (past.length === 0) return state
15
16 const previous = past[past.length - 1]
17 const newPast = past.slice(0, past.length - 1)
18
19 return {
20 past: newPast,
21 present: previous,
22 future: [present, ...future],
23 }
24 }
25
26 case REDO: {
27 if (future.length === 0) return state
28
29 const next = future[0]
30 const newFuture = future.slice(1)
31
32 return {
33 past: [...past, present],
34 present: next,
35 future: newFuture,
36 }
37 }
38
39 case SET: {
40 if (newPresent === present) return state
41
42 return {
43 past: [...past, present],
44 present: newPresent,
45 future: [],
46 }
47 }
48
49 case RESET: {
50 return {
51 past: [],
52 present: newPresent,
53 future: [],
54 }
55 }
56 default: {
57 throw new Error(`Unhandled action type: ${type}`)
58 }
59 }
60}
61
62function useUndo(initialPresent) {
63 const [state, dispatch] = React.useReducer(undoReducer, {
64 past: [],
65 present: initialPresent,
66 future: [],
67 })
68
69 const canUndo = state.past.length !== 0
70 const canRedo = state.future.length !== 0
71 const undo = React.useCallback(() => dispatch({type: UNDO}), [])
72 const redo = React.useCallback(() => dispatch({type: REDO}), [])
73 const set = React.useCallback(
74 newPresent => dispatch({type: SET, newPresent}),
75 [],
76 )
77 const reset = React.useCallback(
78 newPresent => dispatch({type: RESET, newPresent}),
79 [],
80 )
81
82 return {...state, set, reset, undo, redo, canUndo, canRedo}
83}
84
85export default useUndo

Let's say we want to write a test for this so we can maintain confidence that as we make changes and bug fixes 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. Remember that 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.

A mistake that I see a lot of people make is thinking "well, it's just a function right, that's what we love about hooks. So can't I just call the function and assert on the output? Unit tests FTW!" They're not wrong. It is just a function, but technically speaking, it's not a pure function (your hooks are supposed to be idempotent though). If the function were pure, then it would be a simple task of calling it and asserting on the output.

If you try simply calling the function in a test, you're breaking the rules of hooks and you'll get this error:

1Error: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
2 1. You might have mismatching versions of React and the renderer (such as React DOM)
3 2. You might be breaking the Rules of Hooks
4 3. You might have more than one copy of React in the same app
5 See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.

(I've gotten that error for all three reasons mentioned 🙈)

Now, you might start to think: "Hey, if I just mock the built-in React hooks I'm using like useState and useEffect then I could still test it like a function." But for the love of all things pure, please don't do that. You throw away a LOT of confidence in doing so.

But don't fret, if you were to test this manually, rather simply calling the function, you'd probably write a component that uses the hook, and then interact with that component rendered to the page (perhaps using storybook). So let's do that instead:

1import React from 'react'
2import useUndo from '../use-undo'
3
4function UseUndoExample() {
5 const {present, past, future, set, undo, redo, canUndo, canRedo} = useUndo(
6 'one',
7 )
8 function handleSubmit(event) {
9 event.preventDefault()
10 const input = event.target.elements.newValue
11 set(input.value)
12 input.value = ''
13 }
14
15 return (
16 <div>
17 <div>
18 <button onClick={undo} disabled={!canUndo}>
19 undo
20 </button>
21 <button onClick={redo} disabled={!canRedo}>
22 redo
23 </button>
24 </div>
25 <form onSubmit={handleSubmit}>
26 <label htmlFor="newValue">New value</label>
27 <input type="text" id="newValue" />
28 <div>
29 <button type="submit">Submit</button>
30 </div>
31 </form>
32 <div>Present: {present}</div>
33 <div>Past: {past.join(', ')}</div>
34 <div>Future: {future.join(', ')}</div>
35 </div>
36 )
37}
38
39export {UseUndoExample}

Here's that rendered:

Present: one
Past:
Future:

Great, so now we can test that hook manually using the example component that's using the hook, so to use software to automate our manual process, we need to write a test that does the same thing we're doing manually. Here's what that is like:

1import React from 'react'
2import {render, screen, fireEvent} from '@testing-library/react'
3import {UseUndoExample} from '../use-undo.example'
4
5test('allows you to undo and redo', () => {
6 render(<UseUndoExample />)
7 const present = screen.getByText(/present/i)
8 const past = screen.getByText(/past/i)
9 const future = screen.getByText(/future/i)
10 const input = screen.getByLabelText(/new value/i)
11 const submit = screen.getByText(/submit/i)
12 const undo = screen.getByText(/undo/i)
13 const redo = screen.getByText(/redo/i)
14
15 // assert initial state
16 expect(undo).toBeDisabled()
17 expect(redo).toBeDisabled()
18 expect(past).toHaveTextContent(`Past:`)
19 expect(present).toHaveTextContent(`Present: one`)
20 expect(future).toHaveTextContent(`Future:`)
21
22 // add second value
23 input.value = 'two'
24 fireEvent.click(submit)
25
26 // assert new state
27 expect(undo).not.toBeDisabled()
28 expect(redo).toBeDisabled()
29 expect(past).toHaveTextContent(`Past: one`)
30 expect(present).toHaveTextContent(`Present: two`)
31 expect(future).toHaveTextContent(`Future:`)
32
33 // add third value
34 input.value = 'three'
35 fireEvent.click(submit)
36
37 // assert new state
38 expect(undo).not.toBeDisabled()
39 expect(redo).toBeDisabled()
40 expect(past).toHaveTextContent(`Past: one, two`)
41 expect(present).toHaveTextContent(`Present: three`)
42 expect(future).toHaveTextContent(`Future:`)
43
44 // undo
45 fireEvent.click(undo)
46
47 // assert "undone" state
48 expect(undo).not.toBeDisabled()
49 expect(redo).not.toBeDisabled()
50 expect(past).toHaveTextContent(`Past: one`)
51 expect(present).toHaveTextContent(`Present: two`)
52 expect(future).toHaveTextContent(`Future: three`)
53
54 // undo again
55 fireEvent.click(undo)
56
57 // assert "double-undone" state
58 expect(undo).toBeDisabled()
59 expect(redo).not.toBeDisabled()
60 expect(past).toHaveTextContent(`Past:`)
61 expect(present).toHaveTextContent(`Present: one`)
62 expect(future).toHaveTextContent(`Future: two, three`)
63
64 // redo
65 fireEvent.click(redo)
66
67 // assert undo + undo + redo state
68 expect(undo).not.toBeDisabled()
69 expect(redo).not.toBeDisabled()
70 expect(past).toHaveTextContent(`Past: one`)
71 expect(present).toHaveTextContent(`Present: two`)
72 expect(future).toHaveTextContent(`Future: three`)
73
74 // add fourth value
75 input.value = 'four'
76 fireEvent.click(submit)
77
78 // assert final state (note the lack of "third")
79 expect(undo).not.toBeDisabled()
80 expect(redo).toBeDisabled()
81 expect(past).toHaveTextContent(`Past: one, two`)
82 expect(present).toHaveTextContent(`Present: four`)
83 expect(future).toHaveTextContent(`Future:`)
84})

I like this kind of approach because the test is relatively easy to follow and understand. In most situations, this is how I would recommend testing this kind of a hook.

However, sometimes the component that you need to write is pretty complicated and you end up getting test failures not because the hook is broken, but because the example you wrote is which is pretty frustrating.

That problem is compounded by another one. In some scenarios sometimes you have a hook that can be difficult to create a single example for all the use cases it supports so you wind up making a bunch of different example components to test.

Now, having those example components is probably a good idea anyway (they're great for storybook for example), but sometimes it can be nice to create a little helper that doesn't actually have any UI associated with it and you interact with the hook return value directly.

Here's an example of what that would be like for our useUndo hook:

1import React from 'react'
2import {render, act} from '@testing-library/react'
3import useUndo from '../use-undo'
4
5function setup(...args) {
6 const returnVal = {}
7 function TestComponent() {
8 Object.assign(returnVal, useUndo(...args))
9 return null
10 }
11 render(<TestComponent />)
12 return returnVal
13}
14
15test('allows you to undo and redo', () => {
16 const undoData = setup('one')
17
18 // assert initial state
19 expect(undoData.canUndo).toBe(false)
20 expect(undoData.canRedo).toBe(false)
21 expect(undoData.past).toEqual([])
22 expect(undoData.present).toEqual('one')
23 expect(undoData.future).toEqual([])
24
25 // add second value
26 act(() => {
27 undoData.set('two')
28 })
29
30 // assert new state
31 expect(undoData.canUndo).toBe(true)
32 expect(undoData.canRedo).toBe(false)
33 expect(undoData.past).toEqual(['one'])
34 expect(undoData.present).toEqual('two')
35 expect(undoData.future).toEqual([])
36
37 // add third value
38 act(() => {
39 undoData.set('three')
40 })
41
42 // assert new state
43 expect(undoData.canUndo).toBe(true)
44 expect(undoData.canRedo).toBe(false)
45 expect(undoData.past).toEqual(['one', 'two'])
46 expect(undoData.present).toEqual('three')
47 expect(undoData.future).toEqual([])
48
49 // undo
50 act(() => {
51 undoData.undo()
52 })
53
54 // assert "undone" state
55 expect(undoData.canUndo).toBe(true)
56 expect(undoData.canRedo).toBe(true)
57 expect(undoData.past).toEqual(['one'])
58 expect(undoData.present).toEqual('two')
59 expect(undoData.future).toEqual(['three'])
60
61 // undo again
62 act(() => {
63 undoData.undo()
64 })
65
66 // assert "double-undone" state
67 expect(undoData.canUndo).toBe(false)
68 expect(undoData.canRedo).toBe(true)
69 expect(undoData.past).toEqual([])
70 expect(undoData.present).toEqual('one')
71 expect(undoData.future).toEqual(['two', 'three'])
72
73 // redo
74 act(() => {
75 undoData.redo()
76 })
77
78 // assert undo + undo + redo state
79 expect(undoData.canUndo).toBe(true)
80 expect(undoData.canRedo).toBe(true)
81 expect(undoData.past).toEqual(['one'])
82 expect(undoData.present).toEqual('two')
83 expect(undoData.future).toEqual(['three'])
84
85 // add fourth value
86 act(() => {
87 undoData.set('four')
88 })
89
90 // assert final state (note the lack of "third")
91 expect(undoData.canUndo).toBe(true)
92 expect(undoData.canRedo).toBe(false)
93 expect(undoData.past).toEqual(['one', 'two'])
94 expect(undoData.present).toEqual('four')
95 expect(undoData.future).toEqual([])
96})

I feel like this test allows us to interact more directly with the hook (which is why the act is required), and that allows us to cover more cases that may be difficult to write component examples for.

Now, sometimes you have more complicated hooks where you need to wait for mocked HTTP requests to finish, or you want to "rerender" the component that's using the hook with different props etc. Each of these use cases complicates your setup function or your real world example which will make it even more domain-specific and difficult to follow.

This is why @testing-library/react-hooks exists. Here's what this test would be like if we use @testing-library/react-hooks:

1import {renderHook, act} from '@testing-library/react-hooks'
2import useUndo from '../use-undo'
3
4test('allows you to undo and redo', () => {
5 const {result} = renderHook(() => useUndo('one'))
6
7 // assert initial state
8 expect(result.current.canUndo).toBe(false)
9 expect(result.current.canRedo).toBe(false)
10 expect(result.current.past).toEqual([])
11 expect(result.current.present).toEqual('one')
12 expect(result.current.future).toEqual([])
13
14 // add second value
15 act(() => {
16 result.current.set('two')
17 })
18
19 // assert new state
20 expect(result.current.canUndo).toBe(true)
21 expect(result.current.canRedo).toBe(false)
22 expect(result.current.past).toEqual(['one'])
23 expect(result.current.present).toEqual('two')
24 expect(result.current.future).toEqual([])
25
26 // add third value
27 act(() => {
28 result.current.set('three')
29 })
30
31 // assert new state
32 expect(result.current.canUndo).toBe(true)
33 expect(result.current.canRedo).toBe(false)
34 expect(result.current.past).toEqual(['one', 'two'])
35 expect(result.current.present).toEqual('three')
36 expect(result.current.future).toEqual([])
37
38 // undo
39 act(() => {
40 result.current.undo()
41 })
42
43 // assert "undone" state
44 expect(result.current.canUndo).toBe(true)
45 expect(result.current.canRedo).toBe(true)
46 expect(result.current.past).toEqual(['one'])
47 expect(result.current.present).toEqual('two')
48 expect(result.current.future).toEqual(['three'])
49
50 // undo again
51 act(() => {
52 result.current.undo()
53 })
54
55 // assert "double-undone" state
56 expect(result.current.canUndo).toBe(false)
57 expect(result.current.canRedo).toBe(true)
58 expect(result.current.past).toEqual([])
59 expect(result.current.present).toEqual('one')
60 expect(result.current.future).toEqual(['two', 'three'])
61
62 // redo
63 act(() => {
64 result.current.redo()
65 })
66
67 // assert undo + undo + redo state
68 expect(result.current.canUndo).toBe(true)
69 expect(result.current.canRedo).toBe(true)
70 expect(result.current.past).toEqual(['one'])
71 expect(result.current.present).toEqual('two')
72 expect(result.current.future).toEqual(['three'])
73
74 // add fourth value
75 act(() => {
76 result.current.set('four')
77 })
78
79 // assert final state (note the lack of "third")
80 expect(result.current.canUndo).toBe(true)
81 expect(result.current.canRedo).toBe(false)
82 expect(result.current.past).toEqual(['one', 'two'])
83 expect(result.current.present).toEqual('four')
84 expect(result.current.future).toEqual([])
85})

You'll notice it's very similar to our custom setup function. Under the hood, @testing-library/react-hooks is doing something very similar to our original setup function above. A few other things we get from @testing-library/react-hooks are:

  • Utility to "rerender" the component that's rendering the hook (to test effect dependency changes for example)
  • Utility to "unmount" the component that's rendering the hook (to test effect cleanup functions for example)
  • Several async utilities to wait an unspecified amount of time (to test async logic)

Note, you can test more than a single hook by simply calling all the hooks you want in the callback function you pass to renderHook.

Writing a "test-only" component to support some of these requires a fair amount of error-prone boilerplate and you can wind up spending more time writing and testing your test components than the hook you're trying to test.

Conclusion

To be clear, if I were writing and testing the specific useUndo hook, I would go with the real-world example usage. I think it makes the best trade-off between understandability and coverage of our use cases. But there are definitely more complicated hooks where using @testing-library/react-hooks is more useful.

Discuss on TwitterEdit post on GitHub

Share article
TestingJavaScript.com

Your Essential Guide to Flawless Testing

Jump on this self-paced workshop and learn the smart, efficient way to test any JavaScript application.

Start Now

Write well tested JavaScript.

Kent C. Dodds

Kent C. Dodds is a JavaScript software engineer and teacher. He's taught hundreds of thousands of people how to make the world a better place with quality software development tools and practices. He lives with his wife and four kids in Utah.

Join the Newsletter



Kent C. Dodds