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-dom

Testing 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