Testing Complex Custom Hooks: Fear Not, `react-hooks-testing-library` is Here!

Testing Complex Custom Hooks: Fear Not, `react-hooks-testing-library` is Here!

Why Are Complex Custom Hooks Hard to Test?

Hey folks, it's your friendly neighborhood tech blogger here! If you're 'wrestling' with React, you're probably familiar with Custom Hooks – fantastic tools for reusing stateful logic. But when the logic within a hook gets complex, testing them can turn into a nightmare. How do you ensure your hook works as expected, especially with asynchronous operations, intricate state management, or interactions with the Context API? Don't worry, today we're going to 'decode' this mystery with an incredibly powerful tool: react-hooks-testing-library.

You might think: 'Just render a component that uses the hook and test it normally, right?'. Partially true, but this approach often forces you to render an entire UI, making tests slow, cumbersome, and hard to focus on the hook's logic. Common challenges include:

  • Internal State Management: How to test state changes across multiple renders?
  • Asynchronous Operations: API calls, setTimeout, setInterval... how to wait for results or mock them?
  • Context Usage: Your hook depends on Context? How do you provide a mock Context for testing?
  • Logic Isolation: The goal of the test is to test the hook, not the entire component.

react-hooks-testing-library: Your Savior

This is the library specifically designed to solve the problems above. It allows you to 'render' a hook within a virtual component environment, helping you access the hook's return value, call callbacks, and test its behavior independently.

Installation and Basic Usage

First, let's install it:

npm install --save-dev @testing-library/react-hooks react-test-renderer

Or with Yarn:

yarn add --dev @testing-library/react-hooks react-test-renderer

The basic syntax is straightforward:

import { renderHook, act } from '@testing-library/react-hooks';

// Assuming you have this hook
function useCounter(initialValue = 0) {
  const [count, setCount] = React.useState(initialValue);
  const increment = () => setCount(prev => prev + 1);
  const decrement = () => setCount(prev => prev - 1);
  return { count, increment, decrement };
}

describe('useCounter', () => {
  it('should increment the count', () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
    });

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

  it('should decrement the count', () => {
    const { result } = renderHook(() => useCounter(5));

    act(() => {
      result.current.decrement();
    });

    expect(result.current.count).toBe(4);
  });
});

In the example above:

  • renderHook: Creates a virtual component to 'run' your hook.
  • result.current: Contains the latest return value of the hook.
  • act: Ensures all state updates within the hook are processed before assertions, similar to testing React components.

Handling Asynchronous Logic

This is where react-hooks-testing-library truly shines. Suppose you have a hook that makes an API call:

function useFetch(url) {
  const [data, setData] = React.useState(null);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState(null);

  React.useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(url);
        const json = await response.json();
        setData(json);
      } catch (err) {
        setError(err);
      } finally {
        setLoading(false);
      }
    };
    fetchData();
  }, [url]);

  return { data, loading, error };
}

// How to test:
import { renderHook, act, waitFor } from '@testing-library/react-hooks';

// Mock global fetch
global.fetch = jest.fn(() =>
  Promise.resolve({
    json: () => Promise.resolve({ message: 'Hello World' }),
  })
);

describe('useFetch', () => {
  it('should fetch data successfully', async () => {
    const { result, waitForNextUpdate } = renderHook(() => useFetch('/api/data'));

    expect(result.current.loading).toBe(true);
    expect(result.current.data).toBe(null);
    expect(result.current.error).toBe(null);

    await waitForNextUpdate(); // Wait for the effect to complete and state to update

    expect(result.current.loading).toBe(false);
    expect(result.current.data).toEqual({ message: 'Hello World' });
    expect(result.current.error).toBe(null);
  });

  it('should handle fetch error', async () => {
    global.fetch.mockImplementationOnce(() =>
      Promise.reject(new Error('Network error'))
    );

    const { result, waitForNextUpdate } = renderHook(() => useFetch('/api/error'));

    await waitForNextUpdate();

    expect(result.current.loading).toBe(false);
    expect(result.current.data).toBe(null);
    expect(result.current.error).toEqual(new Error('Network error'));
  });
});

Important notes:

  • waitForNextUpdate: This function waits until the hook completes a state update (typically after an effect has run). Very useful for asynchronous tasks.
  • waitFor: Waits for a specific condition to become true. For example: await waitFor(() => expect(result.current.data).not.toBeNull());
  • Mocking: You will need to mock external dependencies like fetch, localStorage, or other async functions using Jest.

Testing Hooks That Use Context

If your hook requires Context, you can pass it via the wrapper option:

import { renderHook } from '@testing-library/react-hooks';
import MyContext from './MyContext'; // Assuming you have this Context

// Hook using context
function useMyContextValue() {
  return React.useContext(MyContext);
}

// Test case
describe('useMyContextValue', () => {
  it('should return context value', () => {
    const wrapper = ({ children }) => (
      <MyContext.Provider value="test-value">
        {children}
      </MyContext.Provider>
    );

    const { result } = renderHook(() => useMyContextValue(), { wrapper });

    expect(result.current).toBe('test-value');
  });
});

Here, wrapper is a simple React component that wraps your hook, allowing you to provide Context or any other Provider that the hook needs.

Working With Timers (setTimeout, setInterval)

When your hook uses setTimeout or setInterval, you can control them using Jest's fake timers:

import { renderHook, act } from '@testing-library/react-hooks';

function useTimer(delay = 1000) {
  const [count, setCount] = React.useState(0);

  React.useEffect(() => {
    const id = setInterval(() => {
      setCount(prev => prev + 1);
    }, delay);
    return () => clearInterval(id);
  }, [delay]);

  return count;
}

describe('useTimer', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.runOnlyPendingTimers();
    jest.useRealTimers();
  });

  it('should increment count after delay', () => {
    const { result } = renderHook(() => useTimer(100));

    expect(result.current).toBe(0);

    act(() => {
      jest.advanceTimersByTime(100);
    });
    expect(result.current).toBe(1);

    act(() => {
      jest.advanceTimersByTime(100);
    });
    expect(result.current).toBe(2);
  });
});

With jest.useFakeTimers() and jest.advanceTimersByTime(), you can control time in your tests without actually waiting.

Testing Custom Hooks with complex logic is no longer an impossible task. With react-hooks-testing-library, you have a powerful tool to isolate and effectively test the behavior of your hooks, from simple state to complex asynchronous tasks and Context interactions. Remember, writing good tests not only helps you catch bugs early but also serves as living documentation for how your hooks work. Happy coding and solid testing!