Skip to main content

⏳ Lesson 9.4: Testing Async Code

Master the art of testing asynchronous operations in React applications. Learn to test data fetching, API calls, loading states, error handling, race conditions, and advanced async patterns with confidence.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Test components that fetch data with proper async handling
  • Mock API calls effectively using Mock Service Worker (MSW)
  • Test loading states, success states, and error states comprehensively
  • Handle race conditions and concurrent requests in tests
  • Test React Query and other data fetching libraries
  • Test infinite scroll, pagination, and polling patterns
  • Write reliable tests for complex async workflows

Estimated Time: 75-90 minutes

Project: Test a complete data fetching component with all async states

πŸ“‘ In This Lesson

πŸ“– Understanding Async Testing

Asynchronous code is everywhere in modern React applicationsβ€”data fetching, API calls, animations, timers, and more. Testing async code is challenging because you need to wait for operations to complete before asserting on results.

πŸ“– What is Async Testing?

Async testing involves testing code that doesn't execute immediately. This includes network requests, setTimeout/setInterval, Promises, async/await, and any operation where results aren't available synchronously.

The Challenge with Async Code

Consider this component that fetches user data:


function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = React.useState<User | null>(null);
  const [loading, setLoading] = React.useState(true);
  
  React.useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      });
  }, [userId]);
  
  if (loading) return <div>Loading...</div>;
  return <div>{user?.name}</div>;
}
                

If we test this synchronously, we'll only see the loading state:


// ❌ This test fails - doesn't wait for async operation
test('displays user name', () => {
  render(<UserProfile userId="123" />);
  
  // This fails - user data hasn't loaded yet!
  expect(screen.getByText('John Doe')).toBeInTheDocument();
});
                

The Async Testing Lifecycle

graph LR A[Render Component] --> B[Initial State: Loading] B --> C[Async Operation Starts] C --> D[Wait for Operation] D --> E{Success?} E -->|Yes| F[Update State: Data] E -->|No| G[Update State: Error] F --> H[Component Rerenders] G --> H H --> I[Assert on Final State] style A fill:#667eea,stroke:#333,stroke-width:2px,color:#fff style D fill:#ffc107,stroke:#333,stroke-width:2px,color:#333 style I fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff

Three Approaches to Async Testing

React Testing Library provides three ways to handle async operations:

Method Use Case Example
findBy queries Waiting for elements to appear await screen.findByText('Loaded')
waitFor Waiting for assertions to pass await waitFor(() => expect(...).toBe(...))
waitForElementToBeRemoved Waiting for elements to disappear await waitForElementToBeRemoved(() => screen.getByText('Loading'))

πŸ’‘ Key Async Testing Principles

  1. Always use async/await: Mark test functions as async
  2. Wait for changes: Use findBy or waitFor
  3. Mock external dependencies: Control API responses in tests
  4. Test all states: Loading, success, error, and empty states
  5. Be patient: Set appropriate timeouts for slow operations
πŸ’¬ Testing Philosophy: "Async tests should mirror realityβ€”wait for operations to complete, handle errors gracefully, and verify the final state matches user expectations."

🌐 Testing Basic Data Fetching

Let's start with the fundamentals of testing components that fetch data.

Simple Data Fetching Component


interface Post {
  id: string;
  title: string;
  body: string;
}

export function PostList() {
  const [posts, setPosts] = React.useState<Post[]>([]);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState<string | null>(null);
  
  React.useEffect(() => {
    fetch('/api/posts')
      .then(res => {
        if (!res.ok) throw new Error('Failed to fetch');
        return res.json();
      })
      .then(data => {
        setPosts(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, []);
  
  if (loading) return <div>Loading posts...</div>;
  if (error) return <div>Error: {error}</div>;
  if (posts.length === 0) return <div>No posts found</div>;
  
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.body}</p>
        </li>
      ))}
    </ul>
  );
}
                

Testing with findBy


import { render, screen } from '@testing-library/react';
import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { PostList } from './PostList';

// Set up MSW server
const server = setupServer(
  rest.get('/api/posts', (req, res, ctx) => {
    return res(
      ctx.json([
        { id: '1', title: 'First Post', body: 'Content 1' },
        { id: '2', title: 'Second Post', body: 'Content 2' }
      ])
    );
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('PostList', () => {
  it('displays posts after loading', async () => {
    render(<PostList />);
    
    // Initially shows loading
    expect(screen.getByText('Loading posts...')).toBeInTheDocument();
    
    // Wait for posts to appear
    expect(await screen.findByText('First Post')).toBeInTheDocument();
    expect(screen.getByText('Second Post')).toBeInTheDocument();
    
    // Loading should be gone
    expect(screen.queryByText('Loading posts...')).not.toBeInTheDocument();
  });
});
                

Testing with waitFor


import { waitFor } from '@testing-library/react';

test('displays posts using waitFor', async () => {
  render(<PostList />);
  
  // Wait for loading to finish and posts to appear
  await waitFor(() => {
    expect(screen.queryByText('Loading posts...')).not.toBeInTheDocument();
    expect(screen.getByText('First Post')).toBeInTheDocument();
  });
});
                

Testing Empty State


test('displays empty state when no posts', async () => {
  // Override handler to return empty array
  server.use(
    rest.get('/api/posts', (req, res, ctx) => {
      return res(ctx.json([]));
    })
  );
  
  render(<PostList />);
  
  expect(await screen.findByText('No posts found')).toBeInTheDocument();
});
                

Testing with Different Data


test('displays correct number of posts', async () => {
  server.use(
    rest.get('/api/posts', (req, res, ctx) => {
      return res(
        ctx.json([
          { id: '1', title: 'Post 1', body: 'Body 1' },
          { id: '2', title: 'Post 2', body: 'Body 2' },
          { id: '3', title: 'Post 3', body: 'Body 3' }
        ])
      );
    })
  );
  
  render(<PostList />);
  
  await waitFor(() => {
    const posts = screen.getAllByRole('listitem');
    expect(posts).toHaveLength(3);
  });
});
                

βœ… findBy vs waitFor - When to Use Which?

Use findBy when:

  • Waiting for a single element to appear
  • The test is simple and straightforward
  • You want cleaner, more readable code

Use waitFor when:

  • Making multiple assertions that need to pass together
  • Waiting for something to disappear
  • Complex conditions that can't be expressed with a single query
  • Need custom timeout or retry intervals

🎭 Mock Service Worker Deep Dive

Mock Service Worker (MSW) is the gold standard for mocking APIs in tests. It intercepts network requests at the network level, making tests more realistic.

Why MSW Over fetch Mocking?

Approach Pros Cons
Mock fetch Simple, no dependencies Brittle, doesn't test network layer, hard to maintain
Mock axios/API client Easier than mocking fetch Tied to implementation, misses network issues
MSW Realistic, reusable, works with any HTTP client Extra dependency, slightly more setup

Complete MSW Setup


// src/mocks/handlers.ts
import { rest } from 'msw';

export const handlers = [
  // GET request with dynamic params
  rest.get('/api/users/:id', (req, res, ctx) => {
    const { id } = req.params;
    
    return res(
      ctx.status(200),
      ctx.json({
        id,
        name: `User ${id}`,
        email: `user${id}@example.com`
      })
    );
  }),
  
  // POST request with request body
  rest.post('/api/users', async (req, res, ctx) => {
    const body = await req.json();
    
    return res(
      ctx.status(201),
      ctx.json({
        id: Math.random().toString(),
        ...body,
        createdAt: new Date().toISOString()
      })
    );
  }),
  
  // PUT request
  rest.put('/api/users/:id', async (req, res, ctx) => {
    const { id } = req.params;
    const body = await req.json();
    
    return res(
      ctx.status(200),
      ctx.json({
        id,
        ...body,
        updatedAt: new Date().toISOString()
      })
    );
  }),
  
  // DELETE request
  rest.delete('/api/users/:id', (req, res, ctx) => {
    return res(
      ctx.status(204)
    );
  }),
  
  // Query parameters
  rest.get('/api/search', (req, res, ctx) => {
    const query = req.url.searchParams.get('q');
    const page = req.url.searchParams.get('page') || '1';
    
    return res(
      ctx.json({
        query,
        page: parseInt(page),
        results: [`Result for ${query}`]
      })
    );
  })
];

// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// src/test/setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from '../mocks/server';

// Start server before all tests
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));

// Reset handlers after each test
afterEach(() => server.resetHandlers());

// Clean up after all tests
afterAll(() => server.close());
                

Simulating Different Response Times


test('handles slow API responses', async () => {
  server.use(
    rest.get('/api/posts', async (req, res, ctx) => {
      // Delay response by 2 seconds
      await ctx.delay(2000);
      
      return res(
        ctx.json([{ id: '1', title: 'Post' }])
      );
    })
  );
  
  render(<PostList />);
  
  // Should show loading for a while
  expect(screen.getByText('Loading posts...')).toBeInTheDocument();
  
  // Eventually loads
  expect(await screen.findByText('Post', {}, { timeout: 3000 })).toBeInTheDocument();
});
                

Simulating Network Errors


test('handles network errors', async () => {
  server.use(
    rest.get('/api/posts', (req, res, ctx) => {
      return res.networkError('Failed to connect');
    })
  );
  
  render(<PostList />);
  
  expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
                

Testing Different Status Codes


test('handles 404 not found', async () => {
  server.use(
    rest.get('/api/posts', (req, res, ctx) => {
      return res(
        ctx.status(404),
        ctx.json({ message: 'Not found' })
      );
    })
  );
  
  render(<PostList />);
  
  expect(await screen.findByText('Error: Failed to fetch')).toBeInTheDocument();
});

test('handles 500 server error', async () => {
  server.use(
    rest.get('/api/posts', (req, res, ctx) => {
      return res(
        ctx.status(500),
        ctx.json({ message: 'Internal server error' })
      );
    })
  );
  
  render(<PostList />);
  
  expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
                

Per-Test Handler Overrides


describe('PostList with different scenarios', () => {
  it('shows success state', async () => {
    // Uses default handler
    render(<PostList />);
    expect(await screen.findByText('First Post')).toBeInTheDocument();
  });
  
  it('shows error state', async () => {
    // Override for this test only
    server.use(
      rest.get('/api/posts', (req, res, ctx) => {
        return res(ctx.status(500));
      })
    );
    
    render(<PostList />);
    expect(await screen.findByText(/error/i)).toBeInTheDocument();
  });
  
  it('shows empty state', async () => {
    // Override for this test only
    server.use(
      rest.get('/api/posts', (req, res, ctx) => {
        return res(ctx.json([]));
      })
    );
    
    render(<PostList />);
    expect(await screen.findByText('No posts found')).toBeInTheDocument();
  });
});
                

πŸ’‘ MSW Best Practices

  • Create realistic responses: Mirror your actual API structure
  • Use request handlers for each endpoint: Keep handlers organized
  • Reset handlers after each test: Prevents test pollution
  • Handle all HTTP methods: GET, POST, PUT, DELETE, PATCH
  • Test various response scenarios: Success, errors, timeouts
  • Use ctx.delay() sparingly: Don't slow down your test suite unnecessarily

⏳ Testing Loading States

Loading states are crucial for user experience. Let's ensure they work correctly in all scenarios.

Testing Sequential Loading States


function DataComponent() {
  const [data, setData] = React.useState(null);
  const [loading, setLoading] = React.useState(true);
  
  React.useEffect(() => {
    setLoading(true);
    fetch('/api/data')
      .then(res => res.json())
      .then(data => {
        setData(data);
        setLoading(false);
      });
  }, []);
  
  return (
    <div>
      {loading && <div data-testid="spinner">Loading...</div>}
      {!loading && data && <div>{data.value}</div>}
    </div>
  );
}

test('shows loading spinner then data', async () => {
  server.use(
    rest.get('/api/data', (req, res, ctx) => {
      return res(
        ctx.delay(100), // Simulate network delay
        ctx.json({ value: 'Loaded!' })
      );
    })
  );
  
  render(<DataComponent />);
  
  // Should show loading initially
  expect(screen.getByTestId('spinner')).toBeInTheDocument();
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  
  // Wait for data to load
  await waitFor(() => {
    expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
  });
  
  expect(screen.getByText('Loaded!')).toBeInTheDocument();
});
                

Testing Multiple Loading Indicators


function MultiStepLoader() {
  const [step, setStep] = React.useState<'idle' | 'fetching' | 'processing' | 'done'>('idle');
  
  const handleLoad = async () => {
    setStep('fetching');
    await new Promise(resolve => setTimeout(resolve, 100));
    
    setStep('processing');
    await new Promise(resolve => setTimeout(resolve, 100));
    
    setStep('done');
  };
  
  return (
    <div>
      <button onClick={handleLoad}>Load Data</button>
      {step === 'fetching' && <div>Fetching data...</div>}
      {step === 'processing' && <div>Processing data...</div>}
      {step === 'done' && <div>Complete!</div>}
    </div>
  );
}

test('shows correct loading message at each step', async () => {
  const user = userEvent.setup();
  render(<MultiStepLoader />);
  
  await user.click(screen.getByRole('button', { name: 'Load Data' }));
  
  // Step 1: Fetching
  expect(screen.getByText('Fetching data...')).toBeInTheDocument();
  
  // Step 2: Processing
  await waitFor(() => {
    expect(screen.getByText('Processing data...')).toBeInTheDocument();
  });
  
  // Step 3: Complete
  await waitFor(() => {
    expect(screen.getByText('Complete!')).toBeInTheDocument();
  });
});
                

Testing Skeleton Screens


function UserCard({ userId }: { userId: string }) {
  const [user, setUser] = React.useState(null);
  const [loading, setLoading] = React.useState(true);
  
  React.useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      });
  }, [userId]);
  
  if (loading) {
    return (
      <div data-testid="skeleton">
        <div className="skeleton-avatar" />
        <div className="skeleton-line" />
        <div className="skeleton-line" />
      </div>
    );
  }
  
  return (
    <div>
      <img src={user.avatar} alt={user.name} />
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

test('shows skeleton screen while loading', async () => {
  server.use(
    rest.get('/api/users/:id', (req, res, ctx) => {
      return res(
        ctx.delay(100),
        ctx.json({
          name: 'John Doe',
          email: 'john@example.com',
          avatar: '/avatar.jpg'
        })
      );
    })
  );
  
  render(<UserCard userId="123" />);
  
  // Skeleton should be visible
  const skeleton = screen.getByTestId('skeleton');
  expect(skeleton).toBeInTheDocument();
  expect(skeleton.querySelector('.skeleton-avatar')).toBeInTheDocument();
  
  // Wait for actual content
  await waitFor(() => {
    expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument();
  });
  
  expect(screen.getByText('John Doe')).toBeInTheDocument();
});
                

Testing Progress Indicators


function FileUploadProgress() {
  const [progress, setProgress] = React.useState(0);
  const [status, setStatus] = React.useState<'idle' | 'uploading' | 'complete'>('idle');
  
  const handleUpload = () => {
    setStatus('uploading');
    
    // Simulate progress
    const interval = setInterval(() => {
      setProgress(prev => {
        if (prev >= 100) {
          clearInterval(interval);
          setStatus('complete');
          return 100;
        }
        return prev + 10;
      });
    }, 100);
  };
  
  return (
    <div>
      <button onClick={handleUpload} disabled={status === 'uploading'}>
        Upload
      </button>
      {status === 'uploading' && (
        <div>
          <div>Uploading... {progress}%</div>
          <progress value={progress} max={100} />
        </div>
      )}
      {status === 'complete' && <div>Upload complete!</div>}
    </div>
  );
}

test('shows upload progress', async () => {
  vi.useFakeTimers();
  const user = userEvent.setup({ delay: null }); // Disable userEvent's built-in delay
  
  render(<FileUploadProgress />);
  
  await user.click(screen.getByRole('button', { name: 'Upload' }));
  
  // Should start uploading
  expect(screen.getByText('Uploading... 0%')).toBeInTheDocument();
  
  // Advance time and check progress
  act(() => {
    vi.advanceTimersByTime(500);
  });
  
  expect(screen.getByText('Uploading... 50%')).toBeInTheDocument();
  
  // Complete upload
  act(() => {
    vi.advanceTimersByTime(500);
  });
  
  expect(screen.getByText('Upload complete!')).toBeInTheDocument();
  
  vi.useRealTimers();
});
                

βœ… Loading State Testing Checklist

  • βœ“ Test that loading indicator appears immediately
  • βœ“ Test that loading indicator disappears after data loads
  • βœ“ Test multiple sequential loading states
  • βœ“ Test that buttons are disabled during loading
  • βœ“ Test progress indicators update correctly
  • βœ“ Test skeleton screens render proper structure

❌ Testing Error Handling

Robust applications handle errors gracefully. Let's ensure your error handling works as expected.

Testing Network Errors


function RobustDataFetcher() {
  const [data, setData] = React.useState(null);
  const [error, setError] = React.useState<string | null>(null);
  const [loading, setLoading] = React.useState(false);
  
  const fetchData = async () => {
    setLoading(true);
    setError(null);
    
    try {
      const response = await fetch('/api/data');
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }
      
      const json = await response.json();
      setData(json);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'An error occurred');
    } finally {
      setLoading(false);
    }
  };
  
  return (
    <div>
      <button onClick={fetchData} disabled={loading}>
        Fetch Data
      </button>
      
      {loading && <div>Loading...</div>}
      {error && <div role="alert">Error: {error}</div>}
      {data && <div>Data: {JSON.stringify(data)}</div>}
    </div>
  );
}

test('handles network error', async () => {
  const user = userEvent.setup();
  
  server.use(
    rest.get('/api/data', (req, res, ctx) => {
      return res.networkError('Failed to connect');
    })
  );
  
  render(<RobustDataFetcher />);
  
  await user.click(screen.getByRole('button', { name: 'Fetch Data' }));
  
  expect(await screen.findByRole('alert')).toHaveTextContent(/error/i);
  expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});

test('handles 404 error', async () => {
  const user = userEvent.setup();
  
  server.use(
    rest.get('/api/data', (req, res, ctx) => {
      return res(
        ctx.status(404),
        ctx.text('Not Found')
      );
    })
  );
  
  render(<RobustDataFetcher />);
  
  await user.click(screen.getByRole('button', { name: 'Fetch Data' }));
  
  expect(await screen.findByRole('alert')).toHaveTextContent(/HTTP 404/i);
});

test('handles 500 error', async () => {
  const user = userEvent.setup();
  
  server.use(
    rest.get('/api/data', (req, res, ctx) => {
      return res(
        ctx.status(500),
        ctx.json({ message: 'Internal Server Error' })
      );
    })
  );
  
  render(<RobustDataFetcher />);
  
  await user.click(screen.getByRole('button', { name: 'Fetch Data' }));
  
  expect(await screen.findByRole('alert')).toHaveTextContent(/HTTP 500/i);
});
                

Testing Retry Logic


function DataFetcherWithRetry() {
  const [data, setData] = React.useState(null);
  const [error, setError] = React.useState(null);
  const [retryCount, setRetryCount] = React.useState(0);
  const maxRetries = 3;
  
  const fetchData = async () => {
    try {
      const response = await fetch('/api/data');
      if (!response.ok) throw new Error('Failed');
      
      const json = await response.json();
      setData(json);
      setRetryCount(0);
    } catch (err) {
      if (retryCount < maxRetries) {
        setRetryCount(prev => prev + 1);
        setTimeout(fetchData, 1000);
      } else {
        setError('Failed after 3 retries');
      }
    }
  };
  
  React.useEffect(() => {
    fetchData();
  }, []);
  
  if (error) return <div>Error: {error}</div>;
  if (!data) return <div>Loading... (Attempt {retryCount + 1})</div>;
  return <div>Data: {data.value}</div>;
}

test('retries on failure and eventually succeeds', async () => {
  let attempts = 0;
  
  server.use(
    rest.get('/api/data', (req, res, ctx) => {
      attempts++;
      
      if (attempts < 3) {
        return res(ctx.status(500));
      }
      
      return res(ctx.json({ value: 'Success!' }));
    })
  );
  
  vi.useFakeTimers();
  render(<DataFetcherWithRetry />);
  
  // First attempt fails
  expect(screen.getByText('Loading... (Attempt 1)')).toBeInTheDocument();
  
  // Wait for retry
  act(() => {
    vi.advanceTimersByTime(1000);
  });
  
  await waitFor(() => {
    expect(screen.getByText('Loading... (Attempt 2)')).toBeInTheDocument();
  });
  
  // Second retry
  act(() => {
    vi.advanceTimersByTime(1000);
  });
  
  // Eventually succeeds
  expect(await screen.findByText('Data: Success!')).toBeInTheDocument();
  
  vi.useRealTimers();
});

test('gives up after max retries', async () => {
  server.use(
    rest.get('/api/data', (req, res, ctx) => {
      return res(ctx.status(500));
    })
  );
  
  vi.useFakeTimers();
  render(<DataFetcherWithRetry />);
  
  // Fast-forward through all retries
  act(() => {
    vi.advanceTimersByTime(4000);
  });
  
  expect(await screen.findByText('Error: Failed after 3 retries')).toBeInTheDocument();
  
  vi.useRealTimers();
});
                

Testing Error Recovery


function ErrorRecoveryComponent() {
  const [data, setData] = React.useState(null);
  const [error, setError] = React.useState(null);
  
  const fetchData = async () => {
    setError(null);
    try {
      const response = await fetch('/api/data');
      if (!response.ok) throw new Error('Failed to fetch');
      const json = await response.json();
      setData(json);
    } catch (err) {
      setError(err.message);
    }
  };
  
  React.useEffect(() => {
    fetchData();
  }, []);
  
  if (error) {
    return (
      <div>
        <div>Error: {error}</div>
        <button onClick={fetchData}>Try Again</button>
      </div>
    );
  }
  
  if (!data) return <div>Loading...</div>;
  return <div>Data: {data.value}</div>;
}

test('allows user to retry after error', async () => {
  const user = userEvent.setup();
  
  // First request fails
  server.use(
    rest.get('/api/data', (req, res, ctx) => {
      return res(ctx.status(500));
    })
  );
  
  render(<ErrorRecoveryComponent />);
  
  // Wait for error
  expect(await screen.findByText(/error/i)).toBeInTheDocument();
  
  // Fix the API
  server.use(
    rest.get('/api/data', (req, res, ctx) => {
      return res(ctx.json({ value: 'Success!' }));
    })
  );
  
  // Click retry
  await user.click(screen.getByRole('button', { name: 'Try Again' }));
  
  // Should succeed now
  expect(await screen.findByText('Data: Success!')).toBeInTheDocument();
});
                

⚠️ Error Testing Best Practices

  • Test all error types: Network, HTTP status codes, timeout, parsing errors
  • Test error messages: Ensure they're user-friendly
  • Test error recovery: Can users retry? Does retry work?
  • Test error boundaries: Do errors crash the app or get caught?
  • Test cleanup: Are errors cleared on retry?

🏁 Testing Race Conditions

Race conditions occur when the order of async operations matters. Let's test that your components handle them correctly.

Testing Rapid Requests


function SearchComponent() {
  const [query, setQuery] = React.useState('');
  const [results, setResults] = React.useState<string[]>([]);
  
  React.useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }
    
    let cancelled = false;
    
    fetch(`/api/search?q=${query}`)
      .then(res => res.json())
      .then(data => {
        if (!cancelled) {
          setResults(data.results);
        }
      });
    
    return () => {
      cancelled = true;
    };
  }, [query]);
  
  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <ul>
        {results.map((result, i) => (
          <li key={i}>{result}</li>
        ))}
      </ul>
    </div>
  );
}

test('handles rapid search queries correctly', async () => {
  const user = userEvent.setup();
  
  server.use(
    rest.get('/api/search', async (req, res, ctx) => {
      const query = req.url.searchParams.get('q');
      
      // Simulate different response times
      const delay = query === 'first' ? 200 : 50;
      await ctx.delay(delay);
      
      return res(
        ctx.json({
          results: [`Result for ${query}`]
        })
      );
    })
  );
  
  render(<SearchComponent />);
  
  const input = screen.getByPlaceholderText('Search...');
  
  // Type "first" - slow response
  await user.type(input, 'first');
  
  // Immediately change to "second" - fast response
  await user.clear(input);
  await user.type(input, 'second');
  
  // Should show results for "second", not "first"
  await waitFor(() => {
    expect(screen.getByText('Result for second')).toBeInTheDocument();
  });
  
  expect(screen.queryByText('Result for first')).not.toBeInTheDocument();
});
                

Testing Request Cancellation


function DataFetcherWithAbort() {
  const [data, setData] = React.useState(null);
  const [loading, setLoading] = React.useState(false);
  const abortControllerRef = React.useRef<AbortController | null>(null);
  
  const fetchData = async (id: string) => {
    // Cancel previous request
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }
    
    abortControllerRef.current = new AbortController();
    setLoading(true);
    
    try {
      const response = await fetch(`/api/data/${id}`, {
        signal: abortControllerRef.current.signal
      });
      
      const json = await response.json();
      setData(json);
    } catch (err) {
      if (err.name !== 'AbortError') {
        console.error(err);
      }
    } finally {
      setLoading(false);
    }
  };
  
  return (
    <div>
      <button onClick={() => fetchData('1')}>Fetch 1</button>
      <button onClick={() => fetchData('2')}>Fetch 2</button>
      {loading && <div>Loading...</div>}
      {data && <div>Data: {data.id}</div>}
    </div>
  );
}

test('cancels previous request when new one starts', async () => {
  const user = userEvent.setup();
  
  let request1Completed = false;
  let request2Completed = false;
  
  server.use(
    rest.get('/api/data/1', async (req, res, ctx) => {
      await ctx.delay(200);
      request1Completed = true;
      return res(ctx.json({ id: '1' }));
    }),
    rest.get('/api/data/2', async (req, res, ctx) => {
      await ctx.delay(50);
      request2Completed = true;
      return res(ctx.json({ id: '2' }));
    })
  );
  
  render(<DataFetcherWithAbort />);
  
  // Start first request
  await user.click(screen.getByRole('button', { name: 'Fetch 1' }));
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  
  // Immediately start second request (should cancel first)
  await user.click(screen.getByRole('button', { name: 'Fetch 2' }));
  
  // Wait for second request to complete
  await waitFor(() => {
    expect(screen.getByText('Data: 2')).toBeInTheDocument();
  });
  
  // Second request should have completed
  expect(request2Completed).toBe(true);
  
  // First request may or may not have completed (depends on abort timing)
  // But we should only see data from request 2
  expect(screen.queryByText('Data: 1')).not.toBeInTheDocument();
});
                

Testing Dependent Requests


function UserWithPosts({ userId }: { userId: string }) {
  const [user, setUser] = React.useState(null);
  const [posts, setPosts] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  
  React.useEffect(() => {
    setLoading(true);
    
    // Fetch user first
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(userData => {
        setUser(userData);
        
        // Then fetch their posts
        return fetch(`/api/users/${userId}/posts`);
      })
      .then(res => res.json())
      .then(postsData => {
        setPosts(postsData);
        setLoading(false);
      });
  }, [userId]);
  
  if (loading) return <div>Loading...</div>;
  
  return (
    <div>
      <h2>{user.name}</h2>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

test('fetches user then posts in sequence', async () => {
  const userFetchOrder: string[] = [];
  
  server.use(
    rest.get('/api/users/:id', async (req, res, ctx) => {
      userFetchOrder.push('user');
      await ctx.delay(50);
      return res(ctx.json({ name: 'John Doe' }));
    }),
    rest.get('/api/users/:id/posts', async (req, res, ctx) => {
      userFetchOrder.push('posts');
      await ctx.delay(50);
      return res(
        ctx.json([
          { id: '1', title: 'First Post' },
          { id: '2', title: 'Second Post' }
        ])
      );
    })
  );
  
  render(<UserWithPosts userId="123" />);
  
  // Wait for both requests to complete
  await waitFor(() => {
    expect(screen.getByText('John Doe')).toBeInTheDocument();
  });
  
  expect(screen.getByText('First Post')).toBeInTheDocument();
  expect(screen.getByText('Second Post')).toBeInTheDocument();
  
  // Verify requests happened in correct order
  expect(userFetchOrder).toEqual(['user', 'posts']);
});
                

πŸ’‘ Race Condition Testing Tips

  • Use cleanup functions: Test that effects cancel previous requests
  • Test rapid changes: Simulate users typing fast or clicking rapidly
  • Verify final state: Ensure the latest request wins
  • Test request order: For dependent requests, verify sequence
  • Use AbortController: Test proper request cancellation

πŸ”„ Testing React Query

React Query (TanStack Query) is a popular data fetching library. Testing it requires understanding its query cache and hooks.

Basic React Query Component


import { useQuery } from '@tanstack/react-query';

interface User {
  id: string;
  name: string;
  email: string;
}

function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json())
  });
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error loading user</div>;
  
  return (
    <div>
      <h2>{data.name}</h2>
      <p>{data.email}</p>
    </div>
  );
}
                

Setting Up Query Client for Tests


import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';

// Create a test wrapper with QueryClient
function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false, // Don't retry failed queries in tests
        cacheTime: 0, // Don't cache between tests
      },
    },
  });
}

function renderWithQueryClient(ui: React.ReactElement) {
  const testQueryClient = createTestQueryClient();
  
  return render(
    <QueryClientProvider client={testQueryClient}>
      {ui}
    </QueryClientProvider>
  );
}
                

Testing React Query Success State


test('displays user data from React Query', async () => {
  server.use(
    rest.get('/api/users/123', (req, res, ctx) => {
      return res(
        ctx.json({
          id: '123',
          name: 'John Doe',
          email: 'john@example.com'
        })
      );
    })
  );
  
  renderWithQueryClient(<UserProfile userId="123" />);
  
  // Shows loading initially
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  
  // Shows data after loading
  expect(await screen.findByText('John Doe')).toBeInTheDocument();
  expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
                

Testing React Query Error State


test('handles React Query errors', async () => {
  server.use(
    rest.get('/api/users/123', (req, res, ctx) => {
      return res(ctx.status(500));
    })
  );
  
  renderWithQueryClient(<UserProfile userId="123" />);
  
  expect(await screen.findByText('Error loading user')).toBeInTheDocument();
});
                

Testing Mutations


import { useMutation, useQueryClient } from '@tanstack/react-query';

function CreateUserForm() {
  const queryClient = useQueryClient();
  const [name, setName] = React.useState('');
  
  const mutation = useMutation({
    mutationFn: (newUser: { name: string }) => 
      fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newUser)
      }).then(res => res.json()),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    }
  });
  
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    mutation.mutate({ name });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
        placeholder="User name"
      />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Creating...' : 'Create User'}
      </button>
      {mutation.isError && <div>Error creating user</div>}
      {mutation.isSuccess && <div>User created!</div>}
    </form>
  );
}

test('creates user with mutation', async () => {
  const user = userEvent.setup();
  
  server.use(
    rest.post('/api/users', async (req, res, ctx) => {
      const body = await req.json();
      return res(
        ctx.json({
          id: '123',
          ...body
        })
      );
    })
  );
  
  renderWithQueryClient(<CreateUserForm />);
  
  const input = screen.getByPlaceholderText('User name');
  const button = screen.getByRole('button');
  
  await user.type(input, 'Jane Doe');
  await user.click(button);
  
  // Button shows loading state
  expect(screen.getByText('Creating...')).toBeInTheDocument();
  
  // Success message appears
  expect(await screen.findByText('User created!')).toBeInTheDocument();
});
                

Testing Query Invalidation


test('invalidates queries after mutation', async () => {
  const user = userEvent.setup();
  let queryCount = 0;
  
  server.use(
    rest.get('/api/users', (req, res, ctx) => {
      queryCount++;
      return res(
        ctx.json([{ id: '1', name: `User ${queryCount}` }])
      );
    }),
    rest.post('/api/users', async (req, res, ctx) => {
      return res(ctx.json({ id: '2', name: 'New User' }));
    })
  );
  
  renderWithQueryClient(
    <>
      <UserList />
      <CreateUserForm />
    </>
  );
  
  // Initial query
  expect(await screen.findByText('User 1')).toBeInTheDocument();
  expect(queryCount).toBe(1);
  
  // Create new user
  await user.type(screen.getByPlaceholderText('User name'), 'New User');
  await user.click(screen.getByText('Create User'));
  
  // Query should be invalidated and refetched
  await waitFor(() => {
    expect(queryCount).toBe(2);
  });
});
                

Testing with Prefilled Cache


test('uses cached data', async () => {
  const testQueryClient = createTestQueryClient();
  
  // Prefill cache
  testQueryClient.setQueryData(['user', '123'], {
    id: '123',
    name: 'Cached User',
    email: 'cached@example.com'
  });
  
  render(
    <QueryClientProvider client={testQueryClient}>
      <UserProfile userId="123" />
    </QueryClientProvider>
  );
  
  // Should immediately show cached data without loading
  expect(screen.getByText('Cached User')).toBeInTheDocument();
  expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
                

βœ… React Query Testing Tips

  • Disable retries: Set retry: false in test config
  • Clear cache: Use cacheTime: 0 to prevent test pollution
  • Create fresh clients: New QueryClient for each test
  • Test invalidation: Verify cache is refreshed after mutations
  • Use MSW for API: Mock at network level, not Query functions
  • Test all states: idle, loading, success, error

πŸš€ Advanced Async Patterns

Let's explore testing more complex async scenarios like infinite scroll, pagination, polling, and debouncing.

Testing Infinite Scroll


function InfinitePostList() {
  const [posts, setPosts] = React.useState<Post[]>([]);
  const [page, setPage] = React.useState(1);
  const [hasMore, setHasMore] = React.useState(true);
  const [loading, setLoading] = React.useState(false);
  
  const loadMore = React.useCallback(async () => {
    if (loading || !hasMore) return;
    
    setLoading(true);
    const response = await fetch(`/api/posts?page=${page}`);
    const data = await response.json();
    
    setPosts(prev => [...prev, ...data.posts]);
    setHasMore(data.hasMore);
    setPage(prev => prev + 1);
    setLoading(false);
  }, [page, loading, hasMore]);
  
  React.useEffect(() => {
    loadMore();
  }, []);
  
  return (
    <div>
      {posts.map(post => (
        <article key={post.id}>{post.title}</article>
      ))}
      {hasMore && (
        <button onClick={loadMore} disabled={loading}>
          {loading ? 'Loading...' : 'Load More'}
        </button>
      )}
      {!hasMore && <p>No more posts</p>}
    </div>
  );
}

test('loads more posts on button click', async () => {
  const user = userEvent.setup();
  let requestCount = 0;
  
  server.use(
    rest.get('/api/posts', (req, res, ctx) => {
      const page = req.url.searchParams.get('page') || '1';
      requestCount++;
      
      return res(
        ctx.json({
          posts: [
            { id: `${page}-1`, title: `Post ${page}-1` },
            { id: `${page}-2`, title: `Post ${page}-2` }
          ],
          hasMore: parseInt(page) < 3
        })
      );
    })
  );
  
  render(<InfinitePostList />);
  
  // First page loads automatically
  expect(await screen.findByText('Post 1-1')).toBeInTheDocument();
  expect(screen.getByText('Post 1-2')).toBeInTheDocument();
  expect(requestCount).toBe(1);
  
  // Click load more
  const loadMoreButton = screen.getByText('Load More');
  await user.click(loadMoreButton);
  
  // Second page appears
  expect(await screen.findByText('Post 2-1')).toBeInTheDocument();
  expect(screen.getByText('Post 2-2')).toBeInTheDocument();
  expect(requestCount).toBe(2);
  
  // Still has more
  expect(screen.getByText('Load More')).toBeInTheDocument();
  
  // Load final page
  await user.click(screen.getByText('Load More'));
  
  expect(await screen.findByText('Post 3-1')).toBeInTheDocument();
  expect(requestCount).toBe(3);
  
  // No more posts
  expect(screen.queryByText('Load More')).not.toBeInTheDocument();
  expect(screen.getByText('No more posts')).toBeInTheDocument();
});
                

Testing Pagination


function PaginatedList() {
  const [page, setPage] = React.useState(1);
  const [data, setData] = React.useState<any>(null);
  
  React.useEffect(() => {
    fetch(`/api/posts?page=${page}`)
      .then(res => res.json())
      .then(setData);
  }, [page]);
  
  if (!data) return <div>Loading...</div>;
  
  return (
    <div>
      {data.posts.map((post: Post) => (
        <div key={post.id}>{post.title}</div>
      ))}
      <button
        onClick={() => setPage(p => p - 1)}
        disabled={page === 1}
      >
        Previous
      </button>
      <span>Page {page} of {data.totalPages}</span>
      <button
        onClick={() => setPage(p => p + 1)}
        disabled={page === data.totalPages}
      >
        Next
      </button>
    </div>
  );
}

test('navigates through pages', async () => {
  const user = userEvent.setup();
  
  server.use(
    rest.get('/api/posts', (req, res, ctx) => {
      const page = parseInt(req.url.searchParams.get('page') || '1');
      
      return res(
        ctx.json({
          posts: [
            { id: `${page}-1`, title: `Page ${page} Post 1` }
          ],
          totalPages: 3
        })
      );
    })
  );
  
  render(<PaginatedList />);
  
  // First page
  expect(await screen.findByText('Page 1 Post 1')).toBeInTheDocument();
  expect(screen.getByText('Page 1 of 3')).toBeInTheDocument();
  
  // Previous button disabled on first page
  expect(screen.getByText('Previous')).toBeDisabled();
  
  // Go to next page
  await user.click(screen.getByText('Next'));
  
  expect(await screen.findByText('Page 2 Post 1')).toBeInTheDocument();
  expect(screen.getByText('Page 2 of 3')).toBeInTheDocument();
  
  // Both buttons enabled
  expect(screen.getByText('Previous')).not.toBeDisabled();
  expect(screen.getByText('Next')).not.toBeDisabled();
  
  // Go to last page
  await user.click(screen.getByText('Next'));
  
  expect(await screen.findByText('Page 3 Post 1')).toBeInTheDocument();
  expect(screen.getByText('Next')).toBeDisabled();
});
                

Testing Debounced Search


import { debounce } from 'lodash';

function SearchComponent() {
  const [query, setQuery] = React.useState('');
  const [results, setResults] = React.useState<string[]>([]);
  const [searching, setSearching] = React.useState(false);
  
  const searchApi = React.useMemo(
    () => debounce(async (searchQuery: string) => {
      if (!searchQuery) {
        setResults([]);
        return;
      }
      
      setSearching(true);
      const response = await fetch(`/api/search?q=${searchQuery}`);
      const data = await response.json();
      setResults(data.results);
      setSearching(false);
    }, 300),
    []
  );
  
  React.useEffect(() => {
    searchApi(query);
  }, [query, searchApi]);
  
  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search..."
      />
      {searching && <div>Searching...</div>}
      <ul>
        {results.map((result, i) => (
          <li key={i}>{result}</li>
        ))}
      </ul>
    </div>
  );
}

test('debounces search requests', async () => {
  const user = userEvent.setup();
  let requestCount = 0;
  
  server.use(
    rest.get('/api/search', (req, res, ctx) => {
      requestCount++;
      const query = req.url.searchParams.get('q');
      
      return res(
        ctx.json({
          results: [`Result for ${query}`]
        })
      );
    })
  );
  
  render(<SearchComponent />);
  
  const input = screen.getByPlaceholderText('Search...');
  
  // Type quickly - should only trigger one request after debounce
  await user.type(input, 'react');
  
  // Wait for debounce and search
  expect(await screen.findByText('Result for react')).toBeInTheDocument();
  
  // Should have made only one request despite typing 5 characters
  expect(requestCount).toBe(1);
});

test('cancels previous search when typing continues', async () => {
  const user = userEvent.setup();
  const searches: string[] = [];
  
  server.use(
    rest.get('/api/search', async (req, res, ctx) => {
      const query = req.url.searchParams.get('q');
      searches.push(query || '');
      
      // Simulate slow search
      await ctx.delay(100);
      
      return res(
        ctx.json({
          results: [`Result for ${query}`]
        })
      );
    })
  );
  
  render(<SearchComponent />);
  
  const input = screen.getByPlaceholderText('Search...');
  
  // Type "re", wait, then type "act"
  await user.type(input, 're');
  await new Promise(resolve => setTimeout(resolve, 350));
  await user.type(input, 'act');
  
  // Should show final results
  expect(await screen.findByText('Result for react')).toBeInTheDocument();
  
  // Should have made two requests (after "re" and after "react")
  expect(searches.length).toBe(2);
});
                

Testing Polling


function LiveCounter() {
  const [count, setCount] = React.useState(0);
  
  React.useEffect(() => {
    const interval = setInterval(() => {
      fetch('/api/counter')
        .then(res => res.json())
        .then(data => setCount(data.count));
    }, 1000);
    
    return () => clearInterval(interval);
  }, []);
  
  return <div>Count: {count}</div>;
}

test('polls for updates', async () => {
  vi.useFakeTimers();
  let currentCount = 0;
  
  server.use(
    rest.get('/api/counter', (req, res, ctx) => {
      currentCount++;
      return res(ctx.json({ count: currentCount }));
    })
  );
  
  render(<LiveCounter />);
  
  // Initial render
  expect(screen.getByText('Count: 0')).toBeInTheDocument();
  
  // First poll (1 second later)
  vi.advanceTimersByTime(1000);
  expect(await screen.findByText('Count: 1')).toBeInTheDocument();
  
  // Second poll
  vi.advanceTimersByTime(1000);
  expect(await screen.findByText('Count: 2')).toBeInTheDocument();
  
  // Third poll
  vi.advanceTimersByTime(1000);
  expect(await screen.findByText('Count: 3')).toBeInTheDocument();
  
  vi.useRealTimers();
});
                

Testing Optimistic Updates


function TodoList() {
  const [todos, setTodos] = React.useState<Todo[]>([]);
  
  const toggleTodo = async (id: string) => {
    // Optimistic update
    setTodos(prev => 
      prev.map(todo => 
        todo.id === id 
          ? { ...todo, completed: !todo.completed }
          : todo
      )
    );
    
    try {
      await fetch(`/api/todos/${id}/toggle`, { method: 'POST' });
    } catch (error) {
      // Rollback on error
      setTodos(prev => 
        prev.map(todo => 
          todo.id === id 
            ? { ...todo, completed: !todo.completed }
            : todo
        )
      );
    }
  };
  
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <input
            type="checkbox"
            checked={todo.completed}
            onChange={() => toggleTodo(todo.id)}
          />
          {todo.title}
        </li>
      ))}
    </ul>
  );
}

test('updates UI optimistically', async () => {
  const user = userEvent.setup();
  
  server.use(
    rest.post('/api/todos/:id/toggle', async (req, res, ctx) => {
      // Simulate slow network
      await ctx.delay(500);
      return res(ctx.status(200));
    })
  );
  
  // Start with initial todos
  render(<TodoList />);
  // ... set up initial state
  
  const checkbox = screen.getByRole('checkbox');
  
  // Should be unchecked
  expect(checkbox).not.toBeChecked();
  
  // Click checkbox
  await user.click(checkbox);
  
  // Should be checked immediately (optimistic)
  expect(checkbox).toBeChecked();
  
  // Wait for API call to complete
  await waitFor(() => {
    // Still checked after API confirms
    expect(checkbox).toBeChecked();
  });
});

test('rolls back on error', async () => {
  const user = userEvent.setup();
  
  server.use(
    rest.post('/api/todos/:id/toggle', (req, res, ctx) => {
      return res(ctx.status(500));
    })
  );
  
  render(<TodoList />);
  // ... set up initial state
  
  const checkbox = screen.getByRole('checkbox');
  
  // Click checkbox
  await user.click(checkbox);
  
  // Immediately checked
  expect(checkbox).toBeChecked();
  
  // Should roll back after error
  await waitFor(() => {
    expect(checkbox).not.toBeChecked();
  });
});
                

πŸ’‘ Advanced Pattern Testing Tips

  • Use fake timers: Control debouncing and polling with vi.useFakeTimers()
  • Test incremental loading: Verify each page/batch loads correctly
  • Test optimistic updates: Check immediate UI changes and rollbacks
  • Count API calls: Ensure debouncing and caching work correctly
  • Test edge cases: Empty results, last page, errors during pagination
  • Test cleanup: Verify intervals and listeners are properly cleaned up

πŸ‹οΈ Hands-on Exercises

Practice your async testing skills with these exercises. Each builds on the concepts we've covered.

Exercise 1: Test a Profile Loader Component

Task: Create comprehensive tests for a user profile component that fetches data from an API.

Component to test:


function UserProfileLoader({ userId }: { userId: string }) {
  const [profile, setProfile] = React.useState<Profile | null>(null);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState<string | null>(null);
  
  React.useEffect(() => {
    let cancelled = false;
    
    setLoading(true);
    setError(null);
    
    fetch(`/api/users/${userId}/profile`)
      .then(res => {
        if (!res.ok) throw new Error('Failed to load profile');
        return res.json();
      })
      .then(data => {
        if (!cancelled) {
          setProfile(data);
          setLoading(false);
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err.message);
          setLoading(false);
        }
      });
    
    return () => {
      cancelled = true;
    };
  }, [userId]);
  
  if (loading) return <div>Loading profile...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!profile) return <div>No profile found</div>;
  
  return (
    <div>
      <h2>{profile.name}</h2>
      <p>{profile.bio}</p>
      <p>Followers: {profile.followers}</p>
    </div>
  );
}
                    

Requirements:

  • Test successful profile loading
  • Test loading state display
  • Test error handling
  • Test that changing userId fetches new data
  • Test cleanup (cancelled requests don't update state)
πŸ’‘ Hint

Use MSW to mock the API endpoint. Use rerender from React Testing Library to test userId changes. For testing cleanup, unmount the component and verify state doesn't update.

βœ… Solution

describe('UserProfileLoader', () => {
  it('displays profile after loading', async () => {
    server.use(
      rest.get('/api/users/123/profile', (req, res, ctx) => {
        return res(
          ctx.json({
            name: 'Jane Doe',
            bio: 'Software Developer',
            followers: 1500
          })
        );
      })
    );
    
    render(<UserProfileLoader userId="123" />);
    
    expect(screen.getByText('Loading profile...')).toBeInTheDocument();
    
    expect(await screen.findByText('Jane Doe')).toBeInTheDocument();
    expect(screen.getByText('Software Developer')).toBeInTheDocument();
    expect(screen.getByText('Followers: 1500')).toBeInTheDocument();
  });
  
  it('handles errors', async () => {
    server.use(
      rest.get('/api/users/123/profile', (req, res, ctx) => {
        return res(ctx.status(500));
      })
    );
    
    render(<UserProfileLoader userId="123" />);
    
    expect(
      await screen.findByText('Error: Failed to load profile')
    ).toBeInTheDocument();
  });
  
  it('refetches when userId changes', async () => {
    server.use(
      rest.get('/api/users/:id/profile', (req, res, ctx) => {
        const { id } = req.params;
        return res(
          ctx.json({
            name: `User ${id}`,
            bio: `Bio ${id}`,
            followers: 100
          })
        );
      })
    );
    
    const { rerender } = render(<UserProfileLoader userId="123" />);
    
    expect(await screen.findByText('User 123')).toBeInTheDocument();
    
    rerender(<UserProfileLoader userId="456" />);
    
    expect(await screen.findByText('User 456')).toBeInTheDocument();
  });
  
  it('cancels requests on unmount', async () => {
    const consoleError = vi.spyOn(console, 'error');
    
    server.use(
      rest.get('/api/users/123/profile', async (req, res, ctx) => {
        await ctx.delay(100);
        return res(ctx.json({ name: 'Test' }));
      })
    );
    
    const { unmount } = render(<UserProfileLoader userId="123" />);
    
    // Unmount before request completes
    unmount();
    
    // Wait for request to complete
    await new Promise(resolve => setTimeout(resolve, 150));
    
    // Should not have errors from state updates after unmount
    expect(consoleError).not.toHaveBeenCalled();
    consoleError.mockRestore();
  });
});
                        

Exercise 2: Test Auto-Save Functionality

Task: Test a text editor with auto-save that debounces saves.

Component:


function AutoSaveEditor({ documentId }: { documentId: string }) {
  const [content, setContent] = React.useState('');
  const [saving, setSaving] = React.useState(false);
  const [lastSaved, setLastSaved] = React.useState<Date | null>(null);
  
  const saveDocument = React.useMemo(
    () => debounce(async (text: string) => {
      setSaving(true);
      await fetch(`/api/documents/${documentId}`, {
        method: 'PUT',
        body: JSON.stringify({ content: text })
      });
      setSaving(false);
      setLastSaved(new Date());
    }, 500),
    [documentId]
  );
  
  const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    const newContent = e.target.value;
    setContent(newContent);
    saveDocument(newContent);
  };
  
  return (
    <div>
      <textarea value={content} onChange={handleChange} />
      {saving && <span>Saving...</span>}
      {lastSaved && <span>Saved at {lastSaved.toLocaleTimeString()}</span>}
    </div>
  );
}
                    

Requirements:

  • Test that typing triggers a save after 500ms
  • Test that multiple rapid changes only trigger one save
  • Test the "Saving..." indicator appears and disappears
  • Test that saved timestamp updates
  • Test error handling during save
πŸ’‘ Hint

Use fake timers to control debouncing. Use vi.advanceTimersByTime() to move time forward. Remember to restore real timers after tests.

βœ… Solution

describe('AutoSaveEditor', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
  
  afterEach(() => {
    vi.useRealTimers();
  });
  
  it('saves after 500ms of inactivity', async () => {
    const user = userEvent.setup({ delay: null });
    let saveCount = 0;
    
    server.use(
      rest.put('/api/documents/doc1', (req, res, ctx) => {
        saveCount++;
        return res(ctx.status(200));
      })
    );
    
    render(<AutoSaveEditor documentId="doc1" />);
    
    const textarea = screen.getByRole('textbox');
    await user.type(textarea, 'Hello');
    
    // Should not save immediately
    expect(saveCount).toBe(0);
    
    // Advance time
    vi.advanceTimersByTime(500);
    
    // Should have saved once
    await waitFor(() => {
      expect(saveCount).toBe(1);
    });
  });
  
  it('debounces multiple changes', async () => {
    const user = userEvent.setup({ delay: null });
    let saveCount = 0;
    
    server.use(
      rest.put('/api/documents/doc1', (req, res, ctx) => {
        saveCount++;
        return res(ctx.status(200));
      })
    );
    
    render(<AutoSaveEditor documentId="doc1" />);
    
    const textarea = screen.getByRole('textbox');
    
    // Type multiple times
    await user.type(textarea, 'H');
    vi.advanceTimersByTime(100);
    await user.type(textarea, 'e');
    vi.advanceTimersByTime(100);
    await user.type(textarea, 'l');
    vi.advanceTimersByTime(100);
    await user.type(textarea, 'l');
    vi.advanceTimersByTime(100);
    await user.type(textarea, 'o');
    
    // Only 100ms passed, shouldn't save yet
    expect(saveCount).toBe(0);
    
    // Wait full 500ms from last change
    vi.advanceTimersByTime(500);
    
    // Should have saved only once
    await waitFor(() => {
      expect(saveCount).toBe(1);
    });
  });
  
  it('shows saving indicator', async () => {
    const user = userEvent.setup({ delay: null });
    
    server.use(
      rest.put('/api/documents/doc1', async (req, res, ctx) => {
        await ctx.delay(100);
        return res(ctx.status(200));
      })
    );
    
    render(<AutoSaveEditor documentId="doc1" />);
    
    const textarea = screen.getByRole('textbox');
    await user.type(textarea, 'Test');
    
    vi.advanceTimersByTime(500);
    
    // Should show saving indicator
    expect(await screen.findByText('Saving...')).toBeInTheDocument();
    
    // Wait for save to complete
    vi.advanceTimersByTime(100);
    
    await waitFor(() => {
      expect(screen.queryByText('Saving...')).not.toBeInTheDocument();
    });
  });
});
                        

Exercise 3: Test Real-time Notifications

Task: Test a notification system that polls for new notifications and displays them.

Requirements:

  • Polls every 5 seconds
  • Displays count of unread notifications
  • Shows notification list when clicked
  • Marks notifications as read
  • Stops polling when component unmounts
πŸ’‘ Hint

Combine fake timers with user interactions. Test polling by advancing time. Verify cleanup by checking that no more requests happen after unmount.

βœ… Solution

Try implementing this yourself first! The solution combines polling (like the LiveCounter example), user interactions (clicking to view), and state management for read/unread status.

⭐ Best Practices for Async Testing

Follow these guidelines to write reliable, maintainable async tests.

1. Always Use async/await

βœ… Do This


test('loads data', async () => {
  render(<Component />);
  expect(await screen.findByText('Loaded')).toBeInTheDocument();
});
                    

❌ Don't Do This


test('loads data', () => {
  render(<Component />);
  // This will fail - doesn't wait!
  expect(screen.getByText('Loaded')).toBeInTheDocument();
});
                    

2. Use MSW Instead of Mocking fetch

βœ… Do This


// Set up MSW handlers
server.use(
  rest.get('/api/data', (req, res, ctx) => {
    return res(ctx.json({ data: 'test' }));
  })
);
                    

❌ Don't Do This


// Mocking fetch directly is brittle
global.fetch = vi.fn().mockResolvedValue({
  json: async () => ({ data: 'test' })
});
                    

3. Test All Async States

Every async operation should test at least these states:

  • Loading: Initial state while waiting
  • Success: Data loaded successfully
  • Error: Request failed
  • Empty: No data returned (when applicable)

describe('DataComponent', () => {
  it('shows loading state', () => {
    render(<DataComponent />);
    expect(screen.getByText('Loading...')).toBeInTheDocument();
  });
  
  it('shows success state', async () => {
    render(<DataComponent />);
    expect(await screen.findByText('Data loaded')).toBeInTheDocument();
  });
  
  it('shows error state', async () => {
    server.use(
      rest.get('/api/data', (req, res, ctx) => {
        return res(ctx.status(500));
      })
    );
    
    render(<DataComponent />);
    expect(await screen.findByText('Error')).toBeInTheDocument();
  });
  
  it('shows empty state', async () => {
    server.use(
      rest.get('/api/data', (req, res, ctx) => {
        return res(ctx.json([]));
      })
    );
    
    render(<DataComponent />);
    expect(await screen.findByText('No data')).toBeInTheDocument();
  });
});
                

4. Set Appropriate Timeouts


// For slow operations, increase timeout
expect(
  await screen.findByText('Loaded', {}, { timeout: 5000 })
).toBeInTheDocument();

// Or for waitFor
await waitFor(
  () => {
    expect(screen.getByText('Loaded')).toBeInTheDocument();
  },
  { timeout: 5000 }
);
                

5. Clean Up After Tests


beforeEach(() => {
  vi.useFakeTimers();
});

afterEach(() => {
  vi.useRealTimers();
  server.resetHandlers();
  cleanup(); // Clean up rendered components
});
                

6. Test Request Parameters


test('sends correct request parameters', async () => {
  let receivedBody: any;
  
  server.use(
    rest.post('/api/users', async (req, res, ctx) => {
      receivedBody = await req.json();
      return res(ctx.json({ id: '123' }));
    })
  );
  
  // ... trigger request
  
  await waitFor(() => {
    expect(receivedBody).toEqual({
      name: 'John',
      email: 'john@example.com'
    });
  });
});
                

7. Handle Race Conditions


test('cancels previous requests', async () => {
  const user = userEvent.setup();
  let requestCount = 0;
  
  server.use(
    rest.get('/api/search', async (req, res, ctx) => {
      requestCount++;
      const query = req.url.searchParams.get('q');
      await ctx.delay(100);
      return res(ctx.json({ query }));
    })
  );
  
  render(<SearchComponent />);
  
  const input = screen.getByRole('textbox');
  
  // Type different searches rapidly
  await user.type(input, 'a');
  await user.clear(input);
  await user.type(input, 'ab');
  await user.clear(input);
  await user.type(input, 'abc');
  
  // Should show only the last result
  expect(await screen.findByText(/abc/)).toBeInTheDocument();
});
                

8. Use React Query Testing Utils


import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

function createWrapper() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
        cacheTime: 0,
      },
    },
  });
  
  return ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

test('with React Query', async () => {
  const { result } = renderHook(() => useUserQuery('123'), {
    wrapper: createWrapper()
  });
  
  await waitFor(() => expect(result.current.isSuccess).toBe(true));
  expect(result.current.data.name).toBe('John');
});
                

9. Avoid Act Warnings

πŸ’‘ Understanding act() Warnings

If you see "Warning: An update to Component inside a test was not wrapped in act(...)", it means state updates happened after your test finished.

Common causes:

  • Not waiting for async operations to complete
  • Component updating after unmount
  • Missing cleanup in useEffect

Solution: Always wait for all state updates


// Wait for all updates
await waitFor(() => {
  expect(screen.queryByText('Loading')).not.toBeInTheDocument();
});

// Or use findBy which waits automatically
await screen.findByText('Loaded');
                    

10. Test Error Boundaries


test('error boundary catches async errors', async () => {
  const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
  
  server.use(
    rest.get('/api/data', (req, res, ctx) => {
      return res(ctx.status(500));
    })
  );
  
  render(
    <ErrorBoundary fallback={<div>Something went wrong</div>}>
      <DataComponent />
    </ErrorBoundary>
  );
  
  expect(await screen.findByText('Something went wrong')).toBeInTheDocument();
  
  consoleError.mockRestore();
});
                

πŸ“‹ Async Testing Checklist

  • βœ… Mark test functions as async
  • βœ… Use await with findBy or waitFor
  • βœ… Mock APIs with MSW, not fetch directly
  • βœ… Test loading, success, error, and empty states
  • βœ… Set appropriate timeouts for slow operations
  • βœ… Clean up timers, handlers, and components
  • βœ… Test race conditions and cancellation
  • βœ… Verify request parameters and headers
  • βœ… Handle act() warnings properly
  • βœ… Test error boundaries with async errors

πŸ“š Summary

Congratulations! You've mastered testing asynchronous code in React applications. Let's recap the key concepts.

Key Takeaways

🎯 Core Concepts

  1. Async Testing Fundamentals
    • Always use async/await in tests
    • Wait for state updates with findBy or waitFor
    • Test all possible states: loading, success, error, empty
  2. Mock Service Worker (MSW)
    • Intercepts network requests at the network level
    • More realistic than mocking fetch directly
    • Reusable handlers across tests
    • Works with any HTTP client
  3. Testing Patterns
    • Use findBy for waiting for elements to appear
    • Use waitFor for complex assertions
    • Use waitForElementToBeRemoved for disappearing elements
    • Use fake timers for debouncing and polling
  4. React Query Testing
    • Create fresh QueryClient for each test
    • Disable retries and caching in tests
    • Test mutations and invalidation
    • Use MSW for API mocking
  5. Advanced Patterns
    • Test infinite scroll and pagination
    • Test debounced searches
    • Test polling and real-time updates
    • Test optimistic updates and rollbacks

Testing Async Code Workflow

graph TB A[Write Component with Async Logic] --> B[Set Up MSW Handlers] B --> C[Write Test Cases] C --> D{Test Type?} D -->|Success| E[Mock Successful Response] D -->|Error| F[Mock Error Response] D -->|Loading| G[Test Initial State] D -->|Empty| H[Mock Empty Response] E --> I[Render Component] F --> I G --> I H --> I I --> J[Wait for Async Operations] J --> K[Assert on Final State] K --> L[Clean Up] style A fill:#667eea,stroke:#333,stroke-width:2px,color:#fff style J fill:#ffc107,stroke:#333,stroke-width:2px,color:#333 style K fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff

Common Pitfalls to Avoid

Pitfall Problem Solution
Not using async/await Tests finish before async operations complete Mark test as async and use await
Mocking fetch directly Brittle tests tied to implementation Use MSW to mock at network level
Not testing all states Missing edge cases and errors Test loading, success, error, and empty states
Forgetting to clean up Test pollution and false positives/negatives Reset handlers and timers in afterEach
Ignoring race conditions Flaky tests that sometimes pass/fail Test cancellation and proper cleanup
Using real timers for debouncing Slow test suite Use fake timers with vi.useFakeTimers()

Tools and Libraries Reference

πŸ› οΈ Essential Testing Tools

  • Vitest: Test runner with modern features
  • React Testing Library: User-centric testing utilities
  • MSW (Mock Service Worker): Network request mocking
  • @testing-library/user-event: Realistic user interactions
  • @tanstack/react-query: Data fetching library

Next Steps

Now that you understand async testing, you're ready to:

  • Write comprehensive tests for data-fetching components
  • Test complex async workflows in your applications
  • Handle edge cases like race conditions and errors
  • Test React Query and other data fetching libraries
  • Build confidence in your application's async behavior

πŸŽ‰ Congratulations!

You've completed Lesson 9.4: Testing Async Code! You now have the skills to:

  • βœ… Test components that fetch data from APIs
  • βœ… Use Mock Service Worker to mock network requests
  • βœ… Handle loading states, errors, and race conditions
  • βœ… Test React Query and other data fetching libraries
  • βœ… Test advanced patterns like pagination and polling
  • βœ… Write reliable, maintainable async tests
πŸ’‘ Remember: "Good async tests are patientβ€”they wait for operations to complete, handle all states gracefully, and mirror how users actually interact with your app."