TUTORIAL

React Hooks Part 9: Testing Hooks with React Testing Library

Hooks are just functions, but testing them well requires care. This final article covers testing hooks through components (the preferred way), using renderHook for isolated custom hook tests, and common async patterns.


SERIES

React Hooks: A Complete Guide

A four-part deep-dive into React Hooks — from managing state with useState to squeezing performance out of useCallback and useMemo. Each article is standalone yet builds on the previous, giving you both quick reference and progressive mastery.

Table of Contents

The philosophy: test behaviour, not implementation

React Testing Library's guiding principle is "the more your tests resemble the way your software is used, the more confidence they give you." For hooks, this means testing through the component that uses them — not the hook internals directly.

Setup

Bash
npm install --save-dev @testing-library/react @testing-library/jest-dom

Testing hooks through components

The best way to test a hook is to render a component that uses it and assert on what the user would see:

JSX
// useCounter.js
import { useState } from 'react'

export function useCounter(initial = 0) {
  const [count, setCount] = useState(initial)
  const increment = () => setCount(c => c + 1)
  const decrement = () => setCount(c => c - 1)
  const reset     = () => setCount(initial)
  return { count, increment, decrement, reset }
}
JSX
// useCounter.test.jsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useCounter } from './useCounter'

function Counter() {
  const { count, increment, decrement, reset } = useCounter(5)
  return (
    <div>
      <p data-testid="count">{count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  )
}

test('starts at initial value', () => {
  render(<Counter />)
  expect(screen.getByTestId('count')).toHaveTextContent('5')
})

test('increments on click', async () => {
  render(<Counter />)
  await userEvent.click(screen.getByText('+'))
  expect(screen.getByTestId('count')).toHaveTextContent('6')
})

test('resets to initial value', async () => {
  render(<Counter />)
  await userEvent.click(screen.getByText('+'))
  await userEvent.click(screen.getByText('Reset'))
  expect(screen.getByTestId('count')).toHaveTextContent('5')
})

renderHook for isolated custom hook tests

Sometimes a hook has complex logic that is hard to exercise through a single component. For these cases, RTL exports renderHook:

JSX
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'

test('increments count', () => {
  const { result } = renderHook(() => useCounter(0))

  act(() => result.current.increment())

  expect(result.current.count).toBe(1)
})

act ensures all state updates and effects have flushed before you assert. Always wrap imperative calls in act.

Testing useEffect and async hooks

For hooks that fetch data, mock the dependency and assert on the rendered output:

JSX
// useFetch.test.jsx
import { render, screen, waitFor } from '@testing-library/react'
import { useFetch } from './useFetch'

// Mock global fetch
global.fetch = jest.fn()

function UserName({ url }) {
  const { data, loading } = useFetch(url)
  if (loading) return <p>Loading…</p>
  return <p>{data?.name}</p>
}

test('displays user name after fetch', async () => {
  global.fetch.mockResolvedValueOnce({
    json: async () => ({ name: 'Alice' }),
  })

  render(<UserName url="/api/user/1" />)

  expect(screen.getByText('Loading…')).toBeInTheDocument()

  await waitFor(() => {
    expect(screen.getByText('Alice')).toBeInTheDocument()
  })
})

waitFor polls until the assertion passes or times out — essential for async state updates.

Testing context hooks

Wrap the component under test in the Provider:

JSX
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ThemeProvider } from './ThemeContext'
import { ThemeToggle } from './ThemeToggle'

function renderWithTheme(ui) {
  return render(<ThemeProvider>{ui}</ThemeProvider>)
}

test('toggles theme on click', async () => {
  renderWithTheme(<ThemeToggle />)

  expect(screen.getByRole('button')).toHaveTextContent('Switch to Dark')
  await userEvent.click(screen.getByRole('button'))
  expect(screen.getByRole('button')).toHaveTextContent('Switch to Light')
})

Dos and don'ts

Do Don't
Test via components that mirror real usage Access result.current internals when you can render
Wrap state updates in act Forget act then wonder why assertions fail
Use waitFor for async updates Use setTimeout or arbitrary delays
Mock at the network boundary (fetch, axios) Mock React internals (useState, useEffect)
Assert on visible output Assert on internal hook state variables

Key takeaways

  • Prefer testing hooks through the component that uses them.
  • Use renderHook only for complex custom hook logic that's hard to exercise via UI.
  • Always wrap imperative calls in act.
  • Use waitFor for anything async — never arbitrary sleeps.

That wraps up the React Hooks series. You now have a full picture from state management all the way to testing.


Was this article helpful?

w

webencher Editorial

Software engineers and technical writers with 10+ years of combined experience in algorithms, systems design, and web development. Every article is reviewed for accuracy, depth, and practical applicability.

More by this author →