nts that trigger immediate state updates, you must ensure React flushes those updates before asserting. Testing Library's renderHook does not automatically wrap direct calls to hook methods in act.
// useTaskQueue.ts
import { useState, useCallback } from 'react';
interface Task {
id: string;
status: 'pending' | 'completed';
}
export function useTaskQueue() {
const [tasks, setTasks] = useState<Task[]>([]);
const addTask = useCallback((id: string) => {
setTasks(prev => [...prev, { id, status: 'pending' }]);
}, []);
const completeTask = useCallback((id: string) => {
setTasks(prev =>
prev.map(task =>
task.id === id ? { ...task, status: 'completed' } : task
)
);
}, []);
return { tasks, addTask, completeTask };
}
// useTaskQueue.test.ts
import { renderHook, act } from '@testing-library/react';
import { useTaskQueue } from './useTaskQueue';
describe('useTaskQueue', () => {
it('processes state updates synchronously when wrapped in act', async () => {
const { result } = renderHook(() => useTaskQueue());
// Direct hook method calls bypass RTL's internal act wrapper
await act(async () => {
result.current.addTask('task-01');
result.current.completeTask('task-01');
});
// React has flushed the batched updates
expect(result.current.tasks).toHaveLength(1);
expect(result.current.tasks[0].status).toBe('completed');
});
});
Architecture Rationale: act accepts an async callback to accommodate hooks that trigger effects or microtasks. Awaiting act guarantees the React scheduler processes all queued updates before the test runner evaluates assertions. Without await, the test continues immediately, often catching the component in a partially rendered state.
2. Network Requests: The waitFor Boundary
Browser fetch calls operate on a separate networking thread. React's scheduler cannot intercept or fast-forward them. You must mock the network layer and use waitFor to poll for the resulting UI changes.
// DashboardMetrics.tsx
import { useEffect, useState } from 'react';
export function DashboardMetrics() {
const [metrics, setMetrics] = useState<{ active: number } | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch('/api/v1/metrics')
.then(res => res.json())
.then(data => setMetrics(data))
.catch(() => setError('Failed to load metrics'));
}, []);
if (error) return <p data-testid="error-msg">{error}</p>;
if (!metrics) return <p data-testid="loading">Loading metrics...</p>;
return <span data-testid="active-count">{metrics.active}</span>;
}
// DashboardMetrics.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { DashboardMetrics } from './DashboardMetrics';
describe('DashboardMetrics', () => {
beforeEach(() => {
global.fetch = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('renders loaded metrics after mocked fetch resolves', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
json: () => Promise.resolve({ active: 42 }),
});
render(<DashboardMetrics />);
// Initial render shows loading state
expect(screen.getByTestId('loading')).toBeInTheDocument();
// waitFor polls until the assertion passes or times out
await waitFor(() => {
expect(screen.getByTestId('active-count')).toHaveTextContent('42');
});
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
});
Architecture Rationale: waitFor executes the callback repeatedly with a default interval of 50ms. It catches assertion errors internally and retries until success. This aligns perfectly with network latency, where the exact resolution time is unpredictable. Mocking fetch isolates the test from network variability while preserving the component's promise chain.
3. Scheduled Timers: Combining act and Fake Clocks
Native setTimeout and setInterval are runtime APIs. React cannot control them, but test runners can hijack the system clock. When advancing fake timers, you must wrap the advancement in act because the timer callback triggers a React state update.
// SessionExpiry.tsx
import { useEffect, useState } from 'react';
export function SessionExpiry({ timeoutMs = 5000 }: { timeoutMs?: number }) {
const [remaining, setRemaining] = useState(timeoutMs);
useEffect(() => {
const timer = setInterval(() => {
setRemaining(prev => {
const next = prev - 1000;
return next < 0 ? 0 : next;
});
}, 1000);
return () => clearInterval(timer);
}, []);
return <time data-testid="timer">{remaining}ms</time>;
}
// SessionExpiry.test.tsx
import { render, screen, act } from '@testing-library/react';
import { SessionExpiry } from './SessionExpiry';
describe('SessionExpiry', () => {
it('updates countdown when fake timers advance', async () => {
vi.useFakeTimers();
render(<SessionExpiry timeoutMs={3000} />);
expect(screen.getByTestId('timer')).toHaveTextContent('3000ms');
// Fake timer advancement triggers React state updates
await act(async () => {
vi.advanceTimersByTime(2000);
});
expect(screen.getByTestId('timer')).toHaveTextContent('1000ms');
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(screen.getByTestId('timer')).toHaveTextContent('0ms');
vi.useRealTimers();
});
});
Architecture Rationale: vi.useFakeTimers() (or jest.useFakeTimers()) replaces the global clock. Advancing time synchronously executes pending callbacks, which in turn call setState. Since setState is a React update, it must be wrapped in act to flush before the next assertion. Omitting act here is a leading cause of the "not wrapped in act" warning in timer-heavy tests.
Pitfall Guide
| Pitfall | Explanation | Fix |
|---|
The act Overwrap | Wrapping render, fireEvent, or findBy* in act creates redundant nesting and can mask race conditions. | Rely on RTL's built-in wrappers. Only manually wrap direct hook calls, fake timer advances, or custom async utilities. |
| Sync Queries for Async Content | Using getByTestId or queryByTestId on network-driven UI throws immediately if the element isn't in the DOM yet. | Use findByTestId (which wraps waitFor) or explicitly wrap assertions in waitFor. |
Missing await on act | Calling act(() => ...) without await allows the test runner to proceed before React flushes updates. | Always await act(async () => { ... }) when triggering state changes or timer advances. |
| Mock Leakage Between Tests | Global mocks (global.fetch = vi.fn()) persist across test files if not cleaned up, causing cross-test pollution. | Use beforeEach to stub and afterEach with vi.restoreAllMocks() or vi.unstubAllGlobals(). |
Ignoring waitFor Timeouts in CI | Default waitFor timeout (1000ms) often fails in CI environments with slower CPU allocation or container overhead. | Configure { timeout: 3000, interval: 100 } in waitFor or set global RTL config for CI pipelines. |
| Testing Internal State Over Rendered Output | Asserting on result.current for components or checking private variables breaks encapsulation and fragilizes tests. | Query the DOM for components. Use result.current only for pure custom hooks where DOM output isn't relevant. |
| Mixing Real and Fake Timers | Calling vi.useRealTimers() inside a test that still has pending fake timer callbacks causes unpredictable execution order. | Isolate fake timer tests in dedicated describe blocks. Always restore real timers in afterEach. |
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Direct hook state change | await act(async () => { ... }) | Forces React scheduler to flush before assertion | Zero overhead; deterministic |
| Network-driven UI update | waitFor or findBy* | Polls DOM until promise resolves; handles variable latency | Slight CPU overhead from polling; prevents flakiness |
setTimeout/setInterval logic | Fake timers + await act | Hijacks runtime clock; act flushes resulting React updates | Requires test runner config; highly deterministic |
| Debounced input handler | userEvent.type + waitFor | userEvent wraps act; waitFor handles debounce delay | Minimal; aligns with real user behavior |
| WebSocket/streaming data | Mock stream + waitFor | External async boundary requires polling until state stabilizes | Moderate setup; isolates network variability |
Configuration Template
// vitest.setup.ts
import { configure } from '@testing-library/react';
// Increase waitFor timeout for CI/containerized environments
configure({
asyncUtilTimeout: 3000,
// Disable act warnings only if you've audited and confirmed safe async patterns
// throwOnUnexpectedActWarning: false,
});
// Global mock cleanup
import { afterEach } from 'vitest';
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
// Ensure fake timers work consistently across test suites
fakeTimers: {
loopLimit: 10000,
},
},
});
Quick Start Guide
- Initialize Test Environment: Install
@testing-library/react, @testing-library/jest-dom, and configure your test runner (Vitest/Jest) with jsdom and RTL setup files.
- Identify Async Boundaries: Map each test case to its async source. If it's React state/effects β
act. If it's network/timers β waitFor + mocks.
- Apply Synchronization Primitives: Replace sync queries with
findBy* for async content. Wrap direct hook calls and fake timer advances in await act(async () => { ... }).
- Configure Timeouts for CI: Adjust
asyncUtilTimeout in RTL config and waitFor options to accommodate slower CI runners. Default 1s is rarely sufficient.
- Validate Isolation: Run tests in random order (
--shuffle or --runInBand). Verify mocks are cleaned up in afterEach and fake timers are restored to prevent cross-test pollution.