Skip
Arish's avatar

29. Testing Custom Hooks


Testing Custom Hooks

Custom hooks need special handling since they can only be called inside React components.

Setup

bash
1npm install -D @testing-library/react

renderHook Utility

jsx
1import { renderHook, act } from '@testing-library/react'
2
3// Custom hook
4function useCounter(initialValue = 0) {
5  const [count, setCount] = useState(initialValue)
6  
7  const increment = () => setCount(c => c + 1)
8  const decrement = () => setCount(c => c - 1)
9  const reset = () => setCount(initialValue)
10  
11  return { count, increment, decrement, reset }
12}
13
14// Test
15describe('useCounter', () => {
16  it('initializes with default value', () => {
17    const { result } = renderHook(() => useCounter())
18    
19    expect(result.current.count).toBe(0)
20  })
21  
22  it('initializes with provided value', () => {
23    const { result } = renderHook(() => useCounter(10))
24    
25    expect(result.current.count).toBe(10)
26  })
27  
28  it('increments count', () => {
29    const { result } = renderHook(() => useCounter())
30    
31    act(() => {
32      result.current.increment()
33    })
34    
35    expect(result.current.count).toBe(1)
36  })
37  
38  it('decrements count', () => {
39    const { result } = renderHook(() => useCounter(5))
40    
41    act(() => {
42      result.current.decrement()
43    })
44    
45    expect(result.current.count).toBe(4)
46  })
47  
48  it('resets to initial value', () => {
49    const { result } = renderHook(() => useCounter(10))
50    
51    act(() => {
52      result.current.increment()
53      result.current.increment()
54    })
55    
56    expect(result.current.count).toBe(12)
57    
58    act(() => {
59      result.current.reset()
60    })
61    
62    expect(result.current.count).toBe(10)
63  })
64})

Testing Async Hooks

jsx
1// Custom hook
2function useFetch(url) {
3  const [data, setData] = useState(null)
4  const [loading, setLoading] = useState(true)
5  const [error, setError] = useState(null)
6  
7  useEffect(() => {
8    const fetchData = async () => {
9      try {
10        const response = await fetch(url)
11        const json = await response.json()
12        setData(json)
13      } catch (err) {
14        setError(err.message)
15      } finally {
16        setLoading(false)
17      }
18    }
19    
20    fetchData()
21  }, [url])
22  
23  return { data, loading, error }
24}
25
26// Test
27describe('useFetch', () => {
28  beforeEach(() => {
29    global.fetch = vi.fn()
30  })
31  
32  it('fetches data successfully', async () => {
33    const mockData = { name: 'John' }
34    global.fetch.mockResolvedValue({
35      json: () => Promise.resolve(mockData)
36    })
37    
38    const { result } = renderHook(() => useFetch('/api/user'))
39    
40    // Initially loading
41    expect(result.current.loading).toBe(true)
42    expect(result.current.data).toBe(null)
43    
44    // Wait for fetch to complete
45    await waitFor(() => {
46      expect(result.current.loading).toBe(false)
47    })
48    
49    expect(result.current.data).toEqual(mockData)
50    expect(result.current.error).toBe(null)
51  })
52  
53  it('handles fetch error', async () => {
54    global.fetch.mockRejectedValue(new Error('Network error'))
55    
56    const { result } = renderHook(() => useFetch('/api/user'))
57    
58    await waitFor(() => {
59      expect(result.current.loading).toBe(false)
60    })
61    
62    expect(result.current.data).toBe(null)
63    expect(result.current.error).toBe('Network error')
64  })
65  
66  it('refetches when URL changes', async () => {
67    const mockData1 = { id: 1 }
68    const mockData2 = { id: 2 }
69    
70    global.fetch
71      .mockResolvedValueOnce({ json: () => Promise.resolve(mockData1) })
72      .mockResolvedValueOnce({ json: () => Promise.resolve(mockData2) })
73    
74    const { result, rerender } = renderHook(
75      ({ url }) => useFetch(url),
76      { initialProps: { url: '/api/user/1' } }
77    )
78    
79    await waitFor(() => {
80      expect(result.current.data).toEqual(mockData1)
81    })
82    
83    // Change URL
84    rerender({ url: '/api/user/2' })
85    
86    await waitFor(() => {
87      expect(result.current.data).toEqual(mockData2)
88    })
89  })
90})

Testing Hooks with Context

jsx
1// Custom hook that uses context
2function useAuth() {
3  const context = useContext(AuthContext)
4  if (!context) {
5    throw new Error('useAuth must be used within AuthProvider')
6  }
7  return context
8}
9
10// Test
11describe('useAuth', () => {
12  const wrapper = ({ children }) => (
13    <AuthProvider>
14      {children}
15    </AuthProvider>
16  )
17  
18  it('returns auth context', () => {
19    const { result } = renderHook(() => useAuth(), { wrapper })
20    
21    expect(result.current.user).toBe(null)
22    expect(typeof result.current.login).toBe('function')
23    expect(typeof result.current.logout).toBe('function')
24  })
25  
26  it('throws without provider', () => {
27    expect(() => {
28      renderHook(() => useAuth())
29    }).toThrow('useAuth must be used within AuthProvider')
30  })
31  
32  it('updates user on login', async () => {
33    const { result } = renderHook(() => useAuth(), { wrapper })
34    
35    await act(async () => {
36      await result.current.login('test@example.com', 'password')
37    })
38    
39    expect(result.current.user).toEqual({
40      email: 'test@example.com'
41    })
42  })
43})

Testing useEffect Cleanup

jsx
1function useEventListener(event, handler) {
2  useEffect(() => {
3    window.addEventListener(event, handler)
4    return () => window.removeEventListener(event, handler)
5  }, [event, handler])
6}
7
8describe('useEventListener', () => {
9  it('adds event listener', () => {
10    const addSpy = vi.spyOn(window, 'addEventListener')
11    const handler = vi.fn()
12    
13    renderHook(() => useEventListener('click', handler))
14    
15    expect(addSpy).toHaveBeenCalledWith('click', handler)
16  })
17  
18  it('removes event listener on unmount', () => {
19    const removeSpy = vi.spyOn(window, 'removeEventListener')
20    const handler = vi.fn()
21    
22    const { unmount } = renderHook(() => useEventListener('click', handler))
23    
24    unmount()
25    
26    expect(removeSpy).toHaveBeenCalledWith('click', handler)
27  })
28})

Testing useDebounce

jsx
1function useDebounce(value, delay) {
2  const [debouncedValue, setDebouncedValue] = useState(value)
3  
4  useEffect(() => {
5    const timer = setTimeout(() => {
6      setDebouncedValue(value)
7    }, delay)
8    
9    return () => clearTimeout(timer)
10  }, [value, delay])
11  
12  return debouncedValue
13}
14
15describe('useDebounce', () => {
16  beforeEach(() => {
17    vi.useFakeTimers()
18  })
19  
20  afterEach(() => {
21    vi.useRealTimers()
22  })
23  
24  it('returns initial value immediately', () => {
25    const { result } = renderHook(() => useDebounce('initial', 500))
26    expect(result.current).toBe('initial')
27  })
28  
29  it('debounces value changes', () => {
30    const { result, rerender } = renderHook(
31      ({ value }) => useDebounce(value, 500),
32      { initialProps: { value: 'initial' } }
33    )
34    
35    rerender({ value: 'updated' })
36    
37    // Still initial before timeout
38    expect(result.current).toBe('initial')
39    
40    // Fast forward
41    act(() => {
42      vi.advanceTimersByTime(500)
43    })
44    
45    expect(result.current).toBe('updated')
46  })
47})

Testing hooks ensures your reusable logic works correctly!