0
# Hook Testing
1
2
Specialized utilities for testing React hooks in isolation with proper act wrapping, lifecycle management, and both synchronous and asynchronous rendering support.
3
4
## Capabilities
5
6
### RenderHook Function
7
8
Test React hooks in isolation without needing to create wrapper components.
9
10
```typescript { .api }
11
/**
12
* Render a hook for testing in isolation
13
* @param hook - Hook function to test
14
* @param options - Hook rendering options
15
* @returns RenderHookResult with hook result and utilities
16
*/
17
function renderHook<Result, Props>(
18
hook: (props: Props) => Result,
19
options?: RenderHookOptions<Props>
20
): RenderHookResult<Result, Props>;
21
22
interface RenderHookOptions<Props> {
23
/** Initial props to pass to the hook */
24
initialProps?: Props;
25
26
/** React component wrapper (for providers) */
27
wrapper?: React.ComponentType<any>;
28
29
/** Enable/disable concurrent rendering */
30
concurrentRoot?: boolean;
31
}
32
33
interface RenderHookResult<Result, Props> {
34
/** Current hook result in a ref object */
35
result: { current: Result };
36
37
/** Re-render hook with new props */
38
rerender: (props?: Props) => void;
39
40
/** Unmount the hook */
41
unmount: () => void;
42
}
43
```
44
45
**Usage Examples:**
46
47
```typescript
48
import { renderHook } from "@testing-library/react-native";
49
50
test("testing useState hook", () => {
51
const useCounter = (initialValue = 0) => {
52
const [count, setCount] = useState(initialValue);
53
54
const increment = () => setCount(prev => prev + 1);
55
const decrement = () => setCount(prev => prev - 1);
56
const reset = () => setCount(initialValue);
57
58
return { count, increment, decrement, reset };
59
};
60
61
const { result } = renderHook(useCounter, {
62
initialProps: 5
63
});
64
65
// Initial state
66
expect(result.current.count).toBe(5);
67
68
// Test increment
69
act(() => {
70
result.current.increment();
71
});
72
expect(result.current.count).toBe(6);
73
74
// Test decrement
75
act(() => {
76
result.current.decrement();
77
});
78
expect(result.current.count).toBe(5);
79
80
// Test reset
81
act(() => {
82
result.current.reset();
83
});
84
expect(result.current.count).toBe(5);
85
});
86
87
test("testing useEffect hook", () => {
88
const useDocumentTitle = (title) => {
89
const [currentTitle, setCurrentTitle] = useState(title);
90
91
useEffect(() => {
92
setCurrentTitle(title);
93
}, [title]);
94
95
return currentTitle;
96
};
97
98
const { result, rerender } = renderHook(useDocumentTitle, {
99
initialProps: "Initial Title"
100
});
101
102
// Initial title
103
expect(result.current).toBe("Initial Title");
104
105
// Update title
106
rerender("Updated Title");
107
expect(result.current).toBe("Updated Title");
108
});
109
```
110
111
### Async Hook Testing
112
113
Test hooks with asynchronous behavior using renderHookAsync and proper async handling.
114
115
```typescript { .api }
116
/**
117
* Async version of renderHook for testing hooks with async behavior
118
* @param hook - Hook function to test
119
* @param options - Async hook rendering options
120
* @returns Promise resolving to RenderHookAsyncResult
121
*/
122
function renderHookAsync<Result, Props>(
123
hook: (props: Props) => Result,
124
options?: RenderHookOptions<Props>
125
): Promise<RenderHookAsyncResult<Result, Props>>;
126
127
interface RenderHookAsyncResult<Result, Props> {
128
/** Current hook result in a ref object */
129
result: { current: Result };
130
131
/** Re-render hook with new props asynchronously */
132
rerenderAsync: (props?: Props) => Promise<void>;
133
134
/** Unmount the hook asynchronously */
135
unmountAsync: () => Promise<void>;
136
}
137
```
138
139
**Usage Examples:**
140
141
```typescript
142
test("async hook testing", async () => {
143
const useAsyncData = (url) => {
144
const [data, setData] = useState(null);
145
const [loading, setLoading] = useState(true);
146
const [error, setError] = useState(null);
147
148
useEffect(() => {
149
const fetchData = async () => {
150
try {
151
setLoading(true);
152
setError(null);
153
154
// Simulate API call
155
await new Promise(resolve => setTimeout(resolve, 100));
156
157
if (url === "/error") {
158
throw new Error("API Error");
159
}
160
161
setData(`Data from ${url}`);
162
} catch (err) {
163
setError(err.message);
164
} finally {
165
setLoading(false);
166
}
167
};
168
169
fetchData();
170
}, [url]);
171
172
return { data, loading, error };
173
};
174
175
const { result } = await renderHookAsync(useAsyncData, {
176
initialProps: "/api/users"
177
});
178
179
// Initially loading
180
expect(result.current.loading).toBe(true);
181
expect(result.current.data).toBeNull();
182
expect(result.current.error).toBeNull();
183
184
// Wait for data to load
185
await waitFor(() => {
186
expect(result.current.loading).toBe(false);
187
});
188
189
expect(result.current.data).toBe("Data from /api/users");
190
expect(result.current.error).toBeNull();
191
});
192
193
test("async hook error handling", async () => {
194
const useAsyncData = (url) => {
195
const [data, setData] = useState(null);
196
const [loading, setLoading] = useState(true);
197
const [error, setError] = useState(null);
198
199
useEffect(() => {
200
const fetchData = async () => {
201
try {
202
setLoading(true);
203
setError(null);
204
await new Promise(resolve => setTimeout(resolve, 50));
205
206
if (url === "/error") {
207
throw new Error("Network error");
208
}
209
210
setData(`Success: ${url}`);
211
} catch (err) {
212
setError(err.message);
213
} finally {
214
setLoading(false);
215
}
216
};
217
218
fetchData();
219
}, [url]);
220
221
return { data, loading, error };
222
};
223
224
const { result, rerenderAsync } = await renderHookAsync(useAsyncData, {
225
initialProps: "/api/success"
226
});
227
228
// Wait for successful load
229
await waitFor(() => {
230
expect(result.current.loading).toBe(false);
231
});
232
233
expect(result.current.data).toBe("Success: /api/success");
234
expect(result.current.error).toBeNull();
235
236
// Test error case
237
await rerenderAsync("/error");
238
239
await waitFor(() => {
240
expect(result.current.loading).toBe(false);
241
});
242
243
expect(result.current.data).toBeNull();
244
expect(result.current.error).toBe("Network error");
245
});
246
```
247
248
### Hook Testing with Context
249
250
Test hooks that depend on React context by providing wrapper components.
251
252
```typescript { .api }
253
/**
254
* Wrapper component type for providing context to hooks
255
*/
256
type WrapperComponent<Props = {}> = React.ComponentType<Props & { children: React.ReactNode }>;
257
```
258
259
**Usage Examples:**
260
261
```typescript
262
// Context setup
263
const ThemeContext = React.createContext({
264
theme: "light",
265
toggleTheme: () => {}
266
});
267
268
const ThemeProvider = ({ children, initialTheme = "light" }) => {
269
const [theme, setTheme] = useState(initialTheme);
270
271
const toggleTheme = () => {
272
setTheme(prev => prev === "light" ? "dark" : "light");
273
};
274
275
return (
276
<ThemeContext.Provider value={{ theme, toggleTheme }}>
277
{children}
278
</ThemeContext.Provider>
279
);
280
};
281
282
// Hook that uses context
283
const useTheme = () => {
284
const context = useContext(ThemeContext);
285
if (!context) {
286
throw new Error("useTheme must be used within ThemeProvider");
287
}
288
return context;
289
};
290
291
test("hook with context provider", () => {
292
const { result } = renderHook(useTheme, {
293
wrapper: ({ children }) => (
294
<ThemeProvider initialTheme="dark">
295
{children}
296
</ThemeProvider>
297
)
298
});
299
300
// Initial theme from provider
301
expect(result.current.theme).toBe("dark");
302
303
// Test theme toggle
304
act(() => {
305
result.current.toggleTheme();
306
});
307
308
expect(result.current.theme).toBe("light");
309
310
// Toggle again
311
act(() => {
312
result.current.toggleTheme();
313
});
314
315
expect(result.current.theme).toBe("dark");
316
});
317
318
test("hook without context throws error", () => {
319
expect(() => {
320
renderHook(useTheme); // No wrapper provided
321
}).toThrow("useTheme must be used within ThemeProvider");
322
});
323
324
test("hook with multiple providers", () => {
325
const UserContext = React.createContext({ user: null });
326
const UserProvider = ({ children, user }) => (
327
<UserContext.Provider value={{ user }}>
328
{children}
329
</UserContext.Provider>
330
);
331
332
const useUserTheme = () => {
333
const { theme } = useTheme();
334
const { user } = useContext(UserContext);
335
336
return {
337
theme,
338
userTheme: user?.preferredTheme || theme,
339
user
340
};
341
};
342
343
const MultiProvider = ({ children }) => (
344
<ThemeProvider initialTheme="light">
345
<UserProvider user={{ name: "John", preferredTheme: "dark" }}>
346
{children}
347
</UserProvider>
348
</ThemeProvider>
349
);
350
351
const { result } = renderHook(useUserTheme, {
352
wrapper: MultiProvider
353
});
354
355
expect(result.current.theme).toBe("light");
356
expect(result.current.userTheme).toBe("dark");
357
expect(result.current.user.name).toBe("John");
358
});
359
```
360
361
### Custom Hook Testing Patterns
362
363
Advanced patterns for testing complex custom hooks.
364
365
```typescript { .api }
366
/**
367
* Test utilities for complex hook scenarios
368
*/
369
```
370
371
**Usage Examples:**
372
373
```typescript
374
test("custom hook with dependencies", () => {
375
const useDebounce = (value, delay) => {
376
const [debouncedValue, setDebouncedValue] = useState(value);
377
378
useEffect(() => {
379
const handler = setTimeout(() => {
380
setDebouncedValue(value);
381
}, delay);
382
383
return () => {
384
clearTimeout(handler);
385
};
386
}, [value, delay]);
387
388
return debouncedValue;
389
};
390
391
const { result, rerender } = renderHook(useDebounce, {
392
initialProps: { value: "initial", delay: 500 }
393
});
394
395
// Initial value is set immediately
396
expect(result.current).toBe("initial");
397
398
// Change value
399
rerender({ value: "updated", delay: 500 });
400
401
// Value should still be "initial" until debounce delay
402
expect(result.current).toBe("initial");
403
404
// Fast-forward time
405
act(() => {
406
jest.advanceTimersByTime(500);
407
});
408
409
// Now value should be updated
410
expect(result.current).toBe("updated");
411
412
// Change delay and value again
413
rerender({ value: "final", delay: 200 });
414
expect(result.current).toBe("updated"); // Still old value
415
416
act(() => {
417
jest.advanceTimersByTime(200);
418
});
419
420
expect(result.current).toBe("final");
421
});
422
423
test("hook with cleanup", () => {
424
const useEventListener = (eventName, handler) => {
425
const savedHandler = useRef(handler);
426
427
useEffect(() => {
428
savedHandler.current = handler;
429
}, [handler]);
430
431
useEffect(() => {
432
const eventListener = (event) => savedHandler.current(event);
433
434
// Add event listener (mocked for testing)
435
window.addEventListener?.(eventName, eventListener);
436
437
return () => {
438
window.removeEventListener?.(eventName, eventListener);
439
};
440
}, [eventName]);
441
};
442
443
const mockAddEventListener = jest.spyOn(window, 'addEventListener').mockImplementation();
444
const mockRemoveEventListener = jest.spyOn(window, 'removeEventListener').mockImplementation();
445
446
const handler = jest.fn();
447
448
const { unmount, rerender } = renderHook(useEventListener, {
449
initialProps: { eventName: "resize", handler }
450
});
451
452
// Event listener should be added
453
expect(mockAddEventListener).toHaveBeenCalledWith("resize", expect.any(Function));
454
455
// Change event name
456
rerender({ eventName: "scroll", handler });
457
458
// Should remove old listener and add new one
459
expect(mockRemoveEventListener).toHaveBeenCalledWith("resize", expect.any(Function));
460
expect(mockAddEventListener).toHaveBeenCalledWith("scroll", expect.any(Function));
461
462
// Unmount should cleanup
463
unmount();
464
465
expect(mockRemoveEventListener).toHaveBeenCalledWith("scroll", expect.any(Function));
466
467
mockAddEventListener.mockRestore();
468
mockRemoveEventListener.mockRestore();
469
});
470
471
test("hook with reducer pattern", () => {
472
const useCounter = () => {
473
const [state, dispatch] = useReducer((state, action) => {
474
switch (action.type) {
475
case "increment":
476
return { count: state.count + (action.payload || 1) };
477
case "decrement":
478
return { count: state.count - (action.payload || 1) };
479
case "reset":
480
return { count: action.payload || 0 };
481
default:
482
return state;
483
}
484
}, { count: 0 });
485
486
const increment = (amount) => dispatch({ type: "increment", payload: amount });
487
const decrement = (amount) => dispatch({ type: "decrement", payload: amount });
488
const reset = (value) => dispatch({ type: "reset", payload: value });
489
490
return {
491
count: state.count,
492
increment,
493
decrement,
494
reset
495
};
496
};
497
498
const { result } = renderHook(useCounter);
499
500
// Initial state
501
expect(result.current.count).toBe(0);
502
503
// Increment by 1 (default)
504
act(() => {
505
result.current.increment();
506
});
507
expect(result.current.count).toBe(1);
508
509
// Increment by custom amount
510
act(() => {
511
result.current.increment(5);
512
});
513
expect(result.current.count).toBe(6);
514
515
// Decrement
516
act(() => {
517
result.current.decrement(2);
518
});
519
expect(result.current.count).toBe(4);
520
521
// Reset to custom value
522
act(() => {
523
result.current.reset(10);
524
});
525
expect(result.current.count).toBe(10);
526
527
// Reset to default (0)
528
act(() => {
529
result.current.reset();
530
});
531
expect(result.current.count).toBe(0);
532
});
533
```
534
535
### Hook Testing Best Practices
536
537
Guidelines and patterns for effective hook testing.
538
539
```typescript { .api }
540
/**
541
* Best practices for hook testing
542
*/
543
```
544
545
**Usage Examples:**
546
547
```typescript
548
test("testing hook with external dependencies", () => {
549
// Mock external dependencies
550
const mockFetch = jest.fn();
551
global.fetch = mockFetch;
552
553
const useApi = (url) => {
554
const [data, setData] = useState(null);
555
const [loading, setLoading] = useState(false);
556
const [error, setError] = useState(null);
557
558
const fetchData = useCallback(async () => {
559
setLoading(true);
560
setError(null);
561
562
try {
563
const response = await fetch(url);
564
const result = await response.json();
565
setData(result);
566
} catch (err) {
567
setError(err.message);
568
} finally {
569
setLoading(false);
570
}
571
}, [url]);
572
573
useEffect(() => {
574
fetchData();
575
}, [fetchData]);
576
577
return { data, loading, error, refetch: fetchData };
578
};
579
580
// Mock successful response
581
mockFetch.mockResolvedValueOnce({
582
json: () => Promise.resolve({ id: 1, name: "Test" })
583
});
584
585
const { result } = renderHook(useApi, {
586
initialProps: "/api/test"
587
});
588
589
expect(result.current.loading).toBe(true);
590
591
// Wait for fetch to complete
592
return waitFor(() => {
593
expect(result.current.loading).toBe(false);
594
expect(result.current.data).toEqual({ id: 1, name: "Test" });
595
expect(result.current.error).toBeNull();
596
});
597
});
598
599
test("testing hook error boundaries", () => {
600
const useRiskyHook = (shouldThrow) => {
601
const [value, setValue] = useState("safe");
602
603
useEffect(() => {
604
if (shouldThrow) {
605
throw new Error("Hook error");
606
}
607
}, [shouldThrow]);
608
609
return value;
610
};
611
612
// Test safe usage
613
const { result, rerender } = renderHook(useRiskyHook, {
614
initialProps: false
615
});
616
617
expect(result.current).toBe("safe");
618
619
// Test error case - should be caught by error boundary
620
expect(() => {
621
rerender(true);
622
}).toThrow("Hook error");
623
});
624
625
test("testing hook performance", () => {
626
const useExpensiveCalculation = (input) => {
627
const expensiveFunction = useCallback((n) => {
628
// Simulate expensive calculation
629
let result = 0;
630
for (let i = 0; i < n; i++) {
631
result += i;
632
}
633
return result;
634
}, []);
635
636
const memoizedResult = useMemo(() => {
637
return expensiveFunction(input);
638
}, [input, expensiveFunction]);
639
640
return memoizedResult;
641
};
642
643
const expensiveFunctionSpy = jest.fn((n) => {
644
let result = 0;
645
for (let i = 0; i < n; i++) {
646
result += i;
647
}
648
return result;
649
});
650
651
// Test with memoization
652
const { result, rerender } = renderHook(useExpensiveCalculation, {
653
initialProps: 1000
654
});
655
656
const firstResult = result.current;
657
expect(typeof firstResult).toBe("number");
658
659
// Re-render with same props - should not recalculate
660
rerender(1000);
661
expect(result.current).toBe(firstResult);
662
663
// Re-render with different props - should recalculate
664
rerender(2000);
665
expect(result.current).not.toBe(firstResult);
666
});
667
```
668
669
### Concurrent Mode and Suspense
670
671
Testing hooks in concurrent mode and with Suspense boundaries.
672
673
```typescript { .api }
674
/**
675
* Hook testing with concurrent features
676
*/
677
```
678
679
**Usage Examples:**
680
681
```typescript
682
test("hook with concurrent rendering", async () => {
683
const useConcurrentData = (query) => {
684
const [data, setData] = useState(null);
685
686
// Use transition for non-urgent updates
687
const [isPending, startTransition] = useTransition();
688
689
const updateData = (newQuery) => {
690
startTransition(() => {
691
// Expensive state update
692
setData(`Result for ${newQuery}`);
693
});
694
};
695
696
useEffect(() => {
697
updateData(query);
698
}, [query]);
699
700
return { data, isPending, updateData };
701
};
702
703
const { result } = renderHook(useConcurrentData, {
704
initialProps: "initial query",
705
concurrentRoot: true
706
});
707
708
await waitFor(() => {
709
expect(result.current.data).toBe("Result for initial query");
710
});
711
712
expect(result.current.isPending).toBe(false);
713
714
// Test pending state during transition
715
act(() => {
716
result.current.updateData("new query");
717
});
718
719
// Might be pending briefly
720
if (result.current.isPending) {
721
await waitFor(() => {
722
expect(result.current.isPending).toBe(false);
723
});
724
}
725
726
expect(result.current.data).toBe("Result for new query");
727
});
728
729
test("hook with Suspense", async () => {
730
const cache = new Map();
731
732
const useSuspenseData = (key) => {
733
if (!cache.has(key)) {
734
const promise = new Promise(resolve => {
735
setTimeout(() => resolve(`Data for ${key}`), 100);
736
});
737
cache.set(key, promise);
738
throw promise;
739
}
740
741
const data = cache.get(key);
742
if (data instanceof Promise) {
743
throw data;
744
}
745
746
return data;
747
};
748
749
const SuspenseWrapper = ({ children }) => (
750
<Suspense fallback={<Text>Loading...</Text>}>
751
{children}
752
</Suspense>
753
);
754
755
// This will initially throw and trigger Suspense
756
const hookPromise = renderHookAsync(useSuspenseData, {
757
initialProps: "test-key",
758
wrapper: SuspenseWrapper
759
});
760
761
// Wait for Suspense to resolve
762
const { result } = await hookPromise;
763
764
await waitFor(() => {
765
expect(result.current).toBe("Data for test-key");
766
});
767
});
768
```