We use cookies to enhance your experience on the site
CodeWorlds

Testing Hooks -- Internal Systems Diagnostics

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 -- Running a Hook in Isolation

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});

Testing State Changes

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});

Testing Hooks with Effects

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});

Testing Hooks with Changing Props

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});

Testing Hooks with Context

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});

Testing a useReducer Hook

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});
Go to CodeWorlds