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.
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.
- 5 React Hooks Part 5: Sharing State with useContext
- 6 React Hooks Part 6: useId, useTransition, and useDeferredValue
- 7 React Hooks Part 7: useImperativeHandle and useLayoutEffect
- 8 React Hooks Part 8: Rules, Anti-Patterns, and the ESLint Plugin
- 9 React Hooks Part 9: Testing Hooks with React Testing Library
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
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:
// 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 }
}
// 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:
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:
// 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:
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
renderHookonly for complex custom hook logic that's hard to exercise via UI. - Always wrap imperative calls in
act. - Use
waitForfor 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.