![]()
Testing hooks requires different approaches than component testing since hooks can’t be called outside of React components. Here’s a comprehensive guide to effectively testing both built-in and custom hooks.
Testing Built-in React Hooks
1. Testing useState Hooks
test('should update state with useState', () => {
const TestComponent = () => {
const [count, setCount] = React.useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
};
render(<TestComponent />);
const button = screen.getByRole('button');
expect(button).toHaveTextContent('Count: 0');
fireEvent.click(button);
expect(button).toHaveTextContent('Count: 1');
});
2. Testing useEffect Hooks
test('should fetch data on mount with useEffect', async () => {
const mockFetch = jest.fn(() => Promise.resolve('data'));
const TestComponent = () => {
const [data, setData] = React.useState(null);
React.useEffect(() => {
mockFetch().then(setData);
}, []);
return <div>{data}</div>;
};
render(<TestComponent />);
expect(mockFetch).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(screen.getByText('data')).toBeInTheDocument();
});
});
Testing Custom Hooks
Using @testing-library/react-hooks
npm install @testing-library/react-hooks
Basic Custom Hook Test
import { renderHook } from '@testing-library/react-hooks';
import { useCounter } from './useCounter';
test('should increment counter', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
Testing Hooks with Context
test('should consume context value', () => {
const wrapper = ({ children }) => (
<ThemeContext.Provider value="dark">
{children}
</ThemeContext.Provider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current).toBe('dark');
});
Advanced Hook Testing Patterns
1. Testing Asynchronous Hooks
test('should handle async operations', async () => {
const { result, waitForNextUpdate } = renderHook(() => useFetchData());
expect(result.current.isLoading).toBe(true);
await waitForNextUpdate();
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toEqual({ id: 1 });
});
2. Testing Hook Dependencies
test('should update when dependencies change', () => {
const { result, rerender } = renderHook(
({ userId }) => useUserProfile(userId),
{ initialProps: { userId: 1 } }
);
expect(result.current.userId).toBe(1);
rerender({ userId: 2 });
expect(result.current.userId).toBe(2);
});
3. Testing Complex Hook Logic
describe('useFormValidation hook', () => {
test('should validate email format', () => {
const { result } = renderHook(() => useFormValidation());
act(() => result.current.handleChange({
target: { name: 'email', value: 'invalid' }
}));
expect(result.current.errors.email).toBe('Invalid email');
});
});
Mocking in Hook Tests
1. Mocking External Dependencies
jest.mock('../api', () => ({
fetchUser: jest.fn(() => Promise.resolve({ name: 'John' }))
}));
test('should fetch user data', async () => {
const { result, waitForNextUpdate } = renderHook(() => useUser());
await waitForNextUpdate();
expect(result.current.user).toEqual({ name: 'John' });
});
2. Mocking Browser APIs
beforeAll(() => {
window.matchMedia = jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
}));
});
test('should detect mobile viewport', () => {
const { result } = renderHook(() => useViewport());
expect(result.current.isMobile).toBe(false);
});
Error Handling in Hook Tests
Testing Error States
test('should handle errors', async () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
const mockFetch = jest.fn(() => Promise.reject('API Error'));
const { result, waitForNextUpdate } = renderHook(() =>
useApi(mockFetch)
);
await waitForNextUpdate();
expect(result.current.error).toBe('API Error');
});
Performance Considerations
1. Avoiding Unnecessary Renders
test('should memoize values properly', () => {
const calculateExpensiveValue = jest.fn(() => 42);
const { result, rerender } = renderHook(
({ deps }) => useMemoizedValue(calculateExpensiveValue, deps),
{ initialProps: { deps: [1] } }
);
expect(calculateExpensiveValue).toHaveBeenCalledTimes(1);
rerender({ deps: [1] }); // Same dependencies
expect(calculateExpensiveValue).toHaveBeenCalledTimes(1);
rerender({ deps: [2] }); // New dependencies
expect(calculateExpensiveValue).toHaveBeenCalledTimes(2);
});
Integration with TypeScript
Typed Hook Tests
interface CounterActions {
increment: () => void;
decrement: () => void;
}
test('should type hook results correctly', () => {
const { result } = renderHook(() => useCounter());
// result.current is properly typed
expectType<number>(result.current.count);
expectType<CounterActions>(result.current.actions);
act(() => result.current.actions.increment());
expect(result.current.count).toBe(1);
});
Best Practices
- Test Hook Contracts: Verify the hook’s API (return values and methods)
- Test Edge Cases: Empty states, error conditions, boundary values
- Keep Tests Focused: Test one behavior per test case
- Use Cleanup: Unsubscribe effects to prevent memory leaks
afterEach(() => {
cleanup();
jest.clearAllMocks();
});
Common Testing Patterns Cheat Sheet
| Hook Type | Testing Approach | Tools Needed |
|---|---|---|
| State Management | Verify state changes after actions | renderHook, act |
| Side Effects | Mock dependencies, test cleanup | jest.mock, waitFor |
| Context Consumers | Wrap with Provider | custom wrapper |
| Async Operations | Wait for updates, test loading states | waitForNextUpdate |
| Callback Handlers | Simulate events, verify calls | fireEvent, jest.fn |
