Introduction to Async Testing Challenges
Testing asynchronous code in React presents unique challenges. Your components might fetch data, use timers, or interact with APIs - all of which happen over time rather than instantly. Today we'll explore how to effectively test these asynchronous behaviors and custom hooks.
🚦 The Traffic Light Analogy
Testing async code is like testing a traffic light system. You need to:
- Wait for the light to change (async operation completion)
- Verify the current state (loading, success, error)
- Check the transition between states
- Handle unexpected delays or failures
Understanding React Testing Library's Async Utilities
graph TD
A[Async Operation] --> B{Type of Wait}
B --> C[waitFor]
B --> D[findBy queries]
B --> E[act]
C --> F[Custom conditions]
D --> G[Element appears]
E --> H[State updates]
F --> I[Test passes/fails]
G --> I
H --> I
Key Async Utilities
// 1. waitFor - Waits for custom conditions
import { render, screen, waitFor } from '@testing-library/react';
test('async data loading', async () => {
render(<DataComponent />);
await waitFor(() => {
expect(screen.getByText('Data loaded')).toBeInTheDocument();
});
});
// 2. findBy queries - Combines getBy + waitFor
test('element appears after delay', async () => {
render(<DelayedComponent />);
// Automatically waits for element to appear
const element = await screen.findByText('Hello World');
expect(element).toBeInTheDocument();
});
// 3. act - Wraps state updates
test('manual state update', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
Testing Components with Data Fetching
// Component that fetches data
const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
try {
setLoading(true);
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return null;
return (
<div>
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
</div>
);
};
// Testing the component
import { rest } from 'msw';
import { setupServer } from 'msw/node';
// Mock API server
const server = setupServer(
rest.get('/api/users/:id', (req, res, ctx) => {
const { id } = req.params;
return res(
ctx.json({
id,
name: 'John Doe',
email: 'john@example.com'
})
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('loads and displays user data', async () => {
render(<UserProfile userId="1" />);
// Loading state
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Wait for data to load
const userName = await screen.findByText('John Doe');
expect(userName).toBeInTheDocument();
expect(screen.getByText('Email: john@example.com')).toBeInTheDocument();
});
test('handles error state', async () => {
// Override the default handler for this test
server.use(
rest.get('/api/users/:id', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<UserProfile userId="1" />);
const errorMessage = await screen.findByText(/Error: Failed to fetch/i);
expect(errorMessage).toBeInTheDocument();
});
Testing Custom Hooks
The renderHook Utility
Custom hooks are functions that use React's hook system. To test them properly, we need to render them within a component context. That's where renderHook comes in.
// Custom hook
const useDebounce = (value, delay) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
};
// Testing the custom hook
import { renderHook, act } from '@testing-library/react';
test('debounces value changes', async () => {
jest.useFakeTimers();
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{
initialProps: { value: 'initial', delay: 500 }
}
);
expect(result.current).toBe('initial');
// Change the value
rerender({ value: 'updated', delay: 500 });
// Value shouldn't change immediately
expect(result.current).toBe('initial');
// Fast-forward time
act(() => {
jest.advanceTimersByTime(500);
});
// Now the value should be updated
expect(result.current).toBe('updated');
jest.useRealTimers();
});
// Another custom hook example - data fetching
const useFetch = (url) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(url);
if (!response.ok) throw new Error('Network error');
const json = await response.json();
setData(json);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchData();
}, [url]);
return { data, loading, error };
};
// Testing the fetch hook
test('fetches data successfully', async () => {
const mockData = { id: 1, title: 'Test Post' };
server.use(
rest.get('https://api.example.com/posts/1', (req, res, ctx) => {
return res(ctx.json(mockData));
})
);
const { result } = renderHook(() =>
useFetch('https://api.example.com/posts/1')
);
// Initial state
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
expect(result.current.error).toBe(null);
// Wait for loading to complete
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Check final state
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
});
Testing WebSocket Connections
// WebSocket hook
const useWebSocket = (url) => {
const [socket, setSocket] = useState(null);
const [messages, setMessages] = useState([]);
const [status, setStatus] = useState('disconnected');
useEffect(() => {
const ws = new WebSocket(url);
ws.onopen = () => {
setStatus('connected');
setSocket(ws);
};
ws.onmessage = (event) => {
setMessages(prev => [...prev, event.data]);
};
ws.onclose = () => {
setStatus('disconnected');
setSocket(null);
};
ws.onerror = () => {
setStatus('error');
};
return () => ws.close();
}, [url]);
const sendMessage = (message) => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(message);
}
};
return { messages, status, sendMessage };
};
// Testing WebSocket hook
test('connects to WebSocket and receives messages', async () => {
// Mock WebSocket
const mockWebSocket = {
onopen: null,
onmessage: null,
onclose: null,
onerror: null,
close: jest.fn(),
send: jest.fn(),
readyState: WebSocket.OPEN
};
global.WebSocket = jest.fn(() => mockWebSocket);
const { result } = renderHook(() =>
useWebSocket('ws://example.com')
);
// Simulate connection
act(() => {
mockWebSocket.onopen();
});
expect(result.current.status).toBe('connected');
// Simulate receiving a message
act(() => {
mockWebSocket.onmessage({ data: 'Hello World' });
});
expect(result.current.messages).toEqual(['Hello World']);
// Test sending a message
act(() => {
result.current.sendMessage('Test message');
});
expect(mockWebSocket.send).toHaveBeenCalledWith('Test message');
});
Real-World Example: Authentication Hook
// Authentication hook
const useAuth = () => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const login = async (email, password) => {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!response.ok) {
throw new Error('Login failed');
}
const userData = await response.json();
setUser(userData);
localStorage.setItem('token', userData.token);
return userData;
} catch (err) {
setError(err.message);
throw err;
} finally {
setLoading(false);
}
};
const logout = () => {
setUser(null);
localStorage.removeItem('token');
};
useEffect(() => {
const checkAuth = async () => {
const token = localStorage.getItem('token');
if (!token) {
setLoading(false);
return;
}
try {
const response = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) throw new Error('Invalid token');
const userData = await response.json();
setUser(userData);
} catch (err) {
localStorage.removeItem('token');
} finally {
setLoading(false);
}
};
checkAuth();
}, []);
return { user, loading, error, login, logout };
};
// Comprehensive test suite
describe('useAuth hook', () => {
beforeEach(() => {
localStorage.clear();
server.resetHandlers();
});
test('checks authentication on mount', async () => {
localStorage.setItem('token', 'valid-token');
server.use(
rest.get('/api/auth/me', (req, res, ctx) => {
const token = req.headers.get('Authorization');
if (token === 'Bearer valid-token') {
return res(ctx.json({ id: 1, email: 'user@example.com' }));
}
return res(ctx.status(401));
})
);
const { result } = renderHook(() => useAuth());
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.user).toEqual({
id: 1,
email: 'user@example.com'
});
});
test('handles login successfully', async () => {
server.use(
rest.post('/api/auth/login', async (req, res, ctx) => {
const { email, password } = await req.json();
if (email === 'test@example.com' && password === 'password123') {
return res(ctx.json({
id: 1,
email: 'test@example.com',
token: 'new-token'
}));
}
return res(ctx.status(401));
})
);
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.login('test@example.com', 'password123');
});
expect(result.current.user).toEqual({
id: 1,
email: 'test@example.com',
token: 'new-token'
});
expect(localStorage.getItem('token')).toBe('new-token');
});
test('handles login failure', async () => {
server.use(
rest.post('/api/auth/login', (req, res, ctx) => {
return res(ctx.status(401));
})
);
const { result } = renderHook(() => useAuth());
await expect(
act(async () => {
await result.current.login('wrong@example.com', 'wrong');
})
).rejects.toThrow('Login failed');
expect(result.current.error).toBe('Login failed');
expect(result.current.user).toBe(null);
});
test('handles logout', () => {
localStorage.setItem('token', 'some-token');
const { result } = renderHook(() => useAuth());
act(() => {
result.current.logout();
});
expect(result.current.user).toBe(null);
expect(localStorage.getItem('token')).toBe(null);
});
});
Testing Timer-Based Hooks
// Countdown timer hook
const useCountdown = (initialSeconds) => {
const [seconds, setSeconds] = useState(initialSeconds);
const [isRunning, setIsRunning] = useState(false);
useEffect(() => {
if (!isRunning || seconds <= 0) return;
const intervalId = setInterval(() => {
setSeconds(prev => {
if (prev <= 1) {
setIsRunning(false);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(intervalId);
}, [isRunning, seconds]);
const start = () => setIsRunning(true);
const pause = () => setIsRunning(false);
const reset = () => {
setSeconds(initialSeconds);
setIsRunning(false);
};
return { seconds, isRunning, start, pause, reset };
};
// Testing with fake timers
test('countdown timer works correctly', () => {
jest.useFakeTimers();
const { result } = renderHook(() => useCountdown(5));
expect(result.current.seconds).toBe(5);
expect(result.current.isRunning).toBe(false);
// Start the timer
act(() => {
result.current.start();
});
expect(result.current.isRunning).toBe(true);
// Advance timer by 3 seconds
act(() => {
jest.advanceTimersByTime(3000);
});
expect(result.current.seconds).toBe(2);
// Pause the timer
act(() => {
result.current.pause();
});
expect(result.current.isRunning).toBe(false);
// Advance time when paused
act(() => {
jest.advanceTimersByTime(2000);
});
// Should still be 2 seconds
expect(result.current.seconds).toBe(2);
// Reset the timer
act(() => {
result.current.reset();
});
expect(result.current.seconds).toBe(5);
expect(result.current.isRunning).toBe(false);
jest.useRealTimers();
});
Best Practices for Async Testing
Essential Guidelines
- Always cleanup: Use afterEach to reset state between tests
- Avoid waiting for fixed timeouts: Use waitFor with conditions instead
- Mock external dependencies: API calls, timers, WebSockets
- Test all states: Loading, success, error, and edge cases
- Use act() for state updates: Wrap state changes in act()
// Good Example: Testing with proper cleanup and mocking
describe('UserDashboard', () => {
// Mock server setup
const server = setupServer(
rest.get('/api/user/data', (req, res, ctx) => {
return res(ctx.json({ stats: { posts: 10, likes: 50 } }));
})
);
beforeAll(() => {
server.listen();
jest.useFakeTimers();
});
afterEach(() => {
server.resetHandlers();
jest.clearAllTimers();
});
afterAll(() => {
server.close();
jest.useRealTimers();
});
test('loads and displays user stats', async () => {
render(<UserDashboard />);
// Check loading state
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('Posts: 10')).toBeInTheDocument();
});
expect(screen.getByText('Likes: 50')).toBeInTheDocument();
});
});
Common Pitfalls and Solutions
graph LR
A[Common Issues] --> B[Act warnings]
A --> C[Unhandled promises]
A --> D[Test timeouts]
A --> E[Memory leaks]
B --> F[Wrap in act()]
C --> G[await all promises]
D --> H[Increase timeout]
E --> I[Cleanup properly]
// Problem: Act warnings
test('problematic test', () => {
const { result } = renderHook(() => useCounter());
// This causes act warning
result.current.increment();
});
// Solution: Wrap in act
test('fixed test', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
});
// Problem: Unhandled promise rejection
test('async error', async () => {
render(<AsyncComponent />);
// Missing await
expect(screen.findByText('Error')).toBeInTheDocument();
});
// Solution: Always await async operations
test('async error fixed', async () => {
render(<AsyncComponent />);
await expect(screen.findByText('Error')).toBeInTheDocument();
});
// Problem: Test timeout
test('slow operation', async () => {
// This might timeout on slow systems
render(<SlowComponent />);
await screen.findByText('Loaded');
}, 5000); // Increase timeout
// Problem: Memory leak
test('subscription', () => {
const { unmount } = render(<SubscriptionComponent />);
// Missing cleanup
});
// Solution: Always cleanup
test('subscription fixed', () => {
const { unmount } = render(<SubscriptionComponent />);
unmount(); // Proper cleanup
});
Practice Exercise
Task: Test a Search Hook
Create and test a custom hook that implements search functionality with debouncing:
// Create this hook
const useSearch = (items, searchTerm, delay = 300) => {
// Implement:
// 1. Debounced search term
// 2. Filtered results
// 3. Loading state during debounce
// Return: { results, isSearching }
};
// Write tests for:
// 1. Initial state
// 2. Search filtering
// 3. Debounce behavior
// 4. Loading state transitions
// 5. Edge cases (empty search, no results)