0
# Testing Utilities
1
2
Testing helpers for managing component rendering, effects flushing, and test environment setup. These utilities ensure reliable testing of Preact components with proper lifecycle management.
3
4
## Capabilities
5
6
### Test Environment Setup
7
8
Functions for initializing and managing the test environment.
9
10
```typescript { .api }
11
/**
12
* Sets up the test environment and returns a rerender function
13
* @returns Function to trigger component re-renders in tests
14
*/
15
function setupRerender(): () => void;
16
17
/**
18
* Wraps test code to ensure all effects and updates are flushed
19
* @param callback - Test code to execute
20
* @returns Promise that resolves when all updates are complete
21
*/
22
function act(callback: () => void | Promise<void>): Promise<void>;
23
24
/**
25
* Resets the test environment and cleans up Preact state
26
* Should be called after each test to ensure clean state
27
*/
28
function teardown(): void;
29
```
30
31
**Usage Examples:**
32
33
```typescript
34
import { setupRerender, act, teardown, render, createElement } from "preact/test-utils";
35
import { useState } from "preact/hooks";
36
37
// Basic test setup
38
describe('Component Tests', () => {
39
let rerender: () => void;
40
41
beforeEach(() => {
42
rerender = setupRerender();
43
});
44
45
afterEach(() => {
46
teardown();
47
});
48
49
test('component renders correctly', async () => {
50
function Counter() {
51
const [count, setCount] = useState(0);
52
53
return createElement("div", null,
54
createElement("span", { "data-testid": "count" }, count),
55
createElement("button", {
56
onClick: () => setCount(c => c + 1),
57
"data-testid": "increment"
58
}, "Increment")
59
);
60
}
61
62
// Render component
63
const container = document.createElement('div');
64
document.body.appendChild(container);
65
66
await act(() => {
67
render(createElement(Counter), container);
68
});
69
70
const countElement = container.querySelector('[data-testid="count"]');
71
const buttonElement = container.querySelector('[data-testid="increment"]');
72
73
expect(countElement?.textContent).toBe('0');
74
75
// Test interaction
76
await act(() => {
77
buttonElement?.click();
78
});
79
80
expect(countElement?.textContent).toBe('1');
81
82
// Cleanup
83
document.body.removeChild(container);
84
});
85
});
86
87
// Testing with async effects
88
test('component with async effects', async () => {
89
const rerender = setupRerender();
90
91
function AsyncComponent({ userId }: { userId: number }) {
92
const [user, setUser] = useState(null);
93
const [loading, setLoading] = useState(true);
94
95
useEffect(() => {
96
const fetchUser = async () => {
97
setLoading(true);
98
// Mock async operation
99
await new Promise(resolve => setTimeout(resolve, 100));
100
setUser({ id: userId, name: `User ${userId}` });
101
setLoading(false);
102
};
103
104
fetchUser();
105
}, [userId]);
106
107
if (loading) return createElement("div", null, "Loading...");
108
if (!user) return createElement("div", null, "No user");
109
110
return createElement("div", null, user.name);
111
}
112
113
const container = document.createElement('div');
114
document.body.appendChild(container);
115
116
await act(async () => {
117
render(createElement(AsyncComponent, { userId: 1 }), container);
118
});
119
120
// Initially loading
121
expect(container.textContent).toBe('Loading...');
122
123
// Wait for async effect to complete
124
await act(async () => {
125
await new Promise(resolve => setTimeout(resolve, 150));
126
});
127
128
expect(container.textContent).toBe('User 1');
129
130
document.body.removeChild(container);
131
teardown();
132
});
133
```
134
135
### Component Testing Patterns
136
137
Common patterns for testing Preact components effectively.
138
139
**Usage Examples:**
140
141
```typescript
142
import { setupRerender, act, teardown, render, createElement } from "preact/test-utils";
143
import { useState, useEffect } from "preact/hooks";
144
145
// Testing component state changes
146
function testComponentState() {
147
const rerender = setupRerender();
148
149
function StatefulComponent() {
150
const [state, setState] = useState({ count: 0, name: '' });
151
152
const updateCount = () => setState(prev => ({ ...prev, count: prev.count + 1 }));
153
const updateName = (name: string) => setState(prev => ({ ...prev, name }));
154
155
return createElement("div", null,
156
createElement("div", { "data-testid": "count" }, state.count),
157
createElement("div", { "data-testid": "name" }, state.name),
158
createElement("button", {
159
onClick: updateCount,
160
"data-testid": "count-btn"
161
}, "Count++"),
162
createElement("input", {
163
value: state.name,
164
onChange: (e) => updateName((e.target as HTMLInputElement).value),
165
"data-testid": "name-input"
166
})
167
);
168
}
169
170
const container = document.createElement('div');
171
document.body.appendChild(container);
172
173
act(() => {
174
render(createElement(StatefulComponent), container);
175
});
176
177
const countEl = container.querySelector('[data-testid="count"]') as HTMLElement;
178
const nameEl = container.querySelector('[data-testid="name"]') as HTMLElement;
179
const countBtn = container.querySelector('[data-testid="count-btn"]') as HTMLButtonElement;
180
const nameInput = container.querySelector('[data-testid="name-input"]') as HTMLInputElement;
181
182
// Initial state
183
expect(countEl.textContent).toBe('0');
184
expect(nameEl.textContent).toBe('');
185
186
// Test count update
187
act(() => {
188
countBtn.click();
189
});
190
191
expect(countEl.textContent).toBe('1');
192
193
// Test name update
194
act(() => {
195
nameInput.value = 'John';
196
nameInput.dispatchEvent(new Event('input', { bubbles: true }));
197
});
198
199
expect(nameEl.textContent).toBe('John');
200
201
document.body.removeChild(container);
202
teardown();
203
}
204
205
// Testing component with context
206
function testComponentWithContext() {
207
const rerender = setupRerender();
208
209
const TestContext = createContext({ value: 'default' });
210
211
function ContextConsumer() {
212
const { value } = useContext(TestContext);
213
return createElement("div", { "data-testid": "context-value" }, value);
214
}
215
216
function TestWrapper({ contextValue, children }: {
217
contextValue: string;
218
children: ComponentChildren;
219
}) {
220
return createElement(TestContext.Provider, {
221
value: { value: contextValue }
222
}, children);
223
}
224
225
const container = document.createElement('div');
226
document.body.appendChild(container);
227
228
act(() => {
229
render(
230
createElement(TestWrapper, { contextValue: 'test-value' },
231
createElement(ContextConsumer)
232
),
233
container
234
);
235
});
236
237
const valueEl = container.querySelector('[data-testid="context-value"]') as HTMLElement;
238
expect(valueEl.textContent).toBe('test-value');
239
240
document.body.removeChild(container);
241
teardown();
242
}
243
244
// Testing component lifecycle
245
function testComponentLifecycle() {
246
const rerender = setupRerender();
247
const mountSpy = jest.fn();
248
const unmountSpy = jest.fn();
249
const updateSpy = jest.fn();
250
251
function LifecycleComponent({ value }: { value: string }) {
252
useEffect(() => {
253
mountSpy();
254
return () => unmountSpy();
255
}, []);
256
257
useEffect(() => {
258
updateSpy(value);
259
}, [value]);
260
261
return createElement("div", null, value);
262
}
263
264
const container = document.createElement('div');
265
document.body.appendChild(container);
266
267
// Mount
268
act(() => {
269
render(createElement(LifecycleComponent, { value: 'initial' }), container);
270
});
271
272
expect(mountSpy).toHaveBeenCalledTimes(1);
273
expect(updateSpy).toHaveBeenCalledWith('initial');
274
275
// Update
276
act(() => {
277
render(createElement(LifecycleComponent, { value: 'updated' }), container);
278
});
279
280
expect(updateSpy).toHaveBeenCalledWith('updated');
281
282
// Unmount
283
act(() => {
284
render(null, container);
285
});
286
287
expect(unmountSpy).toHaveBeenCalledTimes(1);
288
289
document.body.removeChild(container);
290
teardown();
291
}
292
```
293
294
### Advanced Testing Utilities
295
296
Helper functions and patterns for complex testing scenarios.
297
298
**Usage Examples:**
299
300
```typescript
301
import { setupRerender, act, teardown } from "preact/test-utils";
302
303
// Custom testing utilities
304
class TestRenderer {
305
container: HTMLElement;
306
rerender: () => void;
307
308
constructor() {
309
this.container = document.createElement('div');
310
document.body.appendChild(this.container);
311
this.rerender = setupRerender();
312
}
313
314
render(element: ComponentChildren) {
315
return act(() => {
316
render(element, this.container);
317
});
318
}
319
320
update(element: ComponentChildren) {
321
return this.render(element);
322
}
323
324
findByTestId(testId: string): HTMLElement | null {
325
return this.container.querySelector(`[data-testid="${testId}"]`);
326
}
327
328
findAllByTestId(testId: string): NodeListOf<HTMLElement> {
329
return this.container.querySelectorAll(`[data-testid="${testId}"]`);
330
}
331
332
findByText(text: string): HTMLElement | null {
333
const walker = document.createTreeWalker(
334
this.container,
335
NodeFilter.SHOW_TEXT,
336
null,
337
false
338
);
339
340
let node;
341
while (node = walker.nextNode()) {
342
if (node.textContent?.includes(text)) {
343
return node.parentElement;
344
}
345
}
346
return null;
347
}
348
349
cleanup() {
350
document.body.removeChild(this.container);
351
teardown();
352
}
353
}
354
355
// Mock async operations
356
function mockAsyncOperation<T>(result: T, delay = 100): Promise<T> {
357
return new Promise(resolve => {
358
setTimeout(() => resolve(result), delay);
359
});
360
}
361
362
// Testing hook-based components
363
function TestHookComponent({ hook }: { hook: () => any }) {
364
const result = hook();
365
366
return createElement("div", {
367
"data-testid": "hook-result"
368
}, JSON.stringify(result));
369
}
370
371
function testCustomHook(hook: () => any) {
372
const renderer = new TestRenderer();
373
374
renderer.render(createElement(TestHookComponent, { hook }));
375
376
const getResult = () => {
377
const element = renderer.findByTestId('hook-result');
378
return element ? JSON.parse(element.textContent || '{}') : null;
379
};
380
381
return {
382
result: { current: getResult() },
383
rerender: (newHook?: () => any) => {
384
renderer.update(createElement(TestHookComponent, { hook: newHook || hook }));
385
},
386
cleanup: () => renderer.cleanup()
387
};
388
}
389
390
// Example: Testing custom hook
391
function useCounter(initialValue = 0) {
392
const [count, setCount] = useState(initialValue);
393
394
const increment = () => setCount(c => c + 1);
395
const decrement = () => setCount(c => c - 1);
396
const reset = () => setCount(initialValue);
397
398
return { count, increment, decrement, reset };
399
}
400
401
test('useCounter hook', () => {
402
const { result, rerender, cleanup } = testCustomHook(() => useCounter(5));
403
404
expect(result.current.count).toBe(5);
405
406
act(() => {
407
result.current.increment();
408
});
409
410
expect(result.current.count).toBe(6);
411
412
act(() => {
413
result.current.decrement();
414
});
415
416
expect(result.current.count).toBe(5);
417
418
act(() => {
419
result.current.reset();
420
});
421
422
expect(result.current.count).toBe(5);
423
424
cleanup();
425
});
426
427
// Testing error boundaries
428
function TestErrorBoundary({
429
children,
430
onError
431
}: {
432
children: ComponentChildren;
433
onError?: (error: Error) => void;
434
}) {
435
return createElement(ErrorBoundary, {
436
onError,
437
fallback: createElement("div", { "data-testid": "error" }, "Error occurred")
438
}, children);
439
}
440
441
function ErrorThrowingComponent({ shouldThrow }: { shouldThrow: boolean }) {
442
if (shouldThrow) {
443
throw new Error('Test error');
444
}
445
446
return createElement("div", { "data-testid": "success" }, "No error");
447
}
448
449
test('error boundary handling', () => {
450
const renderer = new TestRenderer();
451
const errorSpy = jest.fn();
452
453
// Render without error
454
renderer.render(
455
createElement(TestErrorBoundary, { onError: errorSpy },
456
createElement(ErrorThrowingComponent, { shouldThrow: false })
457
)
458
);
459
460
expect(renderer.findByTestId('success')).toBeTruthy();
461
expect(renderer.findByTestId('error')).toBeFalsy();
462
463
// Render with error
464
act(() => {
465
renderer.update(
466
createElement(TestErrorBoundary, { onError: errorSpy },
467
createElement(ErrorThrowingComponent, { shouldThrow: true })
468
)
469
);
470
});
471
472
expect(renderer.findByTestId('error')).toBeTruthy();
473
expect(renderer.findByTestId('success')).toBeFalsy();
474
expect(errorSpy).toHaveBeenCalledWith(expect.any(Error));
475
476
renderer.cleanup();
477
});
478
```
479
480
### Integration Testing Patterns
481
482
Patterns for testing component integration and complex interactions.
483
484
**Usage Examples:**
485
486
```typescript
487
// Testing form components
488
function testFormIntegration() {
489
const renderer = new TestRenderer();
490
491
function ContactForm() {
492
const [formData, setFormData] = useState({
493
name: '',
494
email: '',
495
message: ''
496
});
497
const [submitted, setSubmitted] = useState(false);
498
499
const handleSubmit = (e: Event) => {
500
e.preventDefault();
501
setSubmitted(true);
502
};
503
504
const updateField = (field: string, value: string) => {
505
setFormData(prev => ({ ...prev, [field]: value }));
506
};
507
508
if (submitted) {
509
return createElement("div", { "data-testid": "success" }, "Form submitted!");
510
}
511
512
return createElement("form", { onSubmit: handleSubmit },
513
createElement("input", {
514
"data-testid": "name",
515
value: formData.name,
516
onChange: (e) => updateField('name', (e.target as HTMLInputElement).value),
517
placeholder: "Name"
518
}),
519
createElement("input", {
520
"data-testid": "email",
521
value: formData.email,
522
onChange: (e) => updateField('email', (e.target as HTMLInputElement).value),
523
placeholder: "Email"
524
}),
525
createElement("textarea", {
526
"data-testid": "message",
527
value: formData.message,
528
onChange: (e) => updateField('message', (e.target as HTMLTextAreaElement).value),
529
placeholder: "Message"
530
}),
531
createElement("button", {
532
type: "submit",
533
"data-testid": "submit"
534
}, "Submit")
535
);
536
}
537
538
act(() => {
539
renderer.render(createElement(ContactForm));
540
});
541
542
const nameInput = renderer.findByTestId('name') as HTMLInputElement;
543
const emailInput = renderer.findByTestId('email') as HTMLInputElement;
544
const messageInput = renderer.findByTestId('message') as HTMLTextAreaElement;
545
const submitButton = renderer.findByTestId('submit') as HTMLButtonElement;
546
547
// Fill form
548
act(() => {
549
nameInput.value = 'John Doe';
550
nameInput.dispatchEvent(new Event('input', { bubbles: true }));
551
552
emailInput.value = 'john@example.com';
553
emailInput.dispatchEvent(new Event('input', { bubbles: true }));
554
555
messageInput.value = 'Hello world';
556
messageInput.dispatchEvent(new Event('input', { bubbles: true }));
557
});
558
559
// Submit form
560
act(() => {
561
submitButton.click();
562
});
563
564
expect(renderer.findByTestId('success')).toBeTruthy();
565
566
renderer.cleanup();
567
}
568
```