Custom hooks are like internal spaceship systems -- gravitational engine, life support system, navigation. You don't see them directly, but you need to be sure they work correctly. To test hooks, we use
renderHook from React Testing Library.renderHook lets you test a hook without creating a component:1import { renderHook, act } from '@testing-library/react';
2import { useCounter } from './useCounter';
3
4test('initializes counter with default value', () => {
5 const { result } = renderHook(() => useCounter());
6
7 expect(result.current.count).toBe(0);
8});
9
10test('initializes counter with custom value', () => {
11 const { result } = renderHook(() => useCounter(10));
12
13 expect(result.current.count).toBe(10);
14});Hook state changes must be wrapped in
act():1// useCounter.js
2function useCounter(initialValue = 0) {
3 const [count, setCount] = useState(initialValue);
4
5 const increment = () => setCount(c => c + 1);
6 const decrement = () => setCount(c => c - 1);
7 const reset = () => setCount(initialValue);
8
9 return { count, increment, decrement, reset };
10}1test('increments and decrements counter', () => {
2 const { result } = renderHook(() => useCounter(5));
3
4 expect(result.current.count).toBe(5);
5
6 act(() => {
7 result.current.increment();
8 });
9 expect(result.current.count).toBe(6);
10
11 act(() => {
12 result.current.decrement();
13 });
14 expect(result.current.count).toBe(5);
15
16 act(() => {
17 result.current.reset();
18 });
19 expect(result.current.count).toBe(5);
20});1// useFuelGauge.js
2function useFuelGauge(shipId) {
3 const [fuel, setFuel] = useState(null);
4 const [loading, setLoading] = useState(true);
5
6 useEffect(() => {
7 fetch(`/api/ships/${shipId}/fuel`)
8 .then(res => res.json())
9 .then(data => {
10 setFuel(data.level);
11 setLoading(false);
12 });
13 }, [shipId]);
14
15 return { fuel, loading };
16}1test('fetches fuel level for ship', async () => {
2 global.fetch = jest.fn().mockResolvedValue({
3 json: () => Promise.resolve({ level: 85 })
4 });
5
6 const { result } = renderHook(() => useFuelGauge('enterprise'));
7
8 // Initially loading
9 expect(result.current.loading).toBe(true);
10 expect(result.current.fuel).toBeNull();
11
12 // After loading
13 await waitFor(() => {
14 expect(result.current.loading).toBe(false);
15 });
16 expect(result.current.fuel).toBe(85);
17});When a hook depends on props, we use
rerender:1test('refetches fuel when shipId changes', async () => {
2 global.fetch = jest.fn()
3 .mockResolvedValueOnce({
4 json: () => Promise.resolve({ level: 85 })
5 })
6 .mockResolvedValueOnce({
7 json: () => Promise.resolve({ level: 42 })
8 });
9
10 const { result, rerender } = renderHook(
11 ({ shipId }) => useFuelGauge(shipId),
12 { initialProps: { shipId: 'enterprise' } }
13 );
14
15 await waitFor(() => {
16 expect(result.current.fuel).toBe(85);
17 });
18
19 // Change shipId -- rerender with new props
20 rerender({ shipId: 'discovery' });
21
22 await waitFor(() => {
23 expect(result.current.fuel).toBe(42);
24 });
25});Hooks using context require a wrapper:
1// useTheme.js
2const ThemeContext = createContext();
3
4function useTheme() {
5 const context = useContext(ThemeContext);
6 if (!context) throw new Error('useTheme must be used within ThemeProvider');
7 return context;
8}1test('returns theme from context', () => {
2 const wrapper = ({ children }) => (
3 <ThemeContext.Provider value={{ mode: 'dark', color: 'blue' }}>
4 {children}
5 </ThemeContext.Provider>
6 );
7
8 const { result } = renderHook(() => useTheme(), { wrapper });
9
10 expect(result.current.mode).toBe('dark');
11 expect(result.current.color).toBe('blue');
12});
13
14test('throws error without provider', () => {
15 expect(() => {
16 renderHook(() => useTheme());
17 }).toThrow('useTheme must be used within ThemeProvider');
18});1// useMissionControl.js
2function missionReducer(state, action) {
3 switch (action.type) {
4 case 'START_MISSION':
5 return { ...state, status: 'active', mission: action.payload };
6 case 'ABORT_MISSION':
7 return { ...state, status: 'aborted', mission: null };
8 case 'COMPLETE_MISSION':
9 return { ...state, status: 'completed' };
10 default:
11 return state;
12 }
13}
14
15function useMissionControl() {
16 const [state, dispatch] = useReducer(missionReducer, {
17 status: 'idle',
18 mission: null
19 });
20
21 const startMission = (name) => dispatch({ type: 'START_MISSION', payload: name });
22 const abortMission = () => dispatch({ type: 'ABORT_MISSION' });
23 const completeMission = () => dispatch({ type: 'COMPLETE_MISSION' });
24
25 return { ...state, startMission, abortMission, completeMission };
26}1test('manages mission lifecycle', () => {
2 const { result } = renderHook(() => useMissionControl());
3
4 // Initial state
5 expect(result.current.status).toBe('idle');
6 expect(result.current.mission).toBeNull();
7
8 // Start mission
9 act(() => result.current.startMission('Apollo'));
10 expect(result.current.status).toBe('active');
11 expect(result.current.mission).toBe('Apollo');
12
13 // Complete mission
14 act(() => result.current.completeMission());
15 expect(result.current.status).toBe('completed');
16});