Testing Hooks and Async Code

Mastering Complex Testing Scenarios in React

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