Welcome to Advanced Testing!
Testing custom hooks and asynchronous code is like being a detective investigating a time-traveling mystery. You need to examine not just what happens, but when it happens, how states change over time, and what side effects occur along the way. Today, we'll master the tools and techniques to solve these testing puzzles!
graph TD
A[Testing Challenges] --> B[Custom Hooks]
A --> C[Async Operations]
B --> D[Hook Lifecycle]
B --> E[State Changes]
B --> F[Side Effects]
C --> G[Promises]
C --> H[API Calls]
C --> I[Timers]
D --> J[renderHook]
E --> J
F --> J
G --> K[waitFor/findBy]
H --> K
I --> L[Fake Timers]
style B fill:#99ff99
style C fill:#9999ff
style J fill:#ffcc99
style K fill:#ffcc99
style L fill:#ffcc99
Testing Custom Hooks In-Depth
1. Advanced Hook Testing Patterns
// Complex hook with multiple states and effects
function useDataFetcher(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [retryCount, setRetryCount] = useState(0);
const {
maxRetries = 3,
retryDelay = 1000,
onSuccess,
onError,
autoFetch = true
} = options;
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
onSuccess?.(result);
setRetryCount(0);
} catch (err) {
setError(err);
onError?.(err);
if (retryCount < maxRetries) {
setTimeout(() => {
setRetryCount(prev => prev + 1);
}, retryDelay * Math.pow(2, retryCount));
}
} finally {
setLoading(false);
}
}, [url, retryCount, maxRetries, retryDelay, onSuccess, onError]);
useEffect(() => {
if (autoFetch) {
fetchData();
}
}, [fetchData, autoFetch]);
// Trigger refetch when retry count changes
useEffect(() => {
if (retryCount > 0) {
fetchData();
}
}, [retryCount, fetchData]);
return {
data,
loading,
error,
refetch: fetchData,
retryCount,
hasExhaustedRetries: retryCount >= maxRetries
};
}
// Comprehensive hook test
import { renderHook, act, waitFor } from '@testing-library/react';
describe('useDataFetcher', () => {
beforeEach(() => {
jest.useFakeTimers();
global.fetch = jest.fn();
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
test('fetches data successfully on mount', async () => {
const mockData = { id: 1, name: 'Test' };
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockData
});
const { result } = renderHook(() =>
useDataFetcher('https://api.test.com/data')
);
// Initially loading
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
// Wait for fetch to complete
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
expect(result.current.retryCount).toBe(0);
});
test('retries on failure with exponential backoff', async () => {
const error = new Error('Network error');
global.fetch.mockRejectedValue(error);
const onError = jest.fn();
const { result } = renderHook(() =>
useDataFetcher('https://api.test.com/data', {
maxRetries: 2,
retryDelay: 1000,
onError
})
);
// First attempt
await waitFor(() => {
expect(result.current.error).toEqual(error);
});
expect(result.current.retryCount).toBe(0);
expect(onError).toHaveBeenCalledTimes(1);
// First retry after 1000ms
act(() => {
jest.advanceTimersByTime(1000);
});
await waitFor(() => {
expect(result.current.retryCount).toBe(1);
});
expect(onError).toHaveBeenCalledTimes(2);
// Second retry after 2000ms (exponential backoff)
act(() => {
jest.advanceTimersByTime(2000);
});
await waitFor(() => {
expect(result.current.retryCount).toBe(2);
});
expect(result.current.hasExhaustedRetries).toBe(true);
expect(onError).toHaveBeenCalledTimes(3);
});
test('manual refetch works correctly', async () => {
const mockData1 = { id: 1 };
const mockData2 = { id: 2 };
global.fetch
.mockResolvedValueOnce({
ok: true,
json: async () => mockData1
})
.mockResolvedValueOnce({
ok: true,
json: async () => mockData2
});
const { result } = renderHook(() =>
useDataFetcher('https://api.test.com/data')
);
// Wait for initial fetch
await waitFor(() => {
expect(result.current.data).toEqual(mockData1);
});
// Manually refetch
await act(async () => {
result.current.refetch();
});
await waitFor(() => {
expect(result.current.data).toEqual(mockData2);
});
});
test('respects autoFetch option', async () => {
const { result } = renderHook(() =>
useDataFetcher('https://api.test.com/data', { autoFetch: false })
);
// Should not fetch automatically
expect(global.fetch).not.toHaveBeenCalled();
expect(result.current.loading).toBe(false);
expect(result.current.data).toBe(null);
// Manual fetch should work
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 1 })
});
await act(async () => {
result.current.refetch();
});
await waitFor(() => {
expect(result.current.data).toEqual({ id: 1 });
});
});
});
2. Testing Hooks with Dependencies
// Hook with complex dependencies
function useDebounce(value, delay, options = {}) {
const [debouncedValue, setDebouncedValue] = useState(value);
const [isPending, setIsPending] = useState(false);
const {
leading = false,
trailing = true,
maxWait
} = options;
const timeoutRef = useRef(null);
const maxWaitTimeoutRef = useRef(null);
const lastCallTimeRef = useRef(null);
useEffect(() => {
let cancelled = false;
const handleValue = () => {
if (!cancelled) {
setDebouncedValue(value);
setIsPending(false);
}
};
// Leading edge
if (leading && !lastCallTimeRef.current) {
handleValue();
} else {
setIsPending(true);
}
lastCallTimeRef.current = Date.now();
// Clear existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Trailing edge
if (trailing) {
timeoutRef.current = setTimeout(() => {
handleValue();
lastCallTimeRef.current = null;
}, delay);
}
// Max wait timeout
if (maxWait && !maxWaitTimeoutRef.current) {
maxWaitTimeoutRef.current = setTimeout(() => {
handleValue();
maxWaitTimeoutRef.current = null;
}, maxWait);
}
return () => {
cancelled = true;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (maxWaitTimeoutRef.current) {
clearTimeout(maxWaitTimeoutRef.current);
}
};
}, [value, delay, leading, trailing, maxWait]);
const cancel = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (maxWaitTimeoutRef.current) {
clearTimeout(maxWaitTimeoutRef.current);
}
setIsPending(false);
}, []);
const flush = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
setDebouncedValue(value);
setIsPending(false);
}
}, [value]);
return {
debouncedValue,
isPending,
cancel,
flush
};
}
// Testing the debounce hook
describe('useDebounce', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('debounces value changes', () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'initial', delay: 1000 } }
);
expect(result.current.debouncedValue).toBe('initial');
// Update value
rerender({ value: 'updated', delay: 1000 });
// Should be pending
expect(result.current.isPending).toBe(true);
expect(result.current.debouncedValue).toBe('initial');
// Fast-forward time
act(() => {
jest.advanceTimersByTime(1000);
});
expect(result.current.debouncedValue).toBe('updated');
expect(result.current.isPending).toBe(false);
});
test('leading edge option', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 1000, { leading: true }),
{ initialProps: { value: 'initial' } }
);
// Update value
rerender({ value: 'updated' });
// Should update immediately on leading edge
expect(result.current.debouncedValue).toBe('updated');
});
test('maxWait option', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 1000, { maxWait: 2000 }),
{ initialProps: { value: 'initial' } }
);
// Rapid updates
rerender({ value: 'update1' });
act(() => { jest.advanceTimersByTime(500); });
rerender({ value: 'update2' });
act(() => { jest.advanceTimersByTime(500); });
rerender({ value: 'update3' });
// Still waiting
expect(result.current.debouncedValue).toBe('initial');
// MaxWait triggers
act(() => { jest.advanceTimersByTime(1000); });
expect(result.current.debouncedValue).toBe('update3');
});
test('cancel and flush methods', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 1000),
{ initialProps: { value: 'initial' } }
);
rerender({ value: 'updated' });
expect(result.current.isPending).toBe(true);
// Cancel
act(() => {
result.current.cancel();
});
expect(result.current.isPending).toBe(false);
// Fast-forward time
act(() => {
jest.advanceTimersByTime(1000);
});
// Value should not have updated
expect(result.current.debouncedValue).toBe('initial');
// Test flush
rerender({ value: 'flushed' });
act(() => {
result.current.flush();
});
expect(result.current.debouncedValue).toBe('flushed');
expect(result.current.isPending).toBe(false);
});
});
3. Testing Hooks with External Dependencies
// Hook that depends on external services
function useAuth() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const checkAuth = useCallback(async () => {
try {
setLoading(true);
const token = localStorage.getItem('token');
if (!token) {
setUser(null);
return;
}
const response = await fetch('/api/auth/verify', {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Invalid token');
}
const userData = await response.json();
setUser(userData);
} catch (err) {
setError(err.message);
localStorage.removeItem('token');
setUser(null);
} finally {
setLoading(false);
}
}, []);
const login = useCallback(async (credentials) => {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
if (!response.ok) {
throw new Error('Login failed');
}
const { token, user } = await response.json();
localStorage.setItem('token', token);
setUser(user);
return user;
} catch (err) {
setError(err.message);
throw err;
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(() => {
localStorage.removeItem('token');
setUser(null);
}, []);
useEffect(() => {
checkAuth();
}, [checkAuth]);
// Listen for storage events (logout in other tabs)
useEffect(() => {
const handleStorageChange = (event) => {
if (event.key === 'token' && !event.newValue) {
setUser(null);
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, []);
return { user, loading, error, login, logout, checkAuth };
}
// Testing with mocked dependencies
describe('useAuth', () => {
let mockLocalStorage;
beforeEach(() => {
// Mock localStorage
mockLocalStorage = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn()
};
Object.defineProperty(window, 'localStorage', {
value: mockLocalStorage,
writable: true
});
// Mock fetch
global.fetch = jest.fn();
// Mock window events
window.addEventListener = jest.fn();
window.removeEventListener = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
});
test('checks authentication on mount', async () => {
const mockUser = { id: 1, name: 'John' };
mockLocalStorage.getItem.mockReturnValue('valid-token');
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockUser
});
const { result } = renderHook(() => useAuth());
// Initially loading
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.user).toEqual(mockUser);
expect(global.fetch).toHaveBeenCalledWith('/api/auth/verify', {
headers: { Authorization: 'Bearer valid-token' }
});
});
test('handles login successfully', async () => {
const credentials = { email: 'test@example.com', password: 'password' };
const mockResponse = {
token: 'new-token',
user: { id: 1, email: 'test@example.com' }
};
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse
});
const { result } = renderHook(() => useAuth());
await act(async () => {
const user = await result.current.login(credentials);
expect(user).toEqual(mockResponse.user);
});
expect(mockLocalStorage.setItem).toHaveBeenCalledWith('token', 'new-token');
expect(result.current.user).toEqual(mockResponse.user);
});
test('handles logout', () => {
const { result } = renderHook(() => useAuth());
act(() => {
result.current.logout();
});
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('token');
expect(result.current.user).toBe(null);
});
test('responds to storage events', () => {
const { result } = renderHook(() => useAuth());
// Simulate storage event
const storageEvent = new Event('storage');
storageEvent.key = 'token';
storageEvent.newValue = null;
// Get the event listener that was added
const [[eventType, handler]] = window.addEventListener.mock.calls;
expect(eventType).toBe('storage');
// Trigger the event
act(() => {
handler(storageEvent);
});
expect(result.current.user).toBe(null);
});
test('cleans up event listeners on unmount', () => {
const { unmount } = renderHook(() => useAuth());
unmount();
expect(window.removeEventListener).toHaveBeenCalledWith(
'storage',
expect.any(Function)
);
});
});
Advanced Async Testing Patterns
1. Testing Complex Async Flows
// Component with complex async behavior
function DataDashboard() {
const [data, setData] = useState(null);
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [refreshing, setRefreshing] = useState(false);
const fetchData = useCallback(async () => {
try {
const [dataResponse, statsResponse] = await Promise.all([
fetch('/api/data'),
fetch('/api/stats')
]);
if (!dataResponse.ok || !statsResponse.ok) {
throw new Error('Failed to fetch data');
}
const [dataJson, statsJson] = await Promise.all([
dataResponse.json(),
statsResponse.json()
]);
setData(dataJson);
setStats(statsJson);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const refresh = () => {
setRefreshing(true);
fetchData();
};
if (loading) return <div>Loading initial data...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<h1>Dashboard</h1>
<button onClick={refresh} disabled={refreshing}>
{refreshing ? 'Refreshing...' : 'Refresh'}
</button>
<div>
<h2>Data Overview</h2>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
<div>
<h2>Statistics</h2>
<ul>
<li>Total: {stats.total}</li>
<li>Average: {stats.average}</li>
<li>Max: {stats.max}</li>
</ul>
</div>
</div>
);
}
// Testing parallel async operations
describe('DataDashboard', () => {
beforeEach(() => {
global.fetch = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
});
test('fetches data and stats in parallel', async () => {
const mockData = { items: [1, 2, 3] };
const mockStats = { total: 6, average: 2, max: 3 };
global.fetch
.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockData)
})
)
.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockStats)
})
);
render(<DataDashboard />);
// Initially loading
expect(screen.getByText('Loading initial data...')).toBeInTheDocument();
// Wait for both requests to complete
await waitFor(() => {
expect(screen.getByText('Dashboard')).toBeInTheDocument();
});
// Check that both endpoints were called
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.fetch).toHaveBeenCalledWith('/api/data');
expect(global.fetch).toHaveBeenCalledWith('/api/stats');
// Check rendered data
expect(screen.getByText('Total: 6')).toBeInTheDocument();
expect(screen.getByText('Average: 2')).toBeInTheDocument();
expect(screen.getByText('Max: 3')).toBeInTheDocument();
});
test('handles partial failures', async () => {
global.fetch
.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ items: [] })
})
)
.mockImplementationOnce(() =>
Promise.resolve({
ok: false,
status: 500
})
);
render(<DataDashboard />);
await waitFor(() => {
expect(screen.getByText('Error: Failed to fetch data')).toBeInTheDocument();
});
});
test('refresh functionality', async () => {
const mockData = { items: [1, 2, 3] };
const mockStats = { total: 6, average: 2, max: 3 };
global.fetch.mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockData)
})
);
render(<DataDashboard />);
// Wait for initial load
await waitFor(() => {
expect(screen.getByText('Dashboard')).toBeInTheDocument();
});
// Clear mock calls
global.fetch.mockClear();
// Click refresh
const refreshButton = screen.getByRole('button', { name: 'Refresh' });
fireEvent.click(refreshButton);
// Button should be disabled while refreshing
expect(refreshButton).toBeDisabled();
expect(refreshButton).toHaveTextContent('Refreshing...');
// Wait for refresh to complete
await waitFor(() => {
expect(refreshButton).toBeEnabled();
expect(refreshButton).toHaveTextContent('Refresh');
});
// Should have fetched data again
expect(global.fetch).toHaveBeenCalledTimes(2);
});
});
2. Testing Race Conditions
// Component with potential race conditions
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const abortControllerRef = useRef(null);
const searchDebounced = useMemo(
() => debounce(async (searchQuery) => {
// Cancel previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
if (!searchQuery.trim()) {
setResults([]);
return;
}
abortControllerRef.current = new AbortController();
try {
setLoading(true);
setError(null);
const response = await fetch(
`/api/search?q=${encodeURIComponent(searchQuery)}`,
{ signal: abortControllerRef.current.signal }
);
if (!response.ok) throw new Error('Search failed');
const data = await response.json();
setResults(data.results);
} catch (err) {
if (err.name === 'AbortError') {
// Ignore abort errors
return;
}
setError(err.message);
setResults([]);
} finally {
setLoading(false);
}
}, 300),
[]
);
useEffect(() => {
searchDebounced(query);
return () => {
searchDebounced.cancel();
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [query, searchDebounced]);
return (
<div>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{loading && <div>Searching...</div>}
{error && <div>Error: {error}</div>}
<ul>
{results.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
</div>
);
}
// Testing race conditions and request cancellation
describe('SearchComponent', () => {
beforeEach(() => {
jest.useFakeTimers();
global.fetch = jest.fn();
global.AbortController = jest.fn(() => ({
signal: {},
abort: jest.fn()
}));
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
test('cancels previous request when new search is initiated', async () => {
const firstAbortController = new AbortController();
const secondAbortController = new AbortController();
global.AbortController
.mockImplementationOnce(() => firstAbortController)
.mockImplementationOnce(() => secondAbortController);
const user = userEvent.setup({ delay: null });
render(<SearchComponent />);
const searchInput = screen.getByPlaceholderText('Search...');
// First search
await user.type(searchInput, 'first');
// Second search immediately after
await user.clear(searchInput);
await user.type(searchInput, 'second');
// Fast-forward debounce for both searches
act(() => {
jest.runAllTimers();
});
// First request should be aborted
expect(firstAbortController.abort).toHaveBeenCalled();
// Only second search should proceed
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('second'),
expect.objectContaining({ signal: secondAbortController.signal })
);
});
test('handles aborted requests correctly', async () => {
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
global.fetch.mockRejectedValueOnce(abortError);
const user = userEvent.setup({ delay: null });
render(<SearchComponent />);
await user.type(screen.getByPlaceholderText('Search...'), 'test');
act(() => {
jest.runAllTimers();
});
// Should not show error for aborted requests
await waitFor(() => {
expect(screen.queryByText(/Error:/)).not.toBeInTheDocument();
});
});
test('cleans up on unmount', async () => {
const abortController = new AbortController();
global.AbortController.mockImplementation(() => abortController);
const { unmount } = render(<SearchComponent />);
unmount();
expect(abortController.abort).toHaveBeenCalled();
});
});
3. Testing WebSocket Connections
// Component using WebSocket
function ChatComponent({ roomId }) {
const [messages, setMessages] = useState([]);
const [connected, setConnected] = useState(false);
const [error, setError] = useState(null);
const wsRef = useRef(null);
useEffect(() => {
const ws = new WebSocket(`wss://chat.example.com/rooms/${roomId}`);
wsRef.current = ws;
ws.onopen = () => {
setConnected(true);
setError(null);
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
setMessages(prev => [...prev, message]);
};
ws.onerror = (event) => {
setError('Connection error');
};
ws.onclose = () => {
setConnected(false);
};
return () => {
ws.close();
};
}, [roomId]);
const sendMessage = (text) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ text, timestamp: Date.now() }));
}
};
return (
<div>
<div>
Status: {connected ? 'Connected' : 'Disconnected'}
{error && <span> - {error}</span>}
<div>
<ul>
{messages.map((msg, index) => (
<li key={index}>{msg.text}</li>
))}
</ul>
<button
onClick={() => sendMessage('Hello!')}
disabled={!connected}
>
Send Hello
</button>
</div>
);
}
// Mocking WebSocket for testing
class MockWebSocket {
constructor(url) {
this.url = url;
this.readyState = MockWebSocket.CONNECTING;
// Simulate connection
setTimeout(() => {
this.readyState = MockWebSocket.OPEN;
this.onopen?.();
}, 100);
}
send(data) {
if (this.readyState !== MockWebSocket.OPEN) {
throw new Error('WebSocket is not open');
}
this.sentMessages.push(data);
}
close() {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.();
}
// Simulate receiving a message
simulateMessage(data) {
this.onmessage?.({ data: JSON.stringify(data) });
}
simulateError() {
this.onerror?.();
}
static CONNECTING = 0;
static OPEN = 1;
static CLOSED = 3;
sentMessages = [];
}
describe('ChatComponent', () => {
let mockWebSocket;
beforeEach(() => {
jest.useFakeTimers();
mockWebSocket = MockWebSocket;
global.WebSocket = mockWebSocket;
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
test('connects to WebSocket on mount', () => {
render(<ChatComponent roomId="123" />);
expect(screen.getByText('Status: Disconnected')).toBeInTheDocument();
// Fast-forward to simulate connection
act(() => {
jest.advanceTimersByTime(100);
});
expect(screen.getByText('Status: Connected')).toBeInTheDocument();
});
test('receives and displays messages', () => {
render(<ChatComponent roomId="123" />);
// Wait for connection
act(() => {
jest.advanceTimersByTime(100);
});
// Get the WebSocket instance
const ws = MockWebSocket.mock.instances[0];
// Simulate receiving messages
act(() => {
ws.simulateMessage({ text: 'Hello!' });
ws.simulateMessage({ text: 'How are you?' });
});
expect(screen.getByText('Hello!')).toBeInTheDocument();
expect(screen.getByText('How are you?')).toBeInTheDocument();
});
test('sends messages when connected', () => {
render(<ChatComponent roomId="123" />);
// Wait for connection
act(() => {
jest.advanceTimersByTime(100);
});
const ws = MockWebSocket.mock.instances[0];
const sendButton = screen.getByRole('button', { name: 'Send Hello' });
fireEvent.click(sendButton);
expect(ws.sentMessages).toHaveLength(1);
expect(JSON.parse(ws.sentMessages[0])).toEqual(
expect.objectContaining({ text: 'Hello!' })
);
});
test('handles connection errors', () => {
render(<ChatComponent roomId="123" />);
const ws = MockWebSocket.mock.instances[0];
act(() => {
ws.simulateError();
});
expect(screen.getByText('Connection error')).toBeInTheDocument();
});
test('closes connection on unmount', () => {
const { unmount } = render(<ChatComponent roomId="123" />);
const ws = MockWebSocket.mock.instances[0];
const closeSpy = jest.spyOn(ws, 'close');
unmount();
expect(closeSpy).toHaveBeenCalled();
});
});
Performance Testing in Async Scenarios
1. Testing Render Performance
// Component with performance considerations
function VirtualizedList({ items, itemHeight = 50 }) {
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 20 });
const containerRef = useRef(null);
const handleScroll = useCallback((e) => {
const scrollTop = e.target.scrollTop;
const start = Math.floor(scrollTop / itemHeight);
const visibleCount = Math.ceil(e.target.clientHeight / itemHeight);
const end = start + visibleCount + 5; // Buffer
setVisibleRange({ start, end });
}, [itemHeight]);
const visibleItems = items.slice(
visibleRange.start,
visibleRange.end
);
return (
<div
ref={containerRef}
onScroll={handleScroll}
style={{
height: '500px',
overflowY: 'auto',
position: 'relative'
}}
>
<div style={{ height: items.length * itemHeight }}>
{visibleItems.map((item, index) => (
<div
key={item.id}
style={{
position: 'absolute',
top: (visibleRange.start + index) * itemHeight,
height: itemHeight
}}
>
{item.content}
</div>
))}
</div>
</div>
);
}
// Performance testing
describe('VirtualizedList Performance', () => {
test('renders only visible items', () => {
const items = Array.from({ length: 1000 }, (_, i) => ({
id: i,
content: `Item ${i}`
}));
const { container } = render(
<VirtualizedList items={items} itemHeight={50} />
);
// Check that only a subset of items are rendered
const renderedItems = container.querySelectorAll('div[style*="position: absolute"]');
expect(renderedItems.length).toBeLessThan(items.length);
expect(renderedItems.length).toBeLessThanOrEqual(25); // 20 + 5 buffer
});
test('updates visible range on scroll', () => {
const items = Array.from({ length: 1000 }, (_, i) => ({
id: i,
content: `Item ${i}`
}));
const { container } = render(
<VirtualizedList items={items} itemHeight={50} />
);
const scrollContainer = container.firstChild;
// Simulate scroll
fireEvent.scroll(scrollContainer, {
target: { scrollTop: 500, clientHeight: 500 }
});
// Check that different items are now rendered
const renderedItems = container.querySelectorAll('div[style*="position: absolute"]');
const firstItem = renderedItems[0];
expect(firstItem).toHaveStyle({ top: '500px' });
});
test('performance of scroll handling', () => {
const items = Array.from({ length: 10000 }, (_, i) => ({
id: i,
content: `Item ${i}`
}));
const { container } = render(
<VirtualizedList items={items} itemHeight={50} />
);
const scrollContainer = container.firstChild;
// Measure performance of rapid scrolling
const startTime = performance.now();
// Simulate rapid scrolling
for (let i = 0; i < 100; i++) {
fireEvent.scroll(scrollContainer, {
target: { scrollTop: i * 100, clientHeight: 500 }
});
}
const endTime = performance.now();
const totalTime = endTime - startTime;
// Ensure scrolling is performant
expect(totalTime).toBeLessThan(100); // Should complete in under 100ms
});
});
2. Testing Memory Leaks
// Hook that might cause memory leaks
function useEventListener(eventName, handler, element = window) {
const savedHandler = useRef();
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
const eventListener = (event) => savedHandler.current(event);
element.addEventListener(eventName, eventListener);
return () => {
element.removeEventListener(eventName, eventListener);
};
}, [eventName, element]);
}
// Testing for memory leaks
describe('useEventListener memory management', () => {
test('cleans up event listeners on unmount', () => {
const handler = jest.fn();
const element = {
addEventListener: jest.fn(),
removeEventListener: jest.fn()
};
const { unmount } = renderHook(() =>
useEventListener('click', handler, element)
);
expect(element.addEventListener).toHaveBeenCalledWith(
'click',
expect.any(Function)
);
unmount();
expect(element.removeEventListener).toHaveBeenCalledWith(
'click',
expect.any(Function)
);
});
test('updates handler without creating new listeners', () => {
const element = {
addEventListener: jest.fn(),
removeEventListener: jest.fn()
};
const { rerender } = renderHook(
({ handler }) => useEventListener('click', handler, element),
{ initialProps: { handler: jest.fn() } }
);
expect(element.addEventListener).toHaveBeenCalledTimes(1);
// Update handler
rerender({ handler: jest.fn() });
// Should not add new listener
expect(element.addEventListener).toHaveBeenCalledTimes(1);
expect(element.removeEventListener).toHaveBeenCalledTimes(0);
});
test('removes old listener when event name changes', () => {
const handler = jest.fn();
const element = {
addEventListener: jest.fn(),
removeEventListener: jest.fn()
};
const { rerender } = renderHook(
({ eventName }) => useEventListener(eventName, handler, element),
{ initialProps: { eventName: 'click' } }
);
rerender({ eventName: 'mouseover' });
expect(element.removeEventListener).toHaveBeenCalledWith(
'click',
expect.any(Function)
);
expect(element.addEventListener).toHaveBeenCalledWith(
'mouseover',
expect.any(Function)
);
});
});
Best Practices for Testing Hooks and Async Code
1. Always Clean Up After Tests
describe('MyComponent', () => {
beforeEach(() => {
jest.useFakeTimers();
global.fetch = jest.fn();
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
// Clean up any remaining promises
jest.clearAllTimers();
});
// Tests...
});
2. Use act() for State Updates
// Wrap state updates in act()
test('updates state correctly', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
// For async operations
test('async state update', async () => {
const { result } = renderHook(() => useAsyncData());
await act(async () => {
await result.current.fetchData();
});
expect(result.current.data).toBeDefined();
});
3. Test Error States
test('handles errors gracefully', async () => {
global.fetch.mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useDataFetcher());
await waitFor(() => {
expect(result.current.error).toBeTruthy();
});
expect(result.current.data).toBeNull();
expect(result.current.loading).toBe(false);
});
4. Test Hook Dependencies
test('refetches when dependencies change', async () => {
const { result, rerender } = renderHook(
({ url }) => useDataFetcher(url),
{ initialProps: { url: '/api/v1' } }
);
expect(global.fetch).toHaveBeenCalledWith('/api/v1');
rerender({ url: '/api/v2' });
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith('/api/v2');
});
});
Practice Exercises
Exercise 1: Test a Polling Hook
Create and test a custom hook that:
- Polls an API endpoint at regular intervals
- Handles start/stop functionality
- Manages error states and retries
- Cleans up properly on unmount
Exercise 2: Test a WebSocket Hook
Build and test a custom WebSocket hook with:
- Automatic reconnection
- Message queuing while disconnected
- Connection status management
- Proper cleanup on unmount
Exercise 3: Test an Infinite Scroll Hook
Create and test a hook that:
- Detects when user scrolls near bottom
- Fetches more data automatically
- Prevents duplicate requests
- Handles loading and error states
Key Takeaways
- Use renderHook for testing custom hooks in isolation
- Always wrap state updates in act()
- Mock external dependencies (fetch, timers, etc.)
- Test both success and error scenarios
- Check for proper cleanup to prevent memory leaks
- Use fake timers for testing time-dependent code
- Test race conditions and edge cases
- Verify hook behavior when dependencies change