β³ 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 async code is challenging because you need to wait for operations to complete before asserting on results.
π What is Async Testing?
Async testing involves testing code that doesn't execute immediately. This includes network requests, setTimeout/setInterval, Promises, async/await, and any operation where results aren't available synchronously.
The Challenge with Async Code
Consider this component that fetches user data:
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>;
}
If we test this synchronously, we'll only see the loading state:
// β 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.
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 { rest } from 'msw';
import { setupServer } from 'msw/node';
import { PostList } from './PostList';
// Set up MSW server
const server = setupServer(
rest.get('/api/posts', (req, res, ctx) => {
return res(
ctx.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
expect(screen.getByText('Loading posts...')).toBeInTheDocument();
// Wait for posts to appear
expect(await screen.findByText('First Post')).toBeInTheDocument();
expect(screen.getByText('Second Post')).toBeInTheDocument();
// Loading should be gone
expect(screen.queryByText('Loading posts...')).not.toBeInTheDocument();
});
});
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 handler to return empty array
server.use(
rest.get('/api/posts', (req, res, ctx) => {
return res(ctx.json([]));
})
);
render(<PostList />);
expect(await screen.findByText('No posts found')).toBeInTheDocument();
});
Testing with Different Data
test('displays correct number of posts', async () => {
server.use(
rest.get('/api/posts', (req, res, ctx) => {
return res(
ctx.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. It intercepts network requests at the network level, making tests more realistic.
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 { rest } from 'msw';
export const handlers = [
// GET request with dynamic params
rest.get('/api/users/:id', (req, res, ctx) => {
const { id } = req.params;
return res(
ctx.status(200),
ctx.json({
id,
name: `User ${id}`,
email: `user${id}@example.com`
})
);
}),
// POST request with request body
rest.post('/api/users', async (req, res, ctx) => {
const body = await req.json();
return res(
ctx.status(201),
ctx.json({
id: Math.random().toString(),
...body,
createdAt: new Date().toISOString()
})
);
}),
// PUT request
rest.put('/api/users/:id', async (req, res, ctx) => {
const { id } = req.params;
const body = await req.json();
return res(
ctx.status(200),
ctx.json({
id,
...body,
updatedAt: new Date().toISOString()
})
);
}),
// DELETE request
rest.delete('/api/users/:id', (req, res, ctx) => {
return res(
ctx.status(204)
);
}),
// Query parameters
rest.get('/api/search', (req, res, ctx) => {
const query = req.url.searchParams.get('q');
const page = req.url.searchParams.get('page') || '1';
return res(
ctx.json({
query,
page: parseInt(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
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from '../mocks/server';
// Start server before all tests
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// Reset handlers after each test
afterEach(() => server.resetHandlers());
// Clean up after all tests
afterAll(() => server.close());
Simulating Different Response Times
test('handles slow API responses', async () => {
server.use(
rest.get('/api/posts', async (req, res, ctx) => {
// Delay response by 2 seconds
await ctx.delay(2000);
return res(
ctx.json([{ id: '1', title: 'Post' }])
);
})
);
render(<PostList />);
// Should show loading for a while
expect(screen.getByText('Loading posts...')).toBeInTheDocument();
// Eventually loads
expect(await screen.findByText('Post', {}, { timeout: 3000 })).toBeInTheDocument();
});
Simulating Network Errors
test('handles network errors', async () => {
server.use(
rest.get('/api/posts', (req, res, ctx) => {
return res.networkError('Failed to connect');
})
);
render(<PostList />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
Testing Different Status Codes
test('handles 404 not found', async () => {
server.use(
rest.get('/api/posts', (req, res, ctx) => {
return res(
ctx.status(404),
ctx.json({ message: 'Not found' })
);
})
);
render(<PostList />);
expect(await screen.findByText('Error: Failed to fetch')).toBeInTheDocument();
});
test('handles 500 server error', async () => {
server.use(
rest.get('/api/posts', (req, res, ctx) => {
return res(
ctx.status(500),
ctx.json({ message: 'Internal server error' })
);
})
);
render(<PostList />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
Per-Test Handler Overrides
describe('PostList with different scenarios', () => {
it('shows success state', async () => {
// Uses default handler
render(<PostList />);
expect(await screen.findByText('First Post')).toBeInTheDocument();
});
it('shows error state', async () => {
// Override for this test only
server.use(
rest.get('/api/posts', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<PostList />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
it('shows empty state', async () => {
// Override for this test only
server.use(
rest.get('/api/posts', (req, res, ctx) => {
return res(ctx.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 ctx.delay() sparingly: Don't slow down your test suite unnecessarily
β³ 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(
rest.get('/api/data', (req, res, ctx) => {
return res(
ctx.delay(100), // Simulate network delay
ctx.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(
rest.get('/api/users/:id', (req, res, ctx) => {
return res(
ctx.delay(100),
ctx.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();
const user = userEvent.setup({ delay: null }); // Disable userEvent's built-in delay
render(<FileUploadProgress />);
await user.click(screen.getByRole('button', { name: 'Upload' }));
// Should start uploading
expect(screen.getByText('Uploading... 0%')).toBeInTheDocument();
// Advance time and check progress
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.getByText('Uploading... 50%')).toBeInTheDocument();
// Complete upload
act(() => {
vi.advanceTimersByTime(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(
rest.get('/api/data', (req, res, ctx) => {
return res.networkError('Failed to connect');
})
);
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(
rest.get('/api/data', (req, res, ctx) => {
return res(
ctx.status(404),
ctx.text('Not Found')
);
})
);
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(
rest.get('/api/data', (req, res, ctx) => {
return res(
ctx.status(500),
ctx.json({ message: 'Internal Server Error' })
);
})
);
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(
rest.get('/api/data', (req, res, ctx) => {
attempts++;
if (attempts < 3) {
return res(ctx.status(500));
}
return res(ctx.json({ value: 'Success!' }));
})
);
vi.useFakeTimers();
render(<DataFetcherWithRetry />);
// First attempt fails
expect(screen.getByText('Loading... (Attempt 1)')).toBeInTheDocument();
// Wait for retry
act(() => {
vi.advanceTimersByTime(1000);
});
await waitFor(() => {
expect(screen.getByText('Loading... (Attempt 2)')).toBeInTheDocument();
});
// Second retry
act(() => {
vi.advanceTimersByTime(1000);
});
// Eventually succeeds
expect(await screen.findByText('Data: Success!')).toBeInTheDocument();
vi.useRealTimers();
});
test('gives up after max retries', async () => {
server.use(
rest.get('/api/data', (req, res, ctx) => {
return res(ctx.status(500));
})
);
vi.useFakeTimers();
render(<DataFetcherWithRetry />);
// Fast-forward through all retries
act(() => {
vi.advanceTimersByTime(4000);
});
expect(await screen.findByText('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(
rest.get('/api/data', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<ErrorRecoveryComponent />);
// Wait for error
expect(await screen.findByText(/error/i)).toBeInTheDocument();
// Fix the API
server.use(
rest.get('/api/data', (req, res, ctx) => {
return res(ctx.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(
rest.get('/api/search', async (req, res, ctx) => {
const query = req.url.searchParams.get('q');
// Simulate different response times
const delay = query === 'first' ? 200 : 50;
await ctx.delay(delay);
return res(
ctx.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(
rest.get('/api/data/1', async (req, res, ctx) => {
await ctx.delay(200);
request1Completed = true;
return res(ctx.json({ id: '1' }));
}),
rest.get('/api/data/2', async (req, res, ctx) => {
await ctx.delay(50);
request2Completed = true;
return res(ctx.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(
rest.get('/api/users/:id', async (req, res, ctx) => {
userFetchOrder.push('user');
await ctx.delay(50);
return res(ctx.json({ name: 'John Doe' }));
}),
rest.get('/api/users/:id/posts', async (req, res, ctx) => {
userFetchOrder.push('posts');
await ctx.delay(50);
return res(
ctx.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, // Don't retry failed queries in tests
cacheTime: 0, // Don't cache between tests
},
},
});
}
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(
rest.get('/api/users/123', (req, res, ctx) => {
return res(
ctx.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(
rest.get('/api/users/123', (req, res, ctx) => {
return res(ctx.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(
rest.post('/api/users', async (req, res, ctx) => {
const body = await req.json();
return res(
ctx.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(
rest.get('/api/users', (req, res, ctx) => {
queryCount++;
return res(
ctx.json([{ id: '1', name: `User ${queryCount}` }])
);
}),
rest.post('/api/users', async (req, res, ctx) => {
return res(ctx.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
cacheTime: 0to 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(
rest.get('/api/posts', (req, res, ctx) => {
const page = req.url.searchParams.get('page') || '1';
requestCount++;
return res(
ctx.json({
posts: [
{ id: `${page}-1`, title: `Post ${page}-1` },
{ id: `${page}-2`, title: `Post ${page}-2` }
],
hasMore: parseInt(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(
rest.get('/api/posts', (req, res, ctx) => {
const page = parseInt(req.url.searchParams.get('page') || '1');
return res(
ctx.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(
rest.get('/api/search', (req, res, ctx) => {
requestCount++;
const query = req.url.searchParams.get('q');
return res(
ctx.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(
rest.get('/api/search', async (req, res, ctx) => {
const query = req.url.searchParams.get('q');
searches.push(query || '');
// Simulate slow search
await ctx.delay(100);
return res(
ctx.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(
rest.get('/api/counter', (req, res, ctx) => {
currentCount++;
return res(ctx.json({ count: currentCount }));
})
);
render(<LiveCounter />);
// Initial render
expect(screen.getByText('Count: 0')).toBeInTheDocument();
// First poll (1 second later)
vi.advanceTimersByTime(1000);
expect(await screen.findByText('Count: 1')).toBeInTheDocument();
// Second poll
vi.advanceTimersByTime(1000);
expect(await screen.findByText('Count: 2')).toBeInTheDocument();
// Third poll
vi.advanceTimersByTime(1000);
expect(await screen.findByText('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(
rest.post('/api/todos/:id/toggle', async (req, res, ctx) => {
// Simulate slow network
await ctx.delay(500);
return res(ctx.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(
rest.post('/api/todos/:id/toggle', (req, res, ctx) => {
return res(ctx.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(
rest.get('/api/users/123/profile', (req, res, ctx) => {
return res(
ctx.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(
rest.get('/api/users/123/profile', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<UserProfileLoader userId="123" />);
expect(
await screen.findByText('Error: Failed to load profile')
).toBeInTheDocument();
});
it('refetches when userId changes', async () => {
server.use(
rest.get('/api/users/:id/profile', (req, res, ctx) => {
const { id } = req.params;
return res(
ctx.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(
rest.get('/api/users/123/profile', async (req, res, ctx) => {
await ctx.delay(100);
return res(ctx.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 () => {
const user = userEvent.setup({ delay: null });
let saveCount = 0;
server.use(
rest.put('/api/documents/doc1', (req, res, ctx) => {
saveCount++;
return res(ctx.status(200));
})
);
render(<AutoSaveEditor documentId="doc1" />);
const textarea = screen.getByRole('textbox');
await user.type(textarea, 'Hello');
// Should not save immediately
expect(saveCount).toBe(0);
// Advance time
vi.advanceTimersByTime(500);
// Should have saved once
await waitFor(() => {
expect(saveCount).toBe(1);
});
});
it('debounces multiple changes', async () => {
const user = userEvent.setup({ delay: null });
let saveCount = 0;
server.use(
rest.put('/api/documents/doc1', (req, res, ctx) => {
saveCount++;
return res(ctx.status(200));
})
);
render(<AutoSaveEditor documentId="doc1" />);
const textarea = screen.getByRole('textbox');
// Type multiple times
await user.type(textarea, 'H');
vi.advanceTimersByTime(100);
await user.type(textarea, 'e');
vi.advanceTimersByTime(100);
await user.type(textarea, 'l');
vi.advanceTimersByTime(100);
await user.type(textarea, 'l');
vi.advanceTimersByTime(100);
await user.type(textarea, 'o');
// Only 100ms passed, shouldn't save yet
expect(saveCount).toBe(0);
// Wait full 500ms from last change
vi.advanceTimersByTime(500);
// Should have saved only once
await waitFor(() => {
expect(saveCount).toBe(1);
});
});
it('shows saving indicator', async () => {
const user = userEvent.setup({ delay: null });
server.use(
rest.put('/api/documents/doc1', async (req, res, ctx) => {
await ctx.delay(100);
return res(ctx.status(200));
})
);
render(<AutoSaveEditor documentId="doc1" />);
const textarea = screen.getByRole('textbox');
await user.type(textarea, 'Test');
vi.advanceTimersByTime(500);
// Should show saving indicator
expect(await screen.findByText('Saving...')).toBeInTheDocument();
// Wait for save to complete
vi.advanceTimersByTime(100);
await waitFor(() => {
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(
rest.get('/api/data', (req, res, ctx) => {
return res(ctx.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(
rest.get('/api/data', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<DataComponent />);
expect(await screen.findByText('Error')).toBeInTheDocument();
});
it('shows empty state', async () => {
server.use(
rest.get('/api/data', (req, res, ctx) => {
return res(ctx.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(
rest.post('/api/users', async (req, res, ctx) => {
receivedBody = await req.json();
return res(ctx.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(
rest.get('/api/search', async (req, res, ctx) => {
requestCount++;
const query = req.url.searchParams.get('q');
await ctx.delay(100);
return res(ctx.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,
cacheTime: 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');
10. Test Error Boundaries
test('error boundary catches async errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
server.use(
rest.get('/api/data', (req, res, ctx) => {
return res(ctx.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."