How to test custom React hooks

March 22nd, 2020 — 10 min read

by Grant Durr
by Grant Durr
No translations available.Add translation

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
import * as React from 'react'

const UNDO = 'UNDO'
const REDO = 'REDO'
const SET = 'SET'
const RESET = 'RESET'

function undoReducer(state, action) {
	const { past, present, future } = state
	const { type, newPresent } = action

	switch (action.type) {
		case UNDO: {
			if (past.length === 0) return state

			const previous = past[past.length - 1]
			const newPast = past.slice(0, past.length - 1)

			return {
				past: newPast,
				present: previous,
				future: [present, ...future],
			}
		}

		case REDO: {
			if (future.length === 0) return state

			const next = future[0]
			const newFuture = future.slice(1)

			return {
				past: [...past, present],
				present: next,
				future: newFuture,
			}
		}

		case SET: {
			if (newPresent === present) return state

			return {
				past: [...past, present],
				present: newPresent,
				future: [],
			}
		}

		case RESET: {
			return {
				past: [],
				present: newPresent,
				future: [],
			}
		}
		default: {
			throw new Error(`Unhandled action type: ${type}`)
		}
	}
}

function useUndo(initialPresent) {
	const [state, dispatch] = React.useReducer(undoReducer, {
		past: [],
		present: initialPresent,
		future: [],
	})

	const canUndo = state.past.length !== 0
	const canRedo = state.future.length !== 0
	const undo = React.useCallback(() => dispatch({ type: UNDO }), [])
	const redo = React.useCallback(() => dispatch({ type: REDO }), [])
	const set = React.useCallback(
		(newPresent) => dispatch({ type: SET, newPresent }),
		[],
	)
	const reset = React.useCallback(
		(newPresent) => dispatch({ type: RESET, newPresent }),
		[],
	)

	return { ...state, set, reset, undo, redo, canUndo, canRedo }
}

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:

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:
  1. You might have mismatching versions of React and the renderer (such as React DOM)
  2. You might be breaking the Rules of Hooks
  3. You might have more than one copy of React in the same app
  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:

import * as React from 'react'
import useUndo from '../use-undo'

function UseUndoExample() {
	const { present, past, future, set, undo, redo, canUndo, canRedo } =
		useUndo('one')
	function handleSubmit(event) {
		event.preventDefault()
		const input = event.target.elements.newValue
		set(input.value)
		input.value = ''
	}

	return (
		<div>
			<div>
				<button onClick={undo} disabled={!canUndo}>
					undo
				</button>
				<button onClick={redo} disabled={!canRedo}>
					redo
				</button>
			</div>
			<form onSubmit={handleSubmit}>
				<label htmlFor="newValue">New value</label>
				<input type="text" id="newValue" />
				<div>
					<button type="submit">Submit</button>
				</div>
			</form>
			<div>Present: {present}</div>
			<div>Past: {past.join(', ')}</div>
			<div>Future: {future.join(', ')}</div>
		</div>
	)
}

export { 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:

import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import {UseUndoExample} from '../use-undo.example'

test('allows you to undo and redo', () => {
  render(<UseUndoExample />)
  const present = screen.getByText(/present/i)
  const past = screen.getByText(/past/i)
  const future = screen.getByText(/future/i)
  const input = screen.getByLabelText(/new value/i)
  const submit = screen.getByText(/submit/i)
  const undo = screen.getByText(/undo/i)
  const redo = screen.getByText(/redo/i)

  // assert initial state
  expect(undo).toBeDisabled()
  expect(redo).toBeDisabled()
  expect(past).toHaveTextContent(`Past:`)
  expect(present).toHaveTextContent(`Present: one`)
  expect(future).toHaveTextContent(`Future:`)

  // add second value
  input.value = 'two'
  await userEvent.click(submit)

  // assert new state
  expect(undo).not.toBeDisabled()
  expect(redo).toBeDisabled()
  expect(past).toHaveTextContent(`Past: one`)
  expect(present).toHaveTextContent(`Present: two`)
  expect(future).toHaveTextContent(`Future:`)

  // add third value
  input.value = 'three'
  await userEvent.click(submit)

  // assert new state
  expect(undo).not.toBeDisabled()
  expect(redo).toBeDisabled()
  expect(past).toHaveTextContent(`Past: one, two`)
  expect(present).toHaveTextContent(`Present: three`)
  expect(future).toHaveTextContent(`Future:`)

  // undo
  await userEvent.click(undo)

  // assert "undone" state
  expect(undo).not.toBeDisabled()
  expect(redo).not.toBeDisabled()
  expect(past).toHaveTextContent(`Past: one`)
  expect(present).toHaveTextContent(`Present: two`)
  expect(future).toHaveTextContent(`Future: three`)

  // undo again
  await userEvent.click(undo)

  // assert "double-undone" state
  expect(undo).toBeDisabled()
  expect(redo).not.toBeDisabled()
  expect(past).toHaveTextContent(`Past:`)
  expect(present).toHaveTextContent(`Present: one`)
  expect(future).toHaveTextContent(`Future: two, three`)

  // redo
  await userEvent.click(redo)

  // assert undo + undo + redo state
  expect(undo).not.toBeDisabled()
  expect(redo).not.toBeDisabled()
  expect(past).toHaveTextContent(`Past: one`)
  expect(present).toHaveTextContent(`Present: two`)
  expect(future).toHaveTextContent(`Future: three`)

  // add fourth value
  input.value = 'four'
  await userEvent.click(submit)

  // assert final state (note the lack of "third")
  expect(undo).not.toBeDisabled()
  expect(redo).toBeDisabled()
  expect(past).toHaveTextContent(`Past: one, two`)
  expect(present).toHaveTextContent(`Present: four`)
  expect(future).toHaveTextContent(`Future:`)
})

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:

import * as React from 'react'
import { render, act } from '@testing-library/react'
import useUndo from '../use-undo'

function setup(...args) {
	const returnVal = {}
	function TestComponent() {
		Object.assign(returnVal, useUndo(...args))
		return null
	}
	render(<TestComponent />)
	return returnVal
}

test('allows you to undo and redo', () => {
	const undoData = setup('one')

	// assert initial state
	expect(undoData.canUndo).toBe(false)
	expect(undoData.canRedo).toBe(false)
	expect(undoData.past).toEqual([])
	expect(undoData.present).toEqual('one')
	expect(undoData.future).toEqual([])

	// add second value
	act(() => {
		undoData.set('two')
	})

	// assert new state
	expect(undoData.canUndo).toBe(true)
	expect(undoData.canRedo).toBe(false)
	expect(undoData.past).toEqual(['one'])
	expect(undoData.present).toEqual('two')
	expect(undoData.future).toEqual([])

	// add third value
	act(() => {
		undoData.set('three')
	})

	// assert new state
	expect(undoData.canUndo).toBe(true)
	expect(undoData.canRedo).toBe(false)
	expect(undoData.past).toEqual(['one', 'two'])
	expect(undoData.present).toEqual('three')
	expect(undoData.future).toEqual([])

	// undo
	act(() => {
		undoData.undo()
	})

	// assert "undone" state
	expect(undoData.canUndo).toBe(true)
	expect(undoData.canRedo).toBe(true)
	expect(undoData.past).toEqual(['one'])
	expect(undoData.present).toEqual('two')
	expect(undoData.future).toEqual(['three'])

	// undo again
	act(() => {
		undoData.undo()
	})

	// assert "double-undone" state
	expect(undoData.canUndo).toBe(false)
	expect(undoData.canRedo).toBe(true)
	expect(undoData.past).toEqual([])
	expect(undoData.present).toEqual('one')
	expect(undoData.future).toEqual(['two', 'three'])

	// redo
	act(() => {
		undoData.redo()
	})

	// assert undo + undo + redo state
	expect(undoData.canUndo).toBe(true)
	expect(undoData.canRedo).toBe(true)
	expect(undoData.past).toEqual(['one'])
	expect(undoData.present).toEqual('two')
	expect(undoData.future).toEqual(['three'])

	// add fourth value
	act(() => {
		undoData.set('four')
	})

	// assert final state (note the lack of "third")
	expect(undoData.canUndo).toBe(true)
	expect(undoData.canRedo).toBe(false)
	expect(undoData.past).toEqual(['one', 'two'])
	expect(undoData.present).toEqual('four')
	expect(undoData.future).toEqual([])
})

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 renderHook from @testing-library/react exists. Here's what this test would be like if we use @testing-library/react:

import { renderHook, act } from '@testing-library/react'
import useUndo from '../use-undo'

test('allows you to undo and redo', () => {
	const { result } = renderHook(() => useUndo('one'))

	// assert initial state
	expect(result.current.canUndo).toBe(false)
	expect(result.current.canRedo).toBe(false)
	expect(result.current.past).toEqual([])
	expect(result.current.present).toEqual('one')
	expect(result.current.future).toEqual([])

	// add second value
	act(() => {
		result.current.set('two')
	})

	// assert new state
	expect(result.current.canUndo).toBe(true)
	expect(result.current.canRedo).toBe(false)
	expect(result.current.past).toEqual(['one'])
	expect(result.current.present).toEqual('two')
	expect(result.current.future).toEqual([])

	// add third value
	act(() => {
		result.current.set('three')
	})

	// assert new state
	expect(result.current.canUndo).toBe(true)
	expect(result.current.canRedo).toBe(false)
	expect(result.current.past).toEqual(['one', 'two'])
	expect(result.current.present).toEqual('three')
	expect(result.current.future).toEqual([])

	// undo
	act(() => {
		result.current.undo()
	})

	// assert "undone" state
	expect(result.current.canUndo).toBe(true)
	expect(result.current.canRedo).toBe(true)
	expect(result.current.past).toEqual(['one'])
	expect(result.current.present).toEqual('two')
	expect(result.current.future).toEqual(['three'])

	// undo again
	act(() => {
		result.current.undo()
	})

	// assert "double-undone" state
	expect(result.current.canUndo).toBe(false)
	expect(result.current.canRedo).toBe(true)
	expect(result.current.past).toEqual([])
	expect(result.current.present).toEqual('one')
	expect(result.current.future).toEqual(['two', 'three'])

	// redo
	act(() => {
		result.current.redo()
	})

	// assert undo + undo + redo state
	expect(result.current.canUndo).toBe(true)
	expect(result.current.canRedo).toBe(true)
	expect(result.current.past).toEqual(['one'])
	expect(result.current.present).toEqual('two')
	expect(result.current.future).toEqual(['three'])

	// add fourth value
	act(() => {
		result.current.set('four')
	})

	// assert final state (note the lack of "third")
	expect(result.current.canUndo).toBe(true)
	expect(result.current.canRedo).toBe(false)
	expect(result.current.past).toEqual(['one', 'two'])
	expect(result.current.present).toEqual('four')
	expect(result.current.future).toEqual([])
})

You'll notice it's very similar to our custom setup function. Under the hood, @testing-library/react is doing something very similar to our original setup function above. A few other things we get from @testing-library/react 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 is more useful.

Epic React

Get Really Good at React

Illustration of a Rocket

Testing JavaScript

Ship Apps with Confidence

Illustration of a trophy
Kent C. Dodds
Written by Kent C. Dodds

Kent C. Dodds is a JavaScript software engineer and teacher. Kent'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.

Learn more about Kent

Want to learn more?

Join Kent in a live workshop

If you found this article helpful.

You will love these ones as well.