Testing Custom Hooks
Custom hooks need special handling since they can only be called inside React components.
Setup
bash
1npm install -D @testing-library/reactrenderHook 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!
