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 1 import React from 'react' 2 3 const UNDO = 'UNDO' 4 const REDO = 'REDO' 5 const SET = 'SET' 6 const RESET = 'RESET' 7 8 function 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 62 function 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 85 export 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:

1 Error: 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:

1 import React from 'react' 2 import useUndo from '../use-undo' 3 4 function 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 39 export { UseUndoExample }

Here's that rendered:

undo redo New value Submit 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:

1 import React from 'react' 2 import { render , screen , fireEvent } from '@testing-library/react' 3 import { UseUndoExample } from '../use-undo.example' 4 5 test ( '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 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 23 input . value = 'two' 24 fireEvent . click ( submit ) 25 26 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 34 input . value = 'three' 35 fireEvent . click ( submit ) 36 37 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 45 fireEvent . click ( undo ) 46 47 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 55 fireEvent . click ( undo ) 56 57 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 65 fireEvent . click ( redo ) 66 67 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 75 input . value = 'four' 76 fireEvent . click ( submit ) 77 78 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:

1 import React from 'react' 2 import { render , act } from '@testing-library/react' 3 import useUndo from '../use-undo' 4 5 function 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 15 test ( 'allows you to undo and redo' , ( ) => { 16 const undoData = setup ( 'one' ) 17 18 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 26 act ( ( ) => { 27 undoData . set ( 'two' ) 28 } ) 29 30 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 38 act ( ( ) => { 39 undoData . set ( 'three' ) 40 } ) 41 42 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 50 act ( ( ) => { 51 undoData . undo ( ) 52 } ) 53 54 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 62 act ( ( ) => { 63 undoData . undo ( ) 64 } ) 65 66 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 74 act ( ( ) => { 75 undoData . redo ( ) 76 } ) 77 78 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 86 act ( ( ) => { 87 undoData . set ( 'four' ) 88 } ) 89 90 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 :

1 import { renderHook , act } from '@testing-library/react-hooks' 2 import useUndo from '../use-undo' 3 4 test ( 'allows you to undo and redo' , ( ) => { 5 const { result } = renderHook ( ( ) => useUndo ( 'one' ) ) 6 7 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 15 act ( ( ) => { 16 result . current . set ( 'two' ) 17 } ) 18 19 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 27 act ( ( ) => { 28 result . current . set ( 'three' ) 29 } ) 30 31 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 39 act ( ( ) => { 40 result . current . undo ( ) 41 } ) 42 43 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 51 act ( ( ) => { 52 result . current . undo ( ) 53 } ) 54 55 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 63 act ( ( ) => { 64 result . current . redo ( ) 65 } ) 66 67 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 75 act ( ( ) => { 76 result . current . set ( 'four' ) 77 } ) 78 79 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