React Hook Testing
Introduction
React Hooks have revolutionized how we manage state and side effects in functional components. However, testing hooks presents unique challenges since hooks can only be called inside React function components. In this guide, you'll learn how to properly test custom React hooks to ensure they work as expected.
React Hook testing involves verifying that:
- Hooks update state correctly
- Side effects execute as expected
- Custom logic within hooks functions properly
Testing Hooks with React Testing Library
React Testing Library provides a special utility called renderHook
that allows us to test hooks outside of components. Let's explore how to use it.
Setting Up Your Testing Environment
First, you'll need to install the necessary packages:
npm install --save-dev @testing-library/react @testing-library/react-hooks jest
For newer versions of React Testing Library (version 13+), the renderHook
function is included in the main package:
import { renderHook } from '@testing-library/react';
For older versions, you'll need to import from @testing-library/react-hooks
:
import { renderHook } from '@testing-library/react-hooks';
Testing a Basic Counter Hook
Let's start with a simple custom hook that implements a counter:
// useCounter.js
import { useState } from 'react';
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
export default useCounter;
Now, let's write tests for this hook:
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
describe('useCounter', () => {
test('should initialize count with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('should initialize count with provided value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('should increment counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('should decrement counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(-1);
});
test('should reset counter', () => {
const { result } = renderHook(() => useCounter(100));
act(() => {
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(100);
});
});
Key Concepts in Hook Testing
- renderHook: This function allows us to invoke a hook within a test environment.
- result.current: Gives us access to the current values returned by the hook.
- act: Wraps any function that causes state updates. This ensures that all updates are processed and applied before making assertions.
Testing Hooks with Dependencies
Often, hooks depend on other hooks or external services. Let's create a more complex example of a hook that fetches data:
// useFetchData.js
import { useState, useEffect } from 'react';
function useFetchData(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(url);
if (!response.ok) throw new Error('Network response was not ok');
const result = await response.json();
setData(result);
setError(null);
} catch (err) {
setError(err.message);
setData(null);
} finally {
setLoading(false);
}
};
fetchData();
}, [url]);
return { data, loading, error };
}
export default useFetchData;
Testing this hook requires mocking the fetch API:
// useFetchData.test.js
import { renderHook, waitFor } from '@testing-library/react';
import useFetchData from './useFetchData';
// Mock the global fetch function
global.fetch = jest.fn();
describe('useFetchData', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('should fetch data successfully', async () => {
// Mock successful response
const mockData = { id: 1, name: 'Test' };
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockData,
});
const { result } = renderHook(() => useFetchData('https://api.example.com/data'));
// Initially, it should be loading
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
expect(result.current.error).toBe(null);
// Wait for the hook to finish processing
await waitFor(() => expect(result.current.loading).toBe(false));
// After loading, we should have data
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/data');
});
test('should handle fetch errors', async () => {
// Mock error response
global.fetch.mockResolvedValueOnce({
ok: false,
});
const { result } = renderHook(() => useFetchData('https://api.example.com/data'));
// Wait for the hook to finish processing
await waitFor(() => expect(result.current.loading).toBe(false));
// After loading, we should have an error
expect(result.current.data).toBe(null);
expect(result.current.error).toBe('Network response was not ok');
});
});
Testing Hooks with Context
Sometimes hooks need to interact with React Context. Let's see how to test such scenarios:
// ThemeContext.js
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// useTheme.js
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
Testing this hook requires wrapping the renderHook in a Provider:
// useTheme.test.js
import { renderHook, act } from '@testing-library/react';
import { ThemeProvider, useTheme } from './ThemeContext';
describe('useTheme', () => {
test('should use theme context', () => {
const wrapper = ({ children }) => <ThemeProvider>{children}</ThemeProvider>;
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current.theme).toBe('light');
act(() => {
result.current.toggleTheme();
});
expect(result.current.theme).toBe('dark');
});
test('should throw error if used outside provider', () => {
// Suppress console errors for this test
const originalError = console.error;
console.error = jest.fn();
expect(() => {
renderHook(() => useTheme());
}).toThrow('useTheme must be used within a ThemeProvider');
console.error = originalError;
});
});
Key Concept: Wrapper
The wrapper
option in renderHook
allows you to provide context providers that your hook depends on. This is crucial for testing hooks that use context.
Testing Hook Re-renders
When props change, hooks should update accordingly. Let's test this behavior:
// useUpdateEffect.test.js
import { renderHook } from '@testing-library/react';
import { useState } from 'react';
function useUpdateEffect(value) {
const [prevValue, setPrevValue] = useState(value);
const [isUpdated, setIsUpdated] = useState(false);
if (value !== prevValue) {
setPrevValue(value);
setIsUpdated(true);
}
return isUpdated;
}
describe('useUpdateEffect', () => {
test('should detect when value updates', async () => {
let value = 'initial';
const { result, rerender } = renderHook(() => useUpdateEffect(value));
// Initial render should not report updates
expect(result.current).toBe(false);
// Change the value and rerender
value = 'updated';
rerender();
expect(result.current).toBe(true);
});
});
Key Concept: Rerender
The rerender
function from renderHook allows you to simulate props or state changes that would cause the hook to be called again with new values.
Best Practices for Testing Hooks
- Test each functionality separately: Write isolated tests for each feature of your hook.
- Mock external dependencies: Use Jest's mocking capabilities for APIs, timers, and other external dependencies.
- Use act() for state updates: Wrap any code that triggers state updates in
act()
to ensure React processes all updates before assertions. - Test edge cases: Consider initialization, error states, and boundary conditions.
- Verify cleanup functions: For hooks that set up subscriptions or timers, test that they're cleaned up properly.
Testing useEffect Cleanup
Hooks that use useEffect
often include cleanup functions. Let's test this:
// useInterval.js
import { useEffect, useRef } from 'react';
function useInterval(callback, delay) {
const savedCallback = useRef();
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
const id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}
// useInterval.test.js
import { renderHook } from '@testing-library/react';
import useInterval from './useInterval';
describe('useInterval', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('should call callback after delay', () => {
const callback = jest.fn();
renderHook(() => useInterval(callback, 1000));
// Callback should not be called immediately
expect(callback).not.toBeCalled();
// Fast-forward time
jest.advanceTimersByTime(1000);
expect(callback).toBeCalled();
expect(callback).toHaveBeenCalledTimes(1);
// Fast-forward time again
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(2);
});
test('should clean up interval on unmount', () => {
const callback = jest.fn();
const { unmount } = renderHook(() => useInterval(callback, 1000));
// Clear the interval
unmount();
// Fast-forward time
jest.advanceTimersByTime(1000);
// Callback should not be called after unmount
expect(callback).not.toBeCalled();
});
});
Key Concept: Unmount
The unmount
function lets you test if your hook's cleanup functions work correctly by simulating a component unmounting.
Testing Custom Hook Integration
Finally, let's test how a custom hook integrates with a component:
// Component.js
import React from 'react';
import useCounter from './useCounter';
function CounterComponent() {
const { count, increment, decrement } = useCounter(0);
return (
<div>
<span data-testid="count">{count}</span>
<button onClick={increment} data-testid="increment">Increment</button>
<button onClick={decrement} data-testid="decrement">Decrement</button>
</div>
);
}
// Component.test.js
import { render, screen, fireEvent } from '@testing-library/react';
import CounterComponent from './Component';
describe('CounterComponent', () => {
test('renders and increments counter', () => {
render(<CounterComponent />);
const countElement = screen.getByTestId('count');
const incrementButton = screen.getByTestId('increment');
expect(countElement).toHaveTextContent('0');
fireEvent.click(incrementButton);
expect(countElement).toHaveTextContent('1');
});
test('decrements counter', () => {
render(<CounterComponent />);
const countElement = screen.getByTestId('count');
const decrementButton = screen.getByTestId('decrement');
expect(countElement).toHaveTextContent('0');
fireEvent.click(decrementButton);
expect(countElement).toHaveTextContent('-1');
});
});
This approach combines hook testing with component testing, allowing you to verify that the hook works correctly in a real component context.
Summary
Testing React hooks requires specialized techniques due to their nature. By using renderHook
, act
, and other utilities provided by React Testing Library, you can thoroughly test custom hooks:
- Use
renderHook
to call hooks outside of components - Wrap state updates in
act()
- Use
wrapper
to provide context for hooks - Test cleanup functions with
unmount
- Verify rerender behavior with
rerender
- Mock external dependencies like fetch or timers
- Test hook integration within components
By following these patterns, you can ensure your custom hooks work correctly in all scenarios, making your React applications more reliable.
Additional Resources and Exercises
Resources
Exercises
-
Basic Hook Testing: Create a
useLocalStorage
hook that stores and retrieves values from localStorage, then write tests for it. -
Advanced Hook Testing: Implement a
useFetch
hook with retry functionality and test both successful requests and retries. -
Context Hook: Create a hook that manages a shopping cart using Context API, and test adding/removing items and calculating totals.
-
Async Hook: Build a
useDebounce
hook that delays function execution, and write tests to verify the timing behavior. -
Integration Exercise: Create a form component that uses multiple custom hooks for validation and submission, then test the entire form workflow.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)