β³ Lesson 9.4: Testing Async Code
Master the art of testing asynchronous operations in React applications. Learn to test data fetching, API calls, loading states, error handling, race conditions, and advanced async patterns with confidence.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Test components that fetch data with proper async handling
- Mock API calls effectively using Mock Service Worker (MSW)
- Test loading states, success states, and error states comprehensively
- Handle race conditions and concurrent requests in tests
- Test React Query and other data fetching libraries
- Test infinite scroll, pagination, and polling patterns
- Write reliable tests for complex async workflows
Estimated Time: 75-90 minutes
Project: Test a complete data fetching component with all async states
In This Lesson
π Understanding Async Testing
Asynchronous code is everywhere in modern React applications β data fetching, API calls, animations, timers, and more. Testing it is uniquely tricky because the thing you want to assert on doesn't exist yet. Picture ordering a coffee: you don't check for your latte the instant you hand over your card β you wait for the barista to call your name. Test the async version too eagerly and you'll "check the counter" while the machine is still brewing, see nothing, and wrongly conclude the order failed. The whole craft of async testing is learning to wait for the right signal instead of guessing.
π What is Async Testing?
Async testing means testing code whose result isn't available on the same tick it was requested. That covers network requests, setTimeout/setInterval, Promises, async/await, and anything that resolves later. The key skill is telling the test to pause until the UI reflects the finished operation β then assert.
The Challenge with Async Code
Consider this component that fetches user data. It starts in a loading state, then swaps in the real data once the network resolves:
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = React.useState<User | null>(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <div>Loading...</div>;
return <div>{user?.name}</div>;
}
Here's the trap. If we test this synchronously β asserting the moment after render β we're inspecting the component during that very first "Loading..." tick, before React has even kicked off the fetch, let alone received a response:
// β This test fails - doesn't wait for async operation
test('displays user name', () => {
render(<UserProfile userId="123" />);
// This fails - user data hasn't loaded yet!
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
The Async Testing Lifecycle
Three Approaches to Async Testing
React Testing Library provides three ways to handle async operations:
| Method | Use Case | Example |
|---|---|---|
findBy queries |
Waiting for elements to appear | await screen.findByText('Loaded') |
waitFor |
Waiting for assertions to pass | await waitFor(() => expect(...).toBe(...)) |
waitForElementToBeRemoved |
Waiting for elements to disappear | await waitForElementToBeRemoved(() => screen.getByText('Loading')) |
π‘ Key Async Testing Principles
- Always use async/await: Mark test functions as
async - Wait for changes: Use
findByorwaitFor - Mock external dependencies: Control API responses in tests
- Test all states: Loading, success, error, and empty states
- Be patient: Set appropriate timeouts for slow operations
π¬ Testing Philosophy: "Async tests should mirror realityβwait for operations to complete, handle errors gracefully, and verify the final state matches user expectations."
π Testing Basic Data Fetching
Let's start with the fundamentals of testing components that fetch data. Throughout this lesson we assume a modern 2026 stack: Vitest as the runner, React Testing Library for rendering and queries, user-event v14 for interactions, and MSW v2 for mocking the network. Every code sample here uses those current APIs.
Simple Data Fetching Component
interface Post {
id: string;
title: string;
body: string;
}
export function PostList() {
const [posts, setPosts] = React.useState<Post[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
fetch('/api/posts')
.then(res => {
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
})
.then(data => {
setPosts(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
if (loading) return <div>Loading posts...</div>;
if (error) return <div>Error: {error}</div>;
if (posts.length === 0) return <div>No posts found</div>;
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</li>
))}
</ul>
);
}
Testing with findBy
import { render, screen } from '@testing-library/react';
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { PostList } from './PostList';
// Set up an MSW v2 server. Note the shape: http.get(path, resolver),
// and the resolver returns an HttpResponse instead of calling res(ctx.*).
const server = setupServer(
http.get('/api/posts', () => {
return HttpResponse.json([
{ id: '1', title: 'First Post', body: 'Content 1' },
{ id: '2', title: 'Second Post', body: 'Content 2' }
]);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('PostList', () => {
it('displays posts after loading', async () => {
render(<PostList />);
// Initially shows loading (this assertion is synchronous and safe β
// the loading UI is present on the very first render).
expect(screen.getByText('Loading posts...')).toBeInTheDocument();
// findByText RETURNS A PROMISE and retries until the element appears
// (default up to 1000ms). Awaiting it is what makes the test wait.
expect(await screen.findByText('First Post')).toBeInTheDocument();
// By now the fetch has resolved, so a synchronous getByText is fine.
expect(screen.getByText('Second Post')).toBeInTheDocument();
// Loading should be gone
expect(screen.queryByText('Loading posts...')).not.toBeInTheDocument();
});
});
Trace what actually happens when that test runs. The request never leaves your machine β MSW intercepts it and answers from your handler β but from the component's point of view it's an ordinary fetch:
Testing with waitFor
import { waitFor } from '@testing-library/react';
test('displays posts using waitFor', async () => {
render(<PostList />);
// Wait for loading to finish and posts to appear
await waitFor(() => {
expect(screen.queryByText('Loading posts...')).not.toBeInTheDocument();
expect(screen.getByText('First Post')).toBeInTheDocument();
});
});
Testing Empty State
test('displays empty state when no posts', async () => {
// Override the default handler for THIS test only
server.use(
http.get('/api/posts', () => {
return HttpResponse.json([]);
})
);
render(<PostList />);
expect(await screen.findByText('No posts found')).toBeInTheDocument();
});
Testing with Different Data
test('displays correct number of posts', async () => {
server.use(
http.get('/api/posts', () => {
return HttpResponse.json([
{ id: '1', title: 'Post 1', body: 'Body 1' },
{ id: '2', title: 'Post 2', body: 'Body 2' },
{ id: '3', title: 'Post 3', body: 'Body 3' }
]);
})
);
render(<PostList />);
await waitFor(() => {
const posts = screen.getAllByRole('listitem');
expect(posts).toHaveLength(3);
});
});
β findBy vs waitFor - When to Use Which?
Use findBy when:
- Waiting for a single element to appear
- The test is simple and straightforward
- You want cleaner, more readable code
Use waitFor when:
- Making multiple assertions that need to pass together
- Waiting for something to disappear
- Complex conditions that can't be expressed with a single query
- Need custom timeout or retry intervals
π Mock Service Worker Deep Dive
Mock Service Worker (MSW) is the gold standard for mocking APIs in tests. Instead of replacing fetch with a stub, it intercepts requests at the network boundary β the same layer a real server would answer from. Your component runs its actual data-fetching code; MSW just supplies the response. Think of it as a stunt double that's indistinguishable from the real actor: the scene plays out normally, but nobody gets hurt (and no real API gets called).
β οΈ MSW v1 β v2: what changed
If you're following older tutorials, you'll see the v1 API everywhere. MSW v2 (the current release) reworked it around the web-standard Request/Response. Here's the translation you'll use constantly:
rest.get(...)βhttp.get(...)(import{ http, HttpResponse })(req, res, ctx) => res(ctx.json(data))β() => HttpResponse.json(data)ctx.status(201)β second arg:HttpResponse.json(data, { status: 201 })req.paramsβ the resolver's{ params }argumentreq.url.searchParamsβnew URL(request.url).searchParamsawait req.json()βawait request.json()res.networkError()βHttpResponse.error();ctx.delay(ms)βawait delay(ms)
Why MSW Over fetch Mocking?
| Approach | Pros | Cons |
|---|---|---|
| Mock fetch | Simple, no dependencies | Brittle, doesn't test network layer, hard to maintain |
| Mock axios/API client | Easier than mocking fetch | Tied to implementation, misses network issues |
| MSW | Realistic, reusable, works with any HTTP client | Extra dependency, slightly more setup |
Complete MSW Setup
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
// GET with a dynamic path segment β path params arrive in { params }
http.get('/api/users/:id', ({ params }) => {
const { id } = params;
return HttpResponse.json({
id,
name: `User ${id}`,
email: `user${id}@example.com`
});
}),
// POST β read the body off the standard Request object
http.post('/api/users', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
// Second arg to HttpResponse.json() sets status/headers
return HttpResponse.json(
{
id: crypto.randomUUID(),
...body,
createdAt: new Date().toISOString()
},
{ status: 201 }
);
}),
// PUT β you can destructure both params and request together
http.put('/api/users/:id', async ({ params, request }) => {
const { id } = params;
const body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({
id,
...body,
updatedAt: new Date().toISOString()
});
}),
// DELETE β a 204 carries no body, so build the response directly
http.delete('/api/users/:id', () => {
return new HttpResponse(null, { status: 204 });
}),
// Query params live on the intercepted Request's URL
http.get('/api/search', ({ request }) => {
const url = new URL(request.url);
const query = url.searchParams.get('q');
const page = url.searchParams.get('page') ?? '1';
return HttpResponse.json({
query,
page: Number(page),
results: [`Result for ${query}`]
});
})
];
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// src/test/setup.ts (registered via vitest.config's setupFiles)
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from '../mocks/server';
// Start intercepting before any test runs. 'error' surfaces any request
// you forgot to mock, instead of letting it hit the real network.
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// Reset per-test overrides so tests stay isolated
afterEach(() => server.resetHandlers());
// Stop intercepting once the suite is done
afterAll(() => server.close());
Simulating Different Response Times
import { http, HttpResponse, delay } from 'msw';
test('handles slow API responses', async () => {
server.use(
http.get('/api/posts', async () => {
// delay() is a standalone helper in MSW v2 (no more ctx.delay)
await delay(2000);
return HttpResponse.json([{ id: '1', title: 'Post' }]);
})
);
render(<PostList />);
// Should show loading for a while
expect(screen.getByText('Loading posts...')).toBeInTheDocument();
// findBy's third arg lets us extend the retry window past the 1s default
expect(await screen.findByText('Post', {}, { timeout: 3000 })).toBeInTheDocument();
});
Simulating Network Errors
test('handles network errors', async () => {
server.use(
http.get('/api/posts', () => {
// Simulates a connection failure (DNS error, offline, etc.)
return HttpResponse.error();
})
);
render(<PostList />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
Testing Different Status Codes
test('handles 404 not found', async () => {
server.use(
http.get('/api/posts', () => {
return HttpResponse.json({ message: 'Not found' }, { status: 404 });
})
);
render(<PostList />);
expect(await screen.findByText('Error: Failed to fetch')).toBeInTheDocument();
});
test('handles 500 server error', async () => {
server.use(
http.get('/api/posts', () => {
return HttpResponse.json({ message: 'Internal server error' }, { status: 500 });
})
);
render(<PostList />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
Per-Test Handler Overrides
describe('PostList with different scenarios', () => {
it('shows success state', async () => {
// Uses the default handler registered in setup
render(<PostList />);
expect(await screen.findByText('First Post')).toBeInTheDocument();
});
it('shows error state', async () => {
// server.use() adds a one-off override; afterEach resetHandlers clears it
server.use(
http.get('/api/posts', () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<PostList />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
it('shows empty state', async () => {
server.use(
http.get('/api/posts', () => {
return HttpResponse.json([]);
})
);
render(<PostList />);
expect(await screen.findByText('No posts found')).toBeInTheDocument();
});
});
π‘ MSW Best Practices
- Create realistic responses: Mirror your actual API structure
- Use request handlers for each endpoint: Keep handlers organized
- Reset handlers after each test: Prevents test pollution
- Handle all HTTP methods: GET, POST, PUT, DELETE, PATCH
- Test various response scenarios: Success, errors, timeouts
- Use delay() sparingly: A short delay proves the loading state renders; long ones just slow your suite
β³ Testing Loading States
Loading states are crucial for user experience. Let's ensure they work correctly in all scenarios.
Testing Sequential Loading States
function DataComponent() {
const [data, setData] = React.useState(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
setLoading(true);
fetch('/api/data')
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false);
});
}, []);
return (
<div>
{loading && <div data-testid="spinner">Loading...</div>}
{!loading && data && <div>{data.value}</div>}
</div>
);
}
test('shows loading spinner then data', async () => {
server.use(
http.get('/api/data', async () => {
await delay(100); // Simulate network latency
return HttpResponse.json({ value: 'Loaded!' });
})
);
render(<DataComponent />);
// Should show loading initially
expect(screen.getByTestId('spinner')).toBeInTheDocument();
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Wait for data to load
await waitFor(() => {
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
});
expect(screen.getByText('Loaded!')).toBeInTheDocument();
});
Testing Multiple Loading Indicators
function MultiStepLoader() {
const [step, setStep] = React.useState<'idle' | 'fetching' | 'processing' | 'done'>('idle');
const handleLoad = async () => {
setStep('fetching');
await new Promise(resolve => setTimeout(resolve, 100));
setStep('processing');
await new Promise(resolve => setTimeout(resolve, 100));
setStep('done');
};
return (
<div>
<button onClick={handleLoad}>Load Data</button>
{step === 'fetching' && <div>Fetching data...</div>}
{step === 'processing' && <div>Processing data...</div>}
{step === 'done' && <div>Complete!</div>}
</div>
);
}
test('shows correct loading message at each step', async () => {
const user = userEvent.setup();
render(<MultiStepLoader />);
await user.click(screen.getByRole('button', { name: 'Load Data' }));
// Step 1: Fetching
expect(screen.getByText('Fetching data...')).toBeInTheDocument();
// Step 2: Processing
await waitFor(() => {
expect(screen.getByText('Processing data...')).toBeInTheDocument();
});
// Step 3: Complete
await waitFor(() => {
expect(screen.getByText('Complete!')).toBeInTheDocument();
});
});
Testing Skeleton Screens
function UserCard({ userId }: { userId: string }) {
const [user, setUser] = React.useState(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) {
return (
<div data-testid="skeleton">
<div className="skeleton-avatar" />
<div className="skeleton-line" />
<div className="skeleton-line" />
</div>
);
}
return (
<div>
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
test('shows skeleton screen while loading', async () => {
server.use(
http.get('/api/users/:id', async () => {
await delay(100);
return HttpResponse.json({
name: 'John Doe',
email: 'john@example.com',
avatar: '/avatar.jpg'
});
})
);
render(<UserCard userId="123" />);
// Skeleton should be visible
const skeleton = screen.getByTestId('skeleton');
expect(skeleton).toBeInTheDocument();
expect(skeleton.querySelector('.skeleton-avatar')).toBeInTheDocument();
// Wait for actual content
await waitFor(() => {
expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument();
});
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
Testing Progress Indicators
function FileUploadProgress() {
const [progress, setProgress] = React.useState(0);
const [status, setStatus] = React.useState<'idle' | 'uploading' | 'complete'>('idle');
const handleUpload = () => {
setStatus('uploading');
// Simulate progress
const interval = setInterval(() => {
setProgress(prev => {
if (prev >= 100) {
clearInterval(interval);
setStatus('complete');
return 100;
}
return prev + 10;
});
}, 100);
};
return (
<div>
<button onClick={handleUpload} disabled={status === 'uploading'}>
Upload
</button>
{status === 'uploading' && (
<div>
<div>Uploading... {progress}%</div>
<progress value={progress} max={100} />
</div>
)}
{status === 'complete' && <div>Upload complete!</div>}
</div>
);
}
test('shows upload progress', async () => {
vi.useFakeTimers();
// With fake timers, tell user-event to drive them so its own waits resolve
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<FileUploadProgress />);
await user.click(screen.getByRole('button', { name: 'Upload' }));
// Should start uploading
expect(screen.getByText('Uploading... 0%')).toBeInTheDocument();
// advanceTimersByTimeAsync flushes the state updates each interval triggers,
// so React re-renders inside act() automatically β no manual act() needed
// and no "not wrapped in act(...)" warning.
await vi.advanceTimersByTimeAsync(500);
expect(screen.getByText('Uploading... 50%')).toBeInTheDocument();
// Complete upload
await vi.advanceTimersByTimeAsync(500);
expect(screen.getByText('Upload complete!')).toBeInTheDocument();
vi.useRealTimers();
});
β Loading State Testing Checklist
- β Test that loading indicator appears immediately
- β Test that loading indicator disappears after data loads
- β Test multiple sequential loading states
- β Test that buttons are disabled during loading
- β Test progress indicators update correctly
- β Test skeleton screens render proper structure
β Testing Error Handling
Robust applications handle errors gracefully. Let's ensure your error handling works as expected.
Testing Network Errors
function RobustDataFetcher() {
const [data, setData] = React.useState(null);
const [error, setError] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(false);
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/data');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
setData(json);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
return (
<div>
<button onClick={fetchData} disabled={loading}>
Fetch Data
</button>
{loading && <div>Loading...</div>}
{error && <div role="alert">Error: {error}</div>}
{data && <div>Data: {JSON.stringify(data)}</div>}
</div>
);
}
test('handles network error', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/data', () => {
return HttpResponse.error();
})
);
render(<RobustDataFetcher />);
await user.click(screen.getByRole('button', { name: 'Fetch Data' }));
expect(await screen.findByRole('alert')).toHaveTextContent(/error/i);
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
test('handles 404 error', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/data', () => {
return HttpResponse.text('Not Found', { status: 404 });
})
);
render(<RobustDataFetcher />);
await user.click(screen.getByRole('button', { name: 'Fetch Data' }));
expect(await screen.findByRole('alert')).toHaveTextContent(/HTTP 404/i);
});
test('handles 500 error', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/data', () => {
return HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 });
})
);
render(<RobustDataFetcher />);
await user.click(screen.getByRole('button', { name: 'Fetch Data' }));
expect(await screen.findByRole('alert')).toHaveTextContent(/HTTP 500/i);
});
Testing Retry Logic
function DataFetcherWithRetry() {
const [data, setData] = React.useState(null);
const [error, setError] = React.useState(null);
const [retryCount, setRetryCount] = React.useState(0);
const maxRetries = 3;
const fetchData = async () => {
try {
const response = await fetch('/api/data');
if (!response.ok) throw new Error('Failed');
const json = await response.json();
setData(json);
setRetryCount(0);
} catch (err) {
if (retryCount < maxRetries) {
setRetryCount(prev => prev + 1);
setTimeout(fetchData, 1000);
} else {
setError('Failed after 3 retries');
}
}
};
React.useEffect(() => {
fetchData();
}, []);
if (error) return <div>Error: {error}</div>;
if (!data) return <div>Loading... (Attempt {retryCount + 1})</div>;
return <div>Data: {data.value}</div>;
}
test('retries on failure and eventually succeeds', async () => {
let attempts = 0;
server.use(
http.get('/api/data', () => {
attempts++;
if (attempts < 3) {
return new HttpResponse(null, { status: 500 });
}
return HttpResponse.json({ value: 'Success!' });
})
);
vi.useFakeTimers();
render(<DataFetcherWithRetry />);
// First attempt fails
expect(screen.getByText('Loading... (Attempt 1)')).toBeInTheDocument();
// Advance past the 1s retry timer; the async variant also flushes the
// fetch promises MSW resolves, so React updates without an act() warning
await vi.advanceTimersByTimeAsync(1000);
expect(screen.getByText('Loading... (Attempt 2)')).toBeInTheDocument();
// Second retry succeeds
await vi.advanceTimersByTimeAsync(1000);
expect(screen.getByText('Data: Success!')).toBeInTheDocument();
vi.useRealTimers();
});
test('gives up after max retries', async () => {
server.use(
http.get('/api/data', () => {
return new HttpResponse(null, { status: 500 });
})
);
vi.useFakeTimers();
render(<DataFetcherWithRetry />);
// Fast-forward through all three retry windows at once
await vi.advanceTimersByTimeAsync(4000);
expect(screen.getByText('Error: Failed after 3 retries')).toBeInTheDocument();
vi.useRealTimers();
});
Testing Error Recovery
function ErrorRecoveryComponent() {
const [data, setData] = React.useState(null);
const [error, setError] = React.useState(null);
const fetchData = async () => {
setError(null);
try {
const response = await fetch('/api/data');
if (!response.ok) throw new Error('Failed to fetch');
const json = await response.json();
setData(json);
} catch (err) {
setError(err.message);
}
};
React.useEffect(() => {
fetchData();
}, []);
if (error) {
return (
<div>
<div>Error: {error}</div>
<button onClick={fetchData}>Try Again</button>
</div>
);
}
if (!data) return <div>Loading...</div>;
return <div>Data: {data.value}</div>;
}
test('allows user to retry after error', async () => {
const user = userEvent.setup();
// First request fails
server.use(
http.get('/api/data', () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<ErrorRecoveryComponent />);
// Wait for error
expect(await screen.findByText(/error/i)).toBeInTheDocument();
// Fix the API β the newest server.use() handler wins on the next request
server.use(
http.get('/api/data', () => {
return HttpResponse.json({ value: 'Success!' });
})
);
// Click retry
await user.click(screen.getByRole('button', { name: 'Try Again' }));
// Should succeed now
expect(await screen.findByText('Data: Success!')).toBeInTheDocument();
});
β οΈ Error Testing Best Practices
- Test all error types: Network, HTTP status codes, timeout, parsing errors
- Test error messages: Ensure they're user-friendly
- Test error recovery: Can users retry? Does retry work?
- Test error boundaries: Do errors crash the app or get caught?
- Test cleanup: Are errors cleared on retry?
π Testing Race Conditions
Race conditions occur when the order of async operations matters. Let's test that your components handle them correctly.
Testing Rapid Requests
function SearchComponent() {
const [query, setQuery] = React.useState('');
const [results, setResults] = React.useState<string[]>([]);
React.useEffect(() => {
if (!query) {
setResults([]);
return;
}
let cancelled = false;
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => {
if (!cancelled) {
setResults(data.results);
}
});
return () => {
cancelled = true;
};
}, [query]);
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<ul>
{results.map((result, i) => (
<li key={i}>{result}</li>
))}
</ul>
</div>
);
}
test('handles rapid search queries correctly', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/search', async ({ request }) => {
const query = new URL(request.url).searchParams.get('q');
// Simulate different response times so the slow one resolves LAST
const responseDelay = query === 'first' ? 200 : 50;
await delay(responseDelay);
return HttpResponse.json({
results: [`Result for ${query}`]
});
})
);
render(<SearchComponent />);
const input = screen.getByPlaceholderText('Search...');
// Type "first" - slow response
await user.type(input, 'first');
// Immediately change to "second" - fast response
await user.clear(input);
await user.type(input, 'second');
// Should show results for "second", not "first"
await waitFor(() => {
expect(screen.getByText('Result for second')).toBeInTheDocument();
});
expect(screen.queryByText('Result for first')).not.toBeInTheDocument();
});
Testing Request Cancellation
function DataFetcherWithAbort() {
const [data, setData] = React.useState(null);
const [loading, setLoading] = React.useState(false);
const abortControllerRef = React.useRef<AbortController | null>(null);
const fetchData = async (id: string) => {
// Cancel previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
setLoading(true);
try {
const response = await fetch(`/api/data/${id}`, {
signal: abortControllerRef.current.signal
});
const json = await response.json();
setData(json);
} catch (err) {
if (err.name !== 'AbortError') {
console.error(err);
}
} finally {
setLoading(false);
}
};
return (
<div>
<button onClick={() => fetchData('1')}>Fetch 1</button>
<button onClick={() => fetchData('2')}>Fetch 2</button>
{loading && <div>Loading...</div>}
{data && <div>Data: {data.id}</div>}
</div>
);
}
test('cancels previous request when new one starts', async () => {
const user = userEvent.setup();
let request1Completed = false;
let request2Completed = false;
server.use(
http.get('/api/data/1', async () => {
await delay(200);
request1Completed = true;
return HttpResponse.json({ id: '1' });
}),
http.get('/api/data/2', async () => {
await delay(50);
request2Completed = true;
return HttpResponse.json({ id: '2' });
})
);
render(<DataFetcherWithAbort />);
// Start first request
await user.click(screen.getByRole('button', { name: 'Fetch 1' }));
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Immediately start second request (should cancel first)
await user.click(screen.getByRole('button', { name: 'Fetch 2' }));
// Wait for second request to complete
await waitFor(() => {
expect(screen.getByText('Data: 2')).toBeInTheDocument();
});
// Second request should have completed
expect(request2Completed).toBe(true);
// First request may or may not have completed (depends on abort timing)
// But we should only see data from request 2
expect(screen.queryByText('Data: 1')).not.toBeInTheDocument();
});
Testing Dependent Requests
function UserWithPosts({ userId }: { userId: string }) {
const [user, setUser] = React.useState(null);
const [posts, setPosts] = React.useState([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
setLoading(true);
// Fetch user first
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(userData => {
setUser(userData);
// Then fetch their posts
return fetch(`/api/users/${userId}/posts`);
})
.then(res => res.json())
.then(postsData => {
setPosts(postsData);
setLoading(false);
});
}, [userId]);
if (loading) return <div>Loading...</div>;
return (
<div>
<h2>{user.name}</h2>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
test('fetches user then posts in sequence', async () => {
const userFetchOrder: string[] = [];
server.use(
http.get('/api/users/:id', async () => {
userFetchOrder.push('user');
await delay(50);
return HttpResponse.json({ name: 'John Doe' });
}),
http.get('/api/users/:id/posts', async () => {
userFetchOrder.push('posts');
await delay(50);
return HttpResponse.json([
{ id: '1', title: 'First Post' },
{ id: '2', title: 'Second Post' }
]);
})
);
render(<UserWithPosts userId="123" />);
// Wait for both requests to complete
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.getByText('First Post')).toBeInTheDocument();
expect(screen.getByText('Second Post')).toBeInTheDocument();
// Verify requests happened in correct order
expect(userFetchOrder).toEqual(['user', 'posts']);
});
π‘ Race Condition Testing Tips
- Use cleanup functions: Test that effects cancel previous requests
- Test rapid changes: Simulate users typing fast or clicking rapidly
- Verify final state: Ensure the latest request wins
- Test request order: For dependent requests, verify sequence
- Use AbortController: Test proper request cancellation
π Testing React Query
React Query (TanStack Query) is a popular data fetching library. Testing it requires understanding its query cache and hooks.
Basic React Query Component
import { useQuery } from '@tanstack/react-query';
interface User {
id: string;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json())
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading user</div>;
return (
<div>
<h2>{data.name}</h2>
<p>{data.email}</p>
</div>
);
}
Setting Up Query Client for Tests
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
// Create a test wrapper with QueryClient
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false, // Failed queries retry 3x by default β off for fast, deterministic tests
gcTime: 0, // TanStack Query v5 renamed cacheTime -> gcTime; 0 = no cross-test caching
},
},
});
}
function renderWithQueryClient(ui: React.ReactElement) {
const testQueryClient = createTestQueryClient();
return render(
<QueryClientProvider client={testQueryClient}>
{ui}
</QueryClientProvider>
);
}
Testing React Query Success State
test('displays user data from React Query', async () => {
server.use(
http.get('/api/users/123', () => {
return HttpResponse.json({
id: '123',
name: 'John Doe',
email: 'john@example.com'
});
})
);
renderWithQueryClient(<UserProfile userId="123" />);
// Shows loading initially
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Shows data after loading
expect(await screen.findByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
Testing React Query Error State
test('handles React Query errors', async () => {
server.use(
http.get('/api/users/123', () => {
return new HttpResponse(null, { status: 500 });
})
);
renderWithQueryClient(<UserProfile userId="123" />);
expect(await screen.findByText('Error loading user')).toBeInTheDocument();
});
Testing Mutations
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateUserForm() {
const queryClient = useQueryClient();
const [name, setName] = React.useState('');
const mutation = useMutation({
mutationFn: (newUser: { name: string }) =>
fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newUser)
}).then(res => res.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
}
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate({ name });
};
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={e => setName(e.target.value)}
placeholder="User name"
/>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create User'}
</button>
{mutation.isError && <div>Error creating user</div>}
{mutation.isSuccess && <div>User created!</div>}
</form>
);
}
test('creates user with mutation', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/users', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({
id: '123',
...body
});
})
);
renderWithQueryClient(<CreateUserForm />);
const input = screen.getByPlaceholderText('User name');
const button = screen.getByRole('button');
await user.type(input, 'Jane Doe');
await user.click(button);
// Button shows loading state
expect(screen.getByText('Creating...')).toBeInTheDocument();
// Success message appears
expect(await screen.findByText('User created!')).toBeInTheDocument();
});
Testing Query Invalidation
test('invalidates queries after mutation', async () => {
const user = userEvent.setup();
let queryCount = 0;
server.use(
http.get('/api/users', () => {
queryCount++;
return HttpResponse.json([{ id: '1', name: `User ${queryCount}` }]);
}),
http.post('/api/users', () => {
return HttpResponse.json({ id: '2', name: 'New User' });
})
);
renderWithQueryClient(
<>
<UserList />
<CreateUserForm />
</>
);
// Initial query
expect(await screen.findByText('User 1')).toBeInTheDocument();
expect(queryCount).toBe(1);
// Create new user
await user.type(screen.getByPlaceholderText('User name'), 'New User');
await user.click(screen.getByText('Create User'));
// Query should be invalidated and refetched
await waitFor(() => {
expect(queryCount).toBe(2);
});
});
Testing with Prefilled Cache
test('uses cached data', async () => {
const testQueryClient = createTestQueryClient();
// Prefill cache
testQueryClient.setQueryData(['user', '123'], {
id: '123',
name: 'Cached User',
email: 'cached@example.com'
});
render(
<QueryClientProvider client={testQueryClient}>
<UserProfile userId="123" />
</QueryClientProvider>
);
// Should immediately show cached data without loading
expect(screen.getByText('Cached User')).toBeInTheDocument();
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
β React Query Testing Tips
- Disable retries: Set
retry: falsein test config - Clear cache: Use
gcTime: 0(the v5 name forcacheTime) to prevent test pollution - Create fresh clients: New QueryClient for each test
- Test invalidation: Verify cache is refreshed after mutations
- Use MSW for API: Mock at network level, not Query functions
- Test all states: idle, loading, success, error
π Advanced Async Patterns
Let's explore testing more complex async scenarios like infinite scroll, pagination, polling, and debouncing.
Testing Infinite Scroll
function InfinitePostList() {
const [posts, setPosts] = React.useState<Post[]>([]);
const [page, setPage] = React.useState(1);
const [hasMore, setHasMore] = React.useState(true);
const [loading, setLoading] = React.useState(false);
const loadMore = React.useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
const response = await fetch(`/api/posts?page=${page}`);
const data = await response.json();
setPosts(prev => [...prev, ...data.posts]);
setHasMore(data.hasMore);
setPage(prev => prev + 1);
setLoading(false);
}, [page, loading, hasMore]);
React.useEffect(() => {
loadMore();
}, []);
return (
<div>
{posts.map(post => (
<article key={post.id}>{post.title}</article>
))}
{hasMore && (
<button onClick={loadMore} disabled={loading}>
{loading ? 'Loading...' : 'Load More'}
</button>
)}
{!hasMore && <p>No more posts</p>}
</div>
);
}
test('loads more posts on button click', async () => {
const user = userEvent.setup();
let requestCount = 0;
server.use(
http.get('/api/posts', ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? '1';
requestCount++;
return HttpResponse.json({
posts: [
{ id: `${page}-1`, title: `Post ${page}-1` },
{ id: `${page}-2`, title: `Post ${page}-2` }
],
hasMore: Number(page) < 3
});
})
);
render(<InfinitePostList />);
// First page loads automatically
expect(await screen.findByText('Post 1-1')).toBeInTheDocument();
expect(screen.getByText('Post 1-2')).toBeInTheDocument();
expect(requestCount).toBe(1);
// Click load more
const loadMoreButton = screen.getByText('Load More');
await user.click(loadMoreButton);
// Second page appears
expect(await screen.findByText('Post 2-1')).toBeInTheDocument();
expect(screen.getByText('Post 2-2')).toBeInTheDocument();
expect(requestCount).toBe(2);
// Still has more
expect(screen.getByText('Load More')).toBeInTheDocument();
// Load final page
await user.click(screen.getByText('Load More'));
expect(await screen.findByText('Post 3-1')).toBeInTheDocument();
expect(requestCount).toBe(3);
// No more posts
expect(screen.queryByText('Load More')).not.toBeInTheDocument();
expect(screen.getByText('No more posts')).toBeInTheDocument();
});
Testing Pagination
function PaginatedList() {
const [page, setPage] = React.useState(1);
const [data, setData] = React.useState<any>(null);
React.useEffect(() => {
fetch(`/api/posts?page=${page}`)
.then(res => res.json())
.then(setData);
}, [page]);
if (!data) return <div>Loading...</div>;
return (
<div>
{data.posts.map((post: Post) => (
<div key={post.id}>{post.title}</div>
))}
<button
onClick={() => setPage(p => p - 1)}
disabled={page === 1}
>
Previous
</button>
<span>Page {page} of {data.totalPages}</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={page === data.totalPages}
>
Next
</button>
</div>
);
}
test('navigates through pages', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/posts', ({ request }) => {
const page = Number(new URL(request.url).searchParams.get('page') ?? '1');
return HttpResponse.json({
posts: [
{ id: `${page}-1`, title: `Page ${page} Post 1` }
],
totalPages: 3
});
})
);
render(<PaginatedList />);
// First page
expect(await screen.findByText('Page 1 Post 1')).toBeInTheDocument();
expect(screen.getByText('Page 1 of 3')).toBeInTheDocument();
// Previous button disabled on first page
expect(screen.getByText('Previous')).toBeDisabled();
// Go to next page
await user.click(screen.getByText('Next'));
expect(await screen.findByText('Page 2 Post 1')).toBeInTheDocument();
expect(screen.getByText('Page 2 of 3')).toBeInTheDocument();
// Both buttons enabled
expect(screen.getByText('Previous')).not.toBeDisabled();
expect(screen.getByText('Next')).not.toBeDisabled();
// Go to last page
await user.click(screen.getByText('Next'));
expect(await screen.findByText('Page 3 Post 1')).toBeInTheDocument();
expect(screen.getByText('Next')).toBeDisabled();
});
Testing Debounced Search
import { debounce } from 'lodash';
function SearchComponent() {
const [query, setQuery] = React.useState('');
const [results, setResults] = React.useState<string[]>([]);
const [searching, setSearching] = React.useState(false);
const searchApi = React.useMemo(
() => debounce(async (searchQuery: string) => {
if (!searchQuery) {
setResults([]);
return;
}
setSearching(true);
const response = await fetch(`/api/search?q=${searchQuery}`);
const data = await response.json();
setResults(data.results);
setSearching(false);
}, 300),
[]
);
React.useEffect(() => {
searchApi(query);
}, [query, searchApi]);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
{searching && <div>Searching...</div>}
<ul>
{results.map((result, i) => (
<li key={i}>{result}</li>
))}
</ul>
</div>
);
}
test('debounces search requests', async () => {
const user = userEvent.setup();
let requestCount = 0;
server.use(
http.get('/api/search', ({ request }) => {
requestCount++;
const query = new URL(request.url).searchParams.get('q');
return HttpResponse.json({
results: [`Result for ${query}`]
});
})
);
render(<SearchComponent />);
const input = screen.getByPlaceholderText('Search...');
// Type quickly - should only trigger one request after debounce
await user.type(input, 'react');
// Wait for debounce and search
expect(await screen.findByText('Result for react')).toBeInTheDocument();
// Should have made only one request despite typing 5 characters
expect(requestCount).toBe(1);
});
test('cancels previous search when typing continues', async () => {
const user = userEvent.setup();
const searches: string[] = [];
server.use(
http.get('/api/search', async ({ request }) => {
const query = new URL(request.url).searchParams.get('q');
searches.push(query || '');
// Simulate slow search
await delay(100);
return HttpResponse.json({
results: [`Result for ${query}`]
});
})
);
render(<SearchComponent />);
const input = screen.getByPlaceholderText('Search...');
// Type "re", wait, then type "act"
await user.type(input, 're');
await new Promise(resolve => setTimeout(resolve, 350));
await user.type(input, 'act');
// Should show final results
expect(await screen.findByText('Result for react')).toBeInTheDocument();
// Should have made two requests (after "re" and after "react")
expect(searches.length).toBe(2);
});
Testing Polling
function LiveCounter() {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
const interval = setInterval(() => {
fetch('/api/counter')
.then(res => res.json())
.then(data => setCount(data.count));
}, 1000);
return () => clearInterval(interval);
}, []);
return <div>Count: {count}</div>;
}
test('polls for updates', async () => {
vi.useFakeTimers();
let currentCount = 0;
server.use(
http.get('/api/counter', () => {
currentCount++;
return HttpResponse.json({ count: currentCount });
})
);
render(<LiveCounter />);
// Initial render
expect(screen.getByText('Count: 0')).toBeInTheDocument();
// Each async advance fires the interval AND flushes the fetch it triggers,
// so the DOM is up to date by the time we assert (no findBy needed).
await vi.advanceTimersByTimeAsync(1000);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
await vi.advanceTimersByTimeAsync(1000);
expect(screen.getByText('Count: 2')).toBeInTheDocument();
await vi.advanceTimersByTimeAsync(1000);
expect(screen.getByText('Count: 3')).toBeInTheDocument();
vi.useRealTimers();
});
Testing Optimistic Updates
function TodoList() {
const [todos, setTodos] = React.useState<Todo[]>([]);
const toggleTodo = async (id: string) => {
// Optimistic update
setTodos(prev =>
prev.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
try {
await fetch(`/api/todos/${id}/toggle`, { method: 'POST' });
} catch (error) {
// Rollback on error
setTodos(prev =>
prev.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
}
};
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
{todo.title}
</li>
))}
</ul>
);
}
test('updates UI optimistically', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/todos/:id/toggle', async () => {
// Simulate slow network
await delay(500);
return new HttpResponse(null, { status: 200 });
})
);
// Start with initial todos
render(<TodoList />);
// ... set up initial state
const checkbox = screen.getByRole('checkbox');
// Should be unchecked
expect(checkbox).not.toBeChecked();
// Click checkbox
await user.click(checkbox);
// Should be checked immediately (optimistic)
expect(checkbox).toBeChecked();
// Wait for API call to complete
await waitFor(() => {
// Still checked after API confirms
expect(checkbox).toBeChecked();
});
});
test('rolls back on error', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/todos/:id/toggle', () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<TodoList />);
// ... set up initial state
const checkbox = screen.getByRole('checkbox');
// Click checkbox
await user.click(checkbox);
// Immediately checked
expect(checkbox).toBeChecked();
// Should roll back after error
await waitFor(() => {
expect(checkbox).not.toBeChecked();
});
});
π‘ Advanced Pattern Testing Tips
- Use fake timers: Control debouncing and polling with
vi.useFakeTimers() - Test incremental loading: Verify each page/batch loads correctly
- Test optimistic updates: Check immediate UI changes and rollbacks
- Count API calls: Ensure debouncing and caching work correctly
- Test edge cases: Empty results, last page, errors during pagination
- Test cleanup: Verify intervals and listeners are properly cleaned up
ποΈ Hands-on Exercises
Practice your async testing skills with these exercises. Each builds on the concepts we've covered.
Exercise 1: Test a Profile Loader Component
Task: Create comprehensive tests for a user profile component that fetches data from an API.
Component to test:
function UserProfileLoader({ userId }: { userId: string }) {
const [profile, setProfile] = React.useState<Profile | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch(`/api/users/${userId}/profile`)
.then(res => {
if (!res.ok) throw new Error('Failed to load profile');
return res.json();
})
.then(data => {
if (!cancelled) {
setProfile(data);
setLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError(err.message);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [userId]);
if (loading) return <div>Loading profile...</div>;
if (error) return <div>Error: {error}</div>;
if (!profile) return <div>No profile found</div>;
return (
<div>
<h2>{profile.name}</h2>
<p>{profile.bio}</p>
<p>Followers: {profile.followers}</p>
</div>
);
}
Requirements:
- Test successful profile loading
- Test loading state display
- Test error handling
- Test that changing userId fetches new data
- Test cleanup (cancelled requests don't update state)
π‘ Hint
Use MSW to mock the API endpoint. Use rerender from React Testing Library to test userId changes. For testing cleanup, unmount the component and verify state doesn't update.
β Solution
describe('UserProfileLoader', () => {
it('displays profile after loading', async () => {
server.use(
http.get('/api/users/123/profile', () => {
return HttpResponse.json({
name: 'Jane Doe',
bio: 'Software Developer',
followers: 1500
});
})
);
render(<UserProfileLoader userId="123" />);
expect(screen.getByText('Loading profile...')).toBeInTheDocument();
expect(await screen.findByText('Jane Doe')).toBeInTheDocument();
expect(screen.getByText('Software Developer')).toBeInTheDocument();
expect(screen.getByText('Followers: 1500')).toBeInTheDocument();
});
it('handles errors', async () => {
server.use(
http.get('/api/users/123/profile', () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<UserProfileLoader userId="123" />);
expect(
await screen.findByText('Error: Failed to load profile')
).toBeInTheDocument();
});
it('refetches when userId changes', async () => {
server.use(
http.get('/api/users/:id/profile', ({ params }) => {
const { id } = params;
return HttpResponse.json({
name: `User ${id}`,
bio: `Bio ${id}`,
followers: 100
});
})
);
const { rerender } = render(<UserProfileLoader userId="123" />);
expect(await screen.findByText('User 123')).toBeInTheDocument();
rerender(<UserProfileLoader userId="456" />);
expect(await screen.findByText('User 456')).toBeInTheDocument();
});
it('cancels requests on unmount', async () => {
const consoleError = vi.spyOn(console, 'error');
server.use(
http.get('/api/users/123/profile', async () => {
await delay(100);
return HttpResponse.json({ name: 'Test' });
})
);
const { unmount } = render(<UserProfileLoader userId="123" />);
// Unmount before request completes
unmount();
// Wait for request to complete
await new Promise(resolve => setTimeout(resolve, 150));
// Should not have errors from state updates after unmount
expect(consoleError).not.toHaveBeenCalled();
consoleError.mockRestore();
});
});
Exercise 2: Test Auto-Save Functionality
Task: Test a text editor with auto-save that debounces saves.
Component:
function AutoSaveEditor({ documentId }: { documentId: string }) {
const [content, setContent] = React.useState('');
const [saving, setSaving] = React.useState(false);
const [lastSaved, setLastSaved] = React.useState<Date | null>(null);
const saveDocument = React.useMemo(
() => debounce(async (text: string) => {
setSaving(true);
await fetch(`/api/documents/${documentId}`, {
method: 'PUT',
body: JSON.stringify({ content: text })
});
setSaving(false);
setLastSaved(new Date());
}, 500),
[documentId]
);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newContent = e.target.value;
setContent(newContent);
saveDocument(newContent);
};
return (
<div>
<textarea value={content} onChange={handleChange} />
{saving && <span>Saving...</span>}
{lastSaved && <span>Saved at {lastSaved.toLocaleTimeString()}</span>}
</div>
);
}
Requirements:
- Test that typing triggers a save after 500ms
- Test that multiple rapid changes only trigger one save
- Test the "Saving..." indicator appears and disappears
- Test that saved timestamp updates
- Test error handling during save
π‘ Hint
Use fake timers to control debouncing. Use vi.advanceTimersByTime() to move time forward. Remember to restore real timers after tests.
β Solution
describe('AutoSaveEditor', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('saves after 500ms of inactivity', async () => {
// With fake timers, hand user-event the advance fn so its keystroke
// waits resolve against the same fake clock
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
let saveCount = 0;
server.use(
http.put('/api/documents/doc1', () => {
saveCount++;
return new HttpResponse(null, { status: 200 });
})
);
render(<AutoSaveEditor documentId="doc1" />);
const textarea = screen.getByRole('textbox');
await user.type(textarea, 'Hello');
// Should not save immediately β the debounce hasn't elapsed
expect(saveCount).toBe(0);
// Cross the 500ms debounce; the async advance flushes the PUT too
await vi.advanceTimersByTimeAsync(500);
expect(saveCount).toBe(1);
});
it('debounces multiple changes', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
let saveCount = 0;
server.use(
http.put('/api/documents/doc1', () => {
saveCount++;
return new HttpResponse(null, { status: 200 });
})
);
render(<AutoSaveEditor documentId="doc1" />);
const textarea = screen.getByRole('textbox');
// Type multiple times, never pausing long enough to trigger a save
await user.type(textarea, 'H');
await vi.advanceTimersByTimeAsync(100);
await user.type(textarea, 'e');
await vi.advanceTimersByTimeAsync(100);
await user.type(textarea, 'l');
await vi.advanceTimersByTimeAsync(100);
await user.type(textarea, 'l');
await vi.advanceTimersByTimeAsync(100);
await user.type(textarea, 'o');
// Only 100ms since the last keystroke β shouldn't save yet
expect(saveCount).toBe(0);
// Wait the full 500ms from the last change
await vi.advanceTimersByTimeAsync(500);
// Should have saved only once, for the final value
expect(saveCount).toBe(1);
});
it('shows saving indicator', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
server.use(
http.put('/api/documents/doc1', async () => {
await delay(100);
return new HttpResponse(null, { status: 200 });
})
);
render(<AutoSaveEditor documentId="doc1" />);
const textarea = screen.getByRole('textbox');
await user.type(textarea, 'Test');
// Fire the debounced save; the PUT is still in flight (100ms delay)
await vi.advanceTimersByTimeAsync(500);
// Should show the saving indicator while the request is pending
expect(screen.getByText('Saving...')).toBeInTheDocument();
// Let the request finish
await vi.advanceTimersByTimeAsync(100);
expect(screen.queryByText('Saving...')).not.toBeInTheDocument();
});
});
Exercise 3: Test Real-time Notifications
Task: Test a notification system that polls for new notifications and displays them.
Requirements:
- Polls every 5 seconds
- Displays count of unread notifications
- Shows notification list when clicked
- Marks notifications as read
- Stops polling when component unmounts
π‘ Hint
Combine fake timers with user interactions. Test polling by advancing time. Verify cleanup by checking that no more requests happen after unmount.
β Solution
Try implementing this yourself first! The solution combines polling (like the LiveCounter example), user interactions (clicking to view), and state management for read/unread status.
β Best Practices for Async Testing
Follow these guidelines to write reliable, maintainable async tests.
1. Always Use async/await
β Do This
test('loads data', async () => {
render(<Component />);
expect(await screen.findByText('Loaded')).toBeInTheDocument();
});
β Don't Do This
test('loads data', () => {
render(<Component />);
// This will fail - doesn't wait!
expect(screen.getByText('Loaded')).toBeInTheDocument();
});
2. Use MSW Instead of Mocking fetch
β Do This
// Set up MSW handlers
server.use(
http.get('/api/data', () => {
return HttpResponse.json({ data: 'test' });
})
);
β Don't Do This
// Mocking fetch directly is brittle
global.fetch = vi.fn().mockResolvedValue({
json: async () => ({ data: 'test' })
});
3. Test All Async States
Every async operation should test at least these states:
- Loading: Initial state while waiting
- Success: Data loaded successfully
- Error: Request failed
- Empty: No data returned (when applicable)
describe('DataComponent', () => {
it('shows loading state', () => {
render(<DataComponent />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('shows success state', async () => {
render(<DataComponent />);
expect(await screen.findByText('Data loaded')).toBeInTheDocument();
});
it('shows error state', async () => {
server.use(
http.get('/api/data', () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<DataComponent />);
expect(await screen.findByText('Error')).toBeInTheDocument();
});
it('shows empty state', async () => {
server.use(
http.get('/api/data', () => {
return HttpResponse.json([]);
})
);
render(<DataComponent />);
expect(await screen.findByText('No data')).toBeInTheDocument();
});
});
4. Set Appropriate Timeouts
// For slow operations, increase timeout
expect(
await screen.findByText('Loaded', {}, { timeout: 5000 })
).toBeInTheDocument();
// Or for waitFor
await waitFor(
() => {
expect(screen.getByText('Loaded')).toBeInTheDocument();
},
{ timeout: 5000 }
);
5. Clean Up After Tests
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
server.resetHandlers();
cleanup(); // Clean up rendered components
});
6. Test Request Parameters
test('sends correct request parameters', async () => {
let receivedBody: any;
server.use(
http.post('/api/users', async ({ request }) => {
receivedBody = await request.json();
return HttpResponse.json({ id: '123' });
})
);
// ... trigger request
await waitFor(() => {
expect(receivedBody).toEqual({
name: 'John',
email: 'john@example.com'
});
});
});
7. Handle Race Conditions
test('cancels previous requests', async () => {
const user = userEvent.setup();
let requestCount = 0;
server.use(
http.get('/api/search', async ({ request }) => {
requestCount++;
const query = new URL(request.url).searchParams.get('q');
await delay(100);
return HttpResponse.json({ query });
})
);
render(<SearchComponent />);
const input = screen.getByRole('textbox');
// Type different searches rapidly
await user.type(input, 'a');
await user.clear(input);
await user.type(input, 'ab');
await user.clear(input);
await user.type(input, 'abc');
// Should show only the last result
expect(await screen.findByText(/abc/)).toBeInTheDocument();
});
8. Use React Query Testing Utils
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
test('with React Query', async () => {
const { result } = renderHook(() => useUserQuery('123'), {
wrapper: createWrapper()
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data.name).toBe('John');
});
9. Avoid Act Warnings
π‘ Understanding act() Warnings
If you see "Warning: An update to Component inside a test was not wrapped in act(...)", it means state updates happened after your test finished.
Common causes:
- Not waiting for async operations to complete
- Component updating after unmount
- Missing cleanup in useEffect
Solution: Always wait for all state updates
// Wait for all updates
await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument();
});
// Or use findBy which waits automatically
await screen.findByText('Loaded');
// With FAKE timers, prefer the async advance β it flushes the
// state updates each timer fires, so React re-renders inside act()
// for you (no manual act() wrapper, no warning):
await vi.advanceTimersByTimeAsync(1000);
This is why every fake-timer test in this lesson uses vi.advanceTimersByTimeAsync() rather than wrapping a synchronous vi.advanceTimersByTime() in act() β it's the current, warning-free idiom.
10. Test Error Boundaries
test('error boundary catches async errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
server.use(
http.get('/api/data', () => {
return new HttpResponse(null, { status: 500 });
})
);
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<DataComponent />
</ErrorBoundary>
);
expect(await screen.findByText('Something went wrong')).toBeInTheDocument();
consoleError.mockRestore();
});
π Async Testing Checklist
- β
Mark test functions as
async - β
Use
awaitwith findBy or waitFor - β Mock APIs with MSW, not fetch directly
- β Test loading, success, error, and empty states
- β Set appropriate timeouts for slow operations
- β Clean up timers, handlers, and components
- β Test race conditions and cancellation
- β Verify request parameters and headers
- β Handle act() warnings properly
- β Test error boundaries with async errors
π Summary
Congratulations! You've mastered testing asynchronous code in React applications. Let's recap the key concepts.
Key Takeaways
π― Core Concepts
- Async Testing Fundamentals
- Always use
async/awaitin tests - Wait for state updates with
findByorwaitFor - Test all possible states: loading, success, error, empty
- Always use
- Mock Service Worker (MSW)
- Intercepts network requests at the network level
- More realistic than mocking fetch directly
- Reusable handlers across tests
- Works with any HTTP client
- Testing Patterns
- Use
findByfor waiting for elements to appear - Use
waitForfor complex assertions - Use
waitForElementToBeRemovedfor disappearing elements - Use fake timers for debouncing and polling
- Use
- React Query Testing
- Create fresh QueryClient for each test
- Disable retries and caching in tests
- Test mutations and invalidation
- Use MSW for API mocking
- Advanced Patterns
- Test infinite scroll and pagination
- Test debounced searches
- Test polling and real-time updates
- Test optimistic updates and rollbacks
Testing Async Code Workflow
Common Pitfalls to Avoid
| Pitfall | Problem | Solution |
|---|---|---|
| Not using async/await | Tests finish before async operations complete | Mark test as async and use await |
| Mocking fetch directly | Brittle tests tied to implementation | Use MSW to mock at network level |
| Not testing all states | Missing edge cases and errors | Test loading, success, error, and empty states |
| Forgetting to clean up | Test pollution and false positives/negatives | Reset handlers and timers in afterEach |
| Ignoring race conditions | Flaky tests that sometimes pass/fail | Test cancellation and proper cleanup |
| Using real timers for debouncing | Slow test suite | Use fake timers with vi.useFakeTimers() |
Tools and Libraries Reference
π οΈ Essential Testing Tools
- Vitest: Test runner with modern features
- React Testing Library: User-centric testing utilities
- MSW (Mock Service Worker): Network request mocking
- @testing-library/user-event: Realistic user interactions
- @tanstack/react-query: Data fetching library
Next Steps
Now that you understand async testing, you're ready to:
- Write comprehensive tests for data-fetching components
- Test complex async workflows in your applications
- Handle edge cases like race conditions and errors
- Test React Query and other data fetching libraries
- Build confidence in your application's async behavior
π Congratulations!
You've completed Lesson 9.4: Testing Async Code! You now have the skills to:
- β Test components that fetch data from APIs
- β Use Mock Service Worker to mock network requests
- β Handle loading states, errors, and race conditions
- β Test React Query and other data fetching libraries
- β Test advanced patterns like pagination and polling
- β Write reliable, maintainable async tests
π‘ Remember: "Good async tests are patientβthey wait for operations to complete, handle all states gracefully, and mirror how users actually interact with your app."