Testing Async Code and Custom Hooks in React

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

  1. Always cleanup: Use afterEach to reset state between tests
  2. Avoid waiting for fixed timeouts: Use waitFor with conditions instead
  3. Mock external dependencies: API calls, timers, WebSockets
  4. Test all states: Loading, success, error, and edge cases
  5. 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)
                

Additional Resources