Introduction to Testing Hooks and Context
Testing React Hooks and Context requires special techniques because they can't be called outside of React components. React Testing Library provides utilities like renderHook for testing custom hooks in isolation and strategies for testing Context providers effectively.
Why Test Hooks Separately?
Custom hooks often contain reusable business logic that should be tested independently:
- Isolation: Test hook logic without component rendering overhead
- Reusability: Ensure hooks work correctly in different contexts
- Debugging: Pinpoint issues in hook logic vs component rendering
- Documentation: Tests serve as usage examples for your hooks
Setting Up Hook Testing
Install the necessary package:
npm install --save-dev @testing-library/react-hooks
# For React 18+ (renderHook is built into @testing-library/react)
# No additional package needed
Note: As of React 18, renderHook is included in @testing-library/react. For React 16-17, use @testing-library/react-hooks.
Testing Simple Custom Hooks
Let's test 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);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
Test the hook:
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(10);
});
Tip: Always wrap state updates in act(). This ensures React processes updates before assertions, matching real component behavior.
Understanding renderHook
The renderHook function returns an object with useful properties:
const { result, rerender, unmount } = renderHook(() => useMyHook());
// result.current - The current return value of the hook
console.log(result.current);
// rerender() - Re-run the hook with new props
rerender();
// unmount() - Unmount the hook (cleanup)
unmount();
Testing Hooks with Props
Test hooks that accept parameters by passing them through renderHook:
// useFetch.js
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
fetch(url)
.then(res => res.json())
.then(data => {
if (!cancelled) {
setData(data);
setLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError(err);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
Test with different URLs:
// useFetch.test.js
import { renderHook, waitFor } from '@testing-library/react';
import { useFetch } from './useFetch';
beforeEach(() => {
global.fetch = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
});
test('fetches data successfully', async () => {
const mockData = { id: 1, name: 'Test' };
global.fetch.mockResolvedValueOnce({
json: async () => mockData
});
const { result } = renderHook(() => useFetch('/api/users/1'));
// Initially loading
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
// Wait for fetch to complete
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
});
test('handles fetch errors', async () => {
const mockError = new Error('Network error');
global.fetch.mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useFetch('/api/users/1'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBe(mockError);
expect(result.current.data).toBe(null);
});
test('refetches when URL changes', async () => {
const mockData1 = { id: 1, name: 'User 1' };
const mockData2 = { id: 2, name: 'User 2' };
global.fetch
.mockResolvedValueOnce({ json: async () => mockData1 })
.mockResolvedValueOnce({ json: async () => mockData2 });
const { result, rerender } = renderHook(
({ url }) => useFetch(url),
{ initialProps: { url: '/api/users/1' } }
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toEqual(mockData1);
// Change URL
rerender({ url: '/api/users/2' });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toEqual(mockData2);
});
Testing Hooks with Dependencies
Test hooks that depend on other hooks or external libraries:
// useLocalStorage.js
import { useState, useEffect } from 'react';
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
Mock localStorage for testing:
// useLocalStorage.test.js
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from './useLocalStorage';
let localStorageMock;
beforeEach(() => {
localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
clear: jest.fn()
};
global.localStorage = localStorageMock;
});
test('reads initial value from localStorage', () => {
localStorageMock.getItem.mockReturnValue(JSON.stringify('stored value'));
const { result } = renderHook(() => useLocalStorage('key', 'default'));
expect(result.current[0]).toBe('stored value');
expect(localStorageMock.getItem).toHaveBeenCalledWith('key');
});
test('uses default value when localStorage is empty', () => {
localStorageMock.getItem.mockReturnValue(null);
const { result } = renderHook(() => useLocalStorage('key', 'default'));
expect(result.current[0]).toBe('default');
});
test('updates localStorage when value changes', () => {
localStorageMock.getItem.mockReturnValue(null);
const { result } = renderHook(() => useLocalStorage('key', 'default'));
act(() => {
result.current[1]('new value');
});
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'key',
JSON.stringify('new value')
);
});
Testing Context Providers
Context requires wrapping components/hooks with providers. Create a custom wrapper:
// AuthContext.js
import { createContext, useContext, useState } from 'react';
const AuthContext = createContext();
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const login = (userData) => setUser(userData);
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
Test the context and hook:
// AuthContext.test.js
import { renderHook, act } from '@testing-library/react';
import { AuthProvider, useAuth } from './AuthContext';
test('provides authentication context', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider
});
expect(result.current.user).toBe(null);
});
test('logs in user', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider
});
const userData = { id: 1, name: 'John Doe' };
act(() => {
result.current.login(userData);
});
expect(result.current.user).toEqual(userData);
});
test('logs out user', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider
});
act(() => {
result.current.login({ id: 1, name: 'John' });
result.current.logout();
});
expect(result.current.user).toBe(null);
});
test('throws error when used outside provider', () => {
// Suppress console.error for this test
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
renderHook(() => useAuth());
}).toThrow('useAuth must be used within AuthProvider');
spy.mockRestore();
});
Testing Components with Context
Test components that consume context by wrapping them with providers:
// UserProfile.test.js
import { render, screen } from '@testing-library/react';
import { AuthProvider } from './AuthContext';
import UserProfile from './UserProfile';
function renderWithAuth(ui, { user = null, ...options } = {}) {
function Wrapper({ children }) {
return (
<AuthProvider initialUser={user}>
{children}
</AuthProvider>
);
}
return render(ui, { wrapper: Wrapper, ...options });
}
test('shows login prompt when not authenticated', () => {
renderWithAuth(<UserProfile />);
expect(screen.getByText(/please log in/i)).toBeInTheDocument();
});
test('shows user info when authenticated', () => {
const user = { id: 1, name: 'John Doe', email: 'john@example.com' };
renderWithAuth(<UserProfile />, { user });
expect(screen.getByText(/john doe/i)).toBeInTheDocument();
expect(screen.getByText(/john@example.com/i)).toBeInTheDocument();
});
Tip: Create reusable render functions with common providers. This reduces boilerplate and ensures consistent test setup.
Testing Context with Multiple Providers
Nest multiple providers for complex scenarios:
function AllProviders({ children }) {
return (
<AuthProvider>
<ThemeProvider>
<I18nProvider>
{children}
</I18nProvider>
</ThemeProvider>
</AuthProvider>
);
}
test('component with multiple contexts', () => {
const { result } = renderHook(
() => ({
auth: useAuth(),
theme: useTheme(),
i18n: useI18n()
}),
{ wrapper: AllProviders }
);
expect(result.current.auth.user).toBe(null);
expect(result.current.theme.mode).toBe('light');
expect(result.current.i18n.locale).toBe('en');
});
Testing Async Hooks
Test hooks with asynchronous operations:
// useDebounce.js
import { useState, useEffect } from 'react';
export function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
Test with fake timers:
// useDebounce.test.js
import { renderHook } from '@testing-library/react';
import { useDebounce } from './useDebounce';
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
test('returns initial value immediately', () => {
const { result } = renderHook(() => useDebounce('initial', 500));
expect(result.current).toBe('initial');
});
test('debounces value changes', () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'initial', delay: 500 } }
);
expect(result.current).toBe('initial');
// Change value
rerender({ value: 'changed', delay: 500 });
// Value shouldn't change immediately
expect(result.current).toBe('initial');
// Fast-forward time
jest.advanceTimersByTime(500);
// Now value should update
expect(result.current).toBe('changed');
});
test('cancels previous timeout on rapid changes', () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'first', delay: 500 } }
);
rerender({ value: 'second', delay: 500 });
jest.advanceTimersByTime(250);
rerender({ value: 'third', delay: 500 });
jest.advanceTimersByTime(500);
// Should skip 'second' and go straight to 'third'
expect(result.current).toBe('third');
});
Note: Use jest.useFakeTimers() to control time in tests. Remember to restore real timers after each test.
Testing Hook Cleanup
Verify that hooks properly clean up side effects:
// useEventListener.js
import { useEffect } from 'react';
export function useEventListener(event, handler, element = window) {
useEffect(() => {
element.addEventListener(event, handler);
return () => {
element.removeEventListener(event, handler);
};
}, [event, handler, element]);
}
Test cleanup:
// useEventListener.test.js
import { renderHook } from '@testing-library/react';
import { useEventListener } from './useEventListener';
test('adds event listener', () => {
const handler = jest.fn();
const addSpy = jest.spyOn(window, 'addEventListener');
renderHook(() => useEventListener('resize', handler));
expect(addSpy).toHaveBeenCalledWith('resize', handler);
addSpy.mockRestore();
});
test('removes event listener on unmount', () => {
const handler = jest.fn();
const removeSpy = jest.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useEventListener('resize', handler));
unmount();
expect(removeSpy).toHaveBeenCalledWith('resize', handler);
removeSpy.mockRestore();
});
test('updates listener when handler changes', () => {
const handler1 = jest.fn();
const handler2 = jest.fn();
const addSpy = jest.spyOn(window, 'addEventListener');
const removeSpy = jest.spyOn(window, 'removeEventListener');
const { rerender } = renderHook(
({ handler }) => useEventListener('click', handler),
{ initialProps: { handler: handler1 } }
);
rerender({ handler: handler2 });
expect(removeSpy).toHaveBeenCalledWith('click', handler1);
expect(addSpy).toHaveBeenCalledWith('click', handler2);
addSpy.mockRestore();
removeSpy.mockRestore();
});
Exercise:
Create and test a useForm custom hook with the following features:
- Manages form state (values, errors, touched fields)
- Provides
handleChange, handleBlur, and handleSubmit functions
- Validates fields based on a validation schema
- Prevents submission when form has errors
- Resets form after successful submission
Write comprehensive tests covering:
- Initial state
- Field updates
- Validation on blur
- Form submission (success and error cases)
- Form reset
Best Practices for Hook Testing
- Use renderHook for isolation: Test hooks independently when possible
- Wrap state updates in act(): Ensures React processes updates correctly
- Test with real providers: Use actual Context providers, not mocks
- Mock external dependencies: Mock APIs, localStorage, timers, etc.
- Test cleanup: Verify useEffect cleanup functions work correctly
- Test edge cases: Test with null, undefined, empty values
- Use fake timers judiciously: Only when testing time-dependent logic
Common Mistakes:
- Forgetting to wrap state updates in
act()
- Not cleaning up mocks and spies
- Testing hooks without providers when they require context
- Not restoring real timers after using fake timers
- Testing implementation details instead of behavior
Summary
Testing hooks and context is essential for maintaining robust React applications. Use renderHook for isolated hook testing, create custom wrappers for context testing, and always verify cleanup behavior. In the next lesson, we'll explore end-to-end testing with Cypress.