How to Test React Hooks in React
Testing React Hooks is essential for ensuring that your application’s
stateful logic and side effects behave predictably. This guide provides
a direct, step-by-step approach to testing custom React Hooks using
React Testing Library. You will learn how to set up tests, render hooks
in isolation, trigger state changes using the act function,
and handle asynchronous operations.
The Standard Tool: React Testing Library
To test hooks in isolation without wrapping them in a dummy
component, use the renderHook utility. In modern React
development, this utility is imported directly from
@testing-library/react.
Install the required dependencies if you haven’t already:
npm install --save-dev @testing-library/react @testing-library/jest-domTesting a Synchronous Hook
To test a custom hook, you need to render it using
renderHook, access its current values via
result.current, and wrap any state-changing actions inside
the act function.
Here is a simple counter hook (useCounter.js):
import { useState } from 'react';
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount((c) => c + 1);
const decrement = () => setCount((c) => c - 1);
return { count, increment, decrement };
}Here is how you test this hook:
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
test('should initialize with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('should initialize with a custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('should increment the counter', () => {
const { result } = renderHook(() => useCounter(0));
// State updates must be wrapped in act()
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
});Testing an Asynchronous Hook
Hooks that fetch data or use timers require asynchronous testing
utilities. You can use waitFor to pause the test assertion
until the asynchronous state update completes.
Here is an asynchronous data-fetching hook
(useFetch.js):
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let isMounted = true;
fetch(url)
.then((res) => res.json())
.then((data) => {
if (isMounted) {
setData(data);
setLoading(false);
}
});
return () => { isMounted = false; };
}, [url]);
return { data, loading };
}Here is how you test the asynchronous hook:
import { renderHook, waitFor } from '@testing-library/react';
import { useFetch } from './useFetch';
// Mock the global fetch API
beforeEach(() => {
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ message: 'success' }),
})
);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('useFetch', () => {
test('should fetch data and set loading to false', async () => {
const { result } = renderHook(() => useFetch('https://api.example.com/data'));
// Initially, the hook should be loading
expect(result.current.loading).toBe(true);
expect(result.current.data).toBeNull();
// Wait for the async state updates to resolve
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({ message: 'success' });
});
});Best Practices for Hook Testing
- Always use
actfor state updates: Any action that causes a state transition (like calling a function returned by the hook) must be wrapped insideact(() => { ... }). - Do not destructure
result.current: Always access properties directly fromresult.current(e.g.,result.current.value). Destructuring the values at the top of the test will prevent you from receiving the updated state values during assertions. - Test behavior, not implementation: Focus on what the hook outputs and how it reacts to inputs, rather than testing the internal variables or how the state is organized internally.