0
# Async Testing Utilities
1
2
Utilities for handling asynchronous behavior in React components, including waiting for elements to appear, disappear, and for arbitrary conditions to be met. These utilities are essential for testing components with async state updates, API calls, and delayed rendering.
3
4
## Capabilities
5
6
### WaitFor Function
7
8
Waits for a callback function to pass without throwing an error, with configurable timeout and polling interval.
9
10
```typescript { .api }
11
/**
12
* Wait for a condition to be true by repeatedly calling a callback
13
* @param callback - Function to test, can be sync or async
14
* @param options - Configuration for waiting behavior
15
* @returns Promise that resolves with callback return value
16
*/
17
function waitFor<T>(
18
callback: () => T | Promise<T>,
19
options?: WaitForOptions
20
): Promise<T>;
21
22
interface WaitForOptions {
23
/** Container to search within for DOM queries */
24
container?: HTMLElement;
25
/** Maximum time to wait in milliseconds (default: 1000) */
26
timeout?: number;
27
/** Polling interval in milliseconds (default: 50) */
28
interval?: number;
29
/** Custom error handler for timeout */
30
onTimeout?: (error: Error) => Error;
31
/** Suppress console errors during polling */
32
showOriginalStackTrace?: boolean;
33
}
34
```
35
36
**Basic Usage:**
37
38
```typescript
39
function AsyncComponent() {
40
const [data, setData] = useState(null);
41
const [loading, setLoading] = useState(true);
42
43
useEffect(() => {
44
setTimeout(() => {
45
setData('Async data loaded');
46
setLoading(false);
47
}, 1000);
48
}, []);
49
50
return (
51
<div>
52
{loading ? <p>Loading...</p> : <p>{data}</p>}
53
</div>
54
);
55
}
56
57
render(<AsyncComponent />);
58
59
// Wait for data to appear
60
await waitFor(() => {
61
expect(screen.getByText('Async data loaded')).toBeInTheDocument();
62
});
63
64
// Wait for loading to disappear
65
await waitFor(() => {
66
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
67
});
68
```
69
70
### WaitForElementToBeRemoved Function
71
72
Waits for elements to be removed from the DOM, useful for testing disappearing content.
73
74
```typescript { .api }
75
/**
76
* Wait for element(s) to be removed from the DOM
77
* @param callback - Element or function returning element(s) to wait for removal
78
* @param options - Configuration for waiting behavior
79
* @returns Promise that resolves when element(s) are removed
80
*/
81
function waitForElementToBeRemoved<T>(
82
callback: T | (() => T),
83
options?: WaitForOptions
84
): Promise<void>;
85
```
86
87
**Usage Examples:**
88
89
```typescript
90
function DisappearingComponent() {
91
const [visible, setVisible] = useState(true);
92
93
useEffect(() => {
94
const timer = setTimeout(() => setVisible(false), 1000);
95
return () => clearTimeout(timer);
96
}, []);
97
98
return visible ? <div data-testid="disappearing">Will disappear</div> : null;
99
}
100
101
render(<DisappearingComponent />);
102
103
// Element is initially present
104
const element = screen.getByTestId('disappearing');
105
expect(element).toBeInTheDocument();
106
107
// Wait for it to be removed
108
await waitForElementToBeRemoved(element);
109
110
// Element is no longer in DOM
111
expect(screen.queryByTestId('disappearing')).not.toBeInTheDocument();
112
113
// Alternative: pass function
114
await waitForElementToBeRemoved(() => screen.queryByTestId('disappearing'));
115
```
116
117
## Advanced Async Patterns
118
119
### API Call Testing
120
121
```typescript
122
function UserProfile({ userId }: { userId: string }) {
123
const [user, setUser] = useState(null);
124
const [loading, setLoading] = useState(true);
125
const [error, setError] = useState(null);
126
127
useEffect(() => {
128
fetch(`/api/users/${userId}`)
129
.then(response => {
130
if (!response.ok) throw new Error('Failed to fetch');
131
return response.json();
132
})
133
.then(userData => {
134
setUser(userData);
135
setLoading(false);
136
})
137
.catch(err => {
138
setError(err.message);
139
setLoading(false);
140
});
141
}, [userId]);
142
143
if (loading) return <div>Loading user...</div>;
144
if (error) return <div>Error: {error}</div>;
145
146
return (
147
<div>
148
<h1>{user.name}</h1>
149
<p>{user.email}</p>
150
</div>
151
);
152
}
153
154
// Mock fetch
155
global.fetch = jest.fn();
156
157
// Test successful API call
158
fetch.mockResolvedValueOnce({
159
ok: true,
160
json: async () => ({ name: 'John Doe', email: 'john@example.com' })
161
});
162
163
render(<UserProfile userId="123" />);
164
165
// Initially loading
166
expect(screen.getByText('Loading user...')).toBeInTheDocument();
167
168
// Wait for data to load
169
await waitFor(() => {
170
expect(screen.getByText('John Doe')).toBeInTheDocument();
171
});
172
173
expect(screen.getByText('john@example.com')).toBeInTheDocument();
174
expect(screen.queryByText('Loading user...')).not.toBeInTheDocument();
175
176
// Test error scenario
177
fetch.mockRejectedValueOnce(new Error('Network error'));
178
179
rerender(<UserProfile userId="456" />);
180
181
await waitFor(() => {
182
expect(screen.getByText('Error: Network error')).toBeInTheDocument();
183
});
184
```
185
186
### Form Validation Testing
187
188
```typescript
189
function ValidatedForm() {
190
const [email, setEmail] = useState('');
191
const [errors, setErrors] = useState({});
192
const [isValidating, setIsValidating] = useState(false);
193
194
const validateEmail = async (value) => {
195
setIsValidating(true);
196
197
// Simulate async validation
198
await new Promise(resolve => setTimeout(resolve, 500));
199
200
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
201
202
setErrors(prev => ({
203
...prev,
204
email: isValid ? null : 'Invalid email format'
205
}));
206
207
setIsValidating(false);
208
};
209
210
const handleEmailChange = (e) => {
211
const value = e.target.value;
212
setEmail(value);
213
214
if (value) {
215
validateEmail(value);
216
} else {
217
setErrors(prev => ({ ...prev, email: null }));
218
}
219
};
220
221
return (
222
<form>
223
<input
224
type="email"
225
value={email}
226
onChange={handleEmailChange}
227
placeholder="Enter email"
228
data-testid="email-input"
229
/>
230
{isValidating && <span>Validating...</span>}
231
{errors.email && <span data-testid="email-error">{errors.email}</span>}
232
</form>
233
);
234
}
235
236
render(<ValidatedForm />);
237
238
const emailInput = screen.getByTestId('email-input');
239
240
// Type invalid email
241
fireEvent.change(emailInput, { target: { value: 'invalid-email' } });
242
243
// Wait for validation to start
244
await waitFor(() => {
245
expect(screen.getByText('Validating...')).toBeInTheDocument();
246
});
247
248
// Wait for validation to complete
249
await waitFor(() => {
250
expect(screen.queryByText('Validating...')).not.toBeInTheDocument();
251
});
252
253
// Check error appears
254
expect(screen.getByTestId('email-error')).toHaveTextContent('Invalid email format');
255
256
// Type valid email
257
fireEvent.change(emailInput, { target: { value: 'valid@example.com' } });
258
259
// Wait for error to disappear
260
await waitFor(() => {
261
expect(screen.queryByTestId('email-error')).not.toBeInTheDocument();
262
});
263
```
264
265
### Animation Testing
266
267
```typescript
268
function AnimatedComponent() {
269
const [isVisible, setIsVisible] = useState(false);
270
const [animationClass, setAnimationClass] = useState('');
271
272
const show = () => {
273
setIsVisible(true);
274
setAnimationClass('fade-in');
275
276
// Remove animation class after animation completes
277
setTimeout(() => setAnimationClass(''), 300);
278
};
279
280
const hide = () => {
281
setAnimationClass('fade-out');
282
283
// Hide element after animation completes
284
setTimeout(() => {
285
setIsVisible(false);
286
setAnimationClass('');
287
}, 300);
288
};
289
290
return (
291
<div>
292
<button onClick={show}>Show</button>
293
<button onClick={hide}>Hide</button>
294
{isVisible && (
295
<div
296
className={animationClass}
297
data-testid="animated-element"
298
>
299
Animated content
300
</div>
301
)}
302
</div>
303
);
304
}
305
306
render(<AnimatedComponent />);
307
308
// Initially hidden
309
expect(screen.queryByTestId('animated-element')).not.toBeInTheDocument();
310
311
// Show element
312
fireEvent.click(screen.getByText('Show'));
313
314
// Wait for element to appear
315
await waitFor(() => {
316
expect(screen.getByTestId('animated-element')).toBeInTheDocument();
317
});
318
319
// Check animation class is applied
320
expect(screen.getByTestId('animated-element')).toHaveClass('fade-in');
321
322
// Wait for animation to complete
323
await waitFor(() => {
324
expect(screen.getByTestId('animated-element')).not.toHaveClass('fade-in');
325
});
326
327
// Hide element
328
fireEvent.click(screen.getByText('Hide'));
329
330
// Check fade-out class is applied
331
await waitFor(() => {
332
expect(screen.getByTestId('animated-element')).toHaveClass('fade-out');
333
});
334
335
// Wait for element to be removed
336
await waitForElementToBeRemoved(screen.getByTestId('animated-element'));
337
```
338
339
### Debounced Input Testing
340
341
```typescript
342
function SearchComponent() {
343
const [query, setQuery] = useState('');
344
const [results, setResults] = useState([]);
345
const [isSearching, setIsSearching] = useState(false);
346
347
useEffect(() => {
348
if (!query) {
349
setResults([]);
350
return;
351
}
352
353
setIsSearching(true);
354
355
const searchTimer = setTimeout(async () => {
356
// Simulate API call
357
const response = await fetch(`/api/search?q=${query}`);
358
const data = await response.json();
359
360
setResults(data.results);
361
setIsSearching(false);
362
}, 300);
363
364
return () => {
365
clearTimeout(searchTimer);
366
setIsSearching(false);
367
};
368
}, [query]);
369
370
return (
371
<div>
372
<input
373
type="text"
374
value={query}
375
onChange={(e) => setQuery(e.target.value)}
376
placeholder="Search..."
377
data-testid="search-input"
378
/>
379
{isSearching && <div data-testid="searching">Searching...</div>}
380
<ul>
381
{results.map((result, index) => (
382
<li key={index} data-testid="result-item">
383
{result.title}
384
</li>
385
))}
386
</ul>
387
</div>
388
);
389
}
390
391
// Mock fetch
392
global.fetch = jest.fn(() =>
393
Promise.resolve({
394
json: () => Promise.resolve({
395
results: [
396
{ title: 'Search result 1' },
397
{ title: 'Search result 2' }
398
]
399
})
400
})
401
);
402
403
render(<SearchComponent />);
404
405
const searchInput = screen.getByTestId('search-input');
406
407
// Type search query
408
fireEvent.change(searchInput, { target: { value: 'test query' } });
409
410
// Wait for debounced search to start
411
await waitFor(() => {
412
expect(screen.getByTestId('searching')).toBeInTheDocument();
413
});
414
415
// Wait for search to complete
416
await waitFor(() => {
417
expect(screen.queryByTestId('searching')).not.toBeInTheDocument();
418
});
419
420
// Check results appear
421
const resultItems = screen.getAllByTestId('result-item');
422
expect(resultItems).toHaveLength(2);
423
expect(resultItems[0]).toHaveTextContent('Search result 1');
424
```
425
426
### Error Boundary Testing
427
428
```typescript
429
function ErrorBoundary({ children }) {
430
const [hasError, setHasError] = useState(false);
431
432
useEffect(() => {
433
const handleError = () => setHasError(true);
434
window.addEventListener('error', handleError);
435
return () => window.removeEventListener('error', handleError);
436
}, []);
437
438
if (hasError) {
439
return <div data-testid="error-message">Something went wrong</div>;
440
}
441
442
return children;
443
}
444
445
function ThrowingComponent({ shouldThrow }) {
446
useEffect(() => {
447
if (shouldThrow) {
448
setTimeout(() => {
449
throw new Error('Async error');
450
}, 100);
451
}
452
}, [shouldThrow]);
453
454
return <div>Component content</div>;
455
}
456
457
render(
458
<ErrorBoundary>
459
<ThrowingComponent shouldThrow={true} />
460
</ErrorBoundary>
461
);
462
463
// Initially no error
464
expect(screen.getByText('Component content')).toBeInTheDocument();
465
expect(screen.queryByTestId('error-message')).not.toBeInTheDocument();
466
467
// Wait for error to be thrown and handled
468
await waitFor(() => {
469
expect(screen.getByTestId('error-message')).toBeInTheDocument();
470
});
471
472
expect(screen.queryByText('Component content')).not.toBeInTheDocument();
473
```
474
475
### Custom Timeout and Error Handling
476
477
```typescript
478
function SlowComponent() {
479
const [data, setData] = useState(null);
480
481
useEffect(() => {
482
// Very slow operation
483
setTimeout(() => {
484
setData('Finally loaded');
485
}, 2000);
486
}, []);
487
488
return data ? <div>{data}</div> : <div>Loading...</div>;
489
}
490
491
render(<SlowComponent />);
492
493
// Custom timeout for slow operations
494
await waitFor(
495
() => {
496
expect(screen.getByText('Finally loaded')).toBeInTheDocument();
497
},
498
{ timeout: 3000 } // Wait up to 3 seconds
499
);
500
501
// Custom error message
502
await waitFor(
503
() => {
504
expect(screen.getByText('Non-existent text')).toBeInTheDocument();
505
},
506
{
507
timeout: 1000,
508
onTimeout: (error) => new Error('Custom timeout message: Element not found')
509
}
510
).catch(error => {
511
expect(error.message).toContain('Custom timeout message');
512
});
513
514
// Custom polling interval
515
let pollCount = 0;
516
await waitFor(
517
() => {
518
pollCount++;
519
return expect(screen.getByText('Finally loaded')).toBeInTheDocument();
520
},
521
{ interval: 100 } // Check every 100ms instead of default 50ms
522
);
523
```