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 it is uniquely tricky because the thing you want to assert on doesn't exist yet. Picture ordering a coffee: you don't check for your latte the instant you hand over your card β€” you wait for the barista to call your name. Test the async version too eagerly and you'll "check the counter" while the machine is still brewing, see nothing, and wrongly conclude the order failed. The whole craft of async testing is learning to wait for the right signal instead of guessing.

πŸ“– What is Async Testing?

Async testing means testing code whose result isn't available on the same tick it was requested. That covers network requests, setTimeout/setInterval, Promises, async/await, and anything that resolves later. The key skill is telling the test to pause until the UI reflects the finished operation β€” then assert.

The Challenge with Async Code

Consider this component that fetches user data. It starts in a loading state, then swaps in the real data once the network resolves:


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>;
}
                

Here's the trap. If we test this synchronously β€” asserting the moment after render β€” we're inspecting the component during that very first "Loading..." tick, before React has even kicked off the fetch, let alone received a response:


// ❌ 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. Throughout this lesson we assume a modern 2026 stack: Vitest as the runner, React Testing Library for rendering and queries, user-event v14 for interactions, and MSW v2 for mocking the network. Every code sample here uses those current APIs.

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 { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { PostList } from './PostList';

// Set up an MSW v2 server. Note the shape: http.get(path, resolver),
// and the resolver returns an HttpResponse instead of calling res(ctx.*).
const server = setupServer(
  http.get('/api/posts', () => {
    return HttpResponse.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 (this assertion is synchronous and safe β€”
    // the loading UI is present on the very first render).
    expect(screen.getByText('Loading posts...')).toBeInTheDocument();

    // findByText RETURNS A PROMISE and retries until the element appears
    // (default up to 1000ms). Awaiting it is what makes the test wait.
    expect(await screen.findByText('First Post')).toBeInTheDocument();

    // By now the fetch has resolved, so a synchronous getByText is fine.
    expect(screen.getByText('Second Post')).toBeInTheDocument();

    // Loading should be gone
    expect(screen.queryByText('Loading posts...')).not.toBeInTheDocument();
  });
});
                

Trace what actually happens when that test runs. The request never leaves your machine β€” MSW intercepts it and answers from your handler β€” but from the component's point of view it's an ordinary fetch:

sequenceDiagram participant T as Test participant C as Component participant M as MSW handler T->>C: render PostList C->>M: fetch /api/posts Note over T: await findByText retries ~50ms M-->>C: HttpResponse.json C->>C: setState then rerender C-->>T: First Post now in DOM Note over T: findBy resolves then assert

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 the default handler for THIS test only
  server.use(
    http.get('/api/posts', () => {
      return HttpResponse.json([]);
    })
  );

  render(<PostList />);

  expect(await screen.findByText('No posts found')).toBeInTheDocument();
});
                

Testing with Different Data


test('displays correct number of posts', async () => {
  server.use(
    http.get('/api/posts', () => {
      return HttpResponse.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. Instead of replacing fetch with a stub, it intercepts requests at the network boundary β€” the same layer a real server would answer from. Your component runs its actual data-fetching code; MSW just supplies the response. Think of it as a stunt double that's indistinguishable from the real actor: the scene plays out normally, but nobody gets hurt (and no real API gets called).

⚠️ MSW v1 β†’ v2: what changed

If you're following older tutorials, you'll see the v1 API everywhere. MSW v2 (the current release) reworked it around the web-standard Request/Response. Here's the translation you'll use constantly:

  • rest.get(...) β†’ http.get(...) (import { http, HttpResponse })
  • (req, res, ctx) => res(ctx.json(data)) β†’ () => HttpResponse.json(data)
  • ctx.status(201) β†’ second arg: HttpResponse.json(data, { status: 201 })
  • req.params β†’ the resolver's { params } argument
  • req.url.searchParams β†’ new URL(request.url).searchParams
  • await req.json() β†’ await request.json()
  • res.networkError() β†’ HttpResponse.error(); ctx.delay(ms) β†’ await delay(ms)

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 { http, HttpResponse } from 'msw';

export const handlers = [
  // GET with a dynamic path segment β€” path params arrive in { params }
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params;

    return HttpResponse.json({
      id,
      name: `User ${id}`,
      email: `user${id}@example.com`
    });
  }),

  // POST β€” read the body off the standard Request object
  http.post('/api/users', async ({ request }) => {
    const body = (await request.json()) as Record<string, unknown>;

    // Second arg to HttpResponse.json() sets status/headers
    return HttpResponse.json(
      {
        id: crypto.randomUUID(),
        ...body,
        createdAt: new Date().toISOString()
      },
      { status: 201 }
    );
  }),

  // PUT β€” you can destructure both params and request together
  http.put('/api/users/:id', async ({ params, request }) => {
    const { id } = params;
    const body = (await request.json()) as Record<string, unknown>;

    return HttpResponse.json({
      id,
      ...body,
      updatedAt: new Date().toISOString()
    });
  }),

  // DELETE β€” a 204 carries no body, so build the response directly
  http.delete('/api/users/:id', () => {
    return new HttpResponse(null, { status: 204 });
  }),

  // Query params live on the intercepted Request's URL
  http.get('/api/search', ({ request }) => {
    const url = new URL(request.url);
    const query = url.searchParams.get('q');
    const page = url.searchParams.get('page') ?? '1';

    return HttpResponse.json({
      query,
      page: Number(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 (registered via vitest.config's setupFiles)
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from '../mocks/server';

// Start intercepting before any test runs. 'error' surfaces any request
// you forgot to mock, instead of letting it hit the real network.
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));

// Reset per-test overrides so tests stay isolated
afterEach(() => server.resetHandlers());

// Stop intercepting once the suite is done
afterAll(() => server.close());
                

Simulating Different Response Times


import { http, HttpResponse, delay } from 'msw';

test('handles slow API responses', async () => {
  server.use(
    http.get('/api/posts', async () => {
      // delay() is a standalone helper in MSW v2 (no more ctx.delay)
      await delay(2000);

      return HttpResponse.json([{ id: '1', title: 'Post' }]);
    })
  );

  render(<PostList />);

  // Should show loading for a while
  expect(screen.getByText('Loading posts...')).toBeInTheDocument();

  // findBy's third arg lets us extend the retry window past the 1s default
  expect(await screen.findByText('Post', {}, { timeout: 3000 })).toBeInTheDocument();
});
                

Simulating Network Errors


test('handles network errors', async () => {
  server.use(
    http.get('/api/posts', () => {
      // Simulates a connection failure (DNS error, offline, etc.)
      return HttpResponse.error();
    })
  );

  render(<PostList />);

  expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
                

Testing Different Status Codes


test('handles 404 not found', async () => {
  server.use(
    http.get('/api/posts', () => {
      return HttpResponse.json({ message: 'Not found' }, { status: 404 });
    })
  );

  render(<PostList />);

  expect(await screen.findByText('Error: Failed to fetch')).toBeInTheDocument();
});

test('handles 500 server error', async () => {
  server.use(
    http.get('/api/posts', () => {
      return HttpResponse.json({ message: 'Internal server error' }, { status: 500 });
    })
  );

  render(<PostList />);

  expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
                

Per-Test Handler Overrides


describe('PostList with different scenarios', () => {
  it('shows success state', async () => {
    // Uses the default handler registered in setup
    render(<PostList />);
    expect(await screen.findByText('First Post')).toBeInTheDocument();
  });

  it('shows error state', async () => {
    // server.use() adds a one-off override; afterEach resetHandlers clears it
    server.use(
      http.get('/api/posts', () => {
        return new HttpResponse(null, { status: 500 });
      })
    );

    render(<PostList />);
    expect(await screen.findByText(/error/i)).toBeInTheDocument();
  });

  it('shows empty state', async () => {
    server.use(
      http.get('/api/posts', () => {
        return HttpResponse.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 delay() sparingly: A short delay proves the loading state renders; long ones just slow your suite

⏳ 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(
    http.get('/api/data', async () => {
      await delay(100); // Simulate network latency
      return HttpResponse.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(
    http.get('/api/users/:id', async () => {
      await delay(100);
      return HttpResponse.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();
  // With fake timers, tell user-event to drive them so its own waits resolve
  const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });

  render(<FileUploadProgress />);

  await user.click(screen.getByRole('button', { name: 'Upload' }));

  // Should start uploading
  expect(screen.getByText('Uploading... 0%')).toBeInTheDocument();

  // advanceTimersByTimeAsync flushes the state updates each interval triggers,
  // so React re-renders inside act() automatically β€” no manual act() needed
  // and no "not wrapped in act(...)" warning.
  await vi.advanceTimersByTimeAsync(500);
  expect(screen.getByText('Uploading... 50%')).toBeInTheDocument();

  // Complete upload
  await vi.advanceTimersByTimeAsync(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(
    http.get('/api/data', () => {
      return HttpResponse.error();
    })
  );

  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(
    http.get('/api/data', () => {
      return HttpResponse.text('Not Found', { status: 404 });
    })
  );

  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(
    http.get('/api/data', () => {
      return HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 });
    })
  );

  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(
    http.get('/api/data', () => {
      attempts++;

      if (attempts < 3) {
        return new HttpResponse(null, { status: 500 });
      }

      return HttpResponse.json({ value: 'Success!' });
    })
  );

  vi.useFakeTimers();
  render(<DataFetcherWithRetry />);

  // First attempt fails
  expect(screen.getByText('Loading... (Attempt 1)')).toBeInTheDocument();

  // Advance past the 1s retry timer; the async variant also flushes the
  // fetch promises MSW resolves, so React updates without an act() warning
  await vi.advanceTimersByTimeAsync(1000);
  expect(screen.getByText('Loading... (Attempt 2)')).toBeInTheDocument();

  // Second retry succeeds
  await vi.advanceTimersByTimeAsync(1000);
  expect(screen.getByText('Data: Success!')).toBeInTheDocument();

  vi.useRealTimers();
});

test('gives up after max retries', async () => {
  server.use(
    http.get('/api/data', () => {
      return new HttpResponse(null, { status: 500 });
    })
  );

  vi.useFakeTimers();
  render(<DataFetcherWithRetry />);

  // Fast-forward through all three retry windows at once
  await vi.advanceTimersByTimeAsync(4000);

  expect(screen.getByText('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(
    http.get('/api/data', () => {
      return new HttpResponse(null, { status: 500 });
    })
  );

  render(<ErrorRecoveryComponent />);

  // Wait for error
  expect(await screen.findByText(/error/i)).toBeInTheDocument();

  // Fix the API β€” the newest server.use() handler wins on the next request
  server.use(
    http.get('/api/data', () => {
      return HttpResponse.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(
    http.get('/api/search', async ({ request }) => {
      const query = new URL(request.url).searchParams.get('q');

      // Simulate different response times so the slow one resolves LAST
      const responseDelay = query === 'first' ? 200 : 50;
      await delay(responseDelay);

      return HttpResponse.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(
    http.get('/api/data/1', async () => {
      await delay(200);
      request1Completed = true;
      return HttpResponse.json({ id: '1' });
    }),
    http.get('/api/data/2', async () => {
      await delay(50);
      request2Completed = true;
      return HttpResponse.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(
    http.get('/api/users/:id', async () => {
      userFetchOrder.push('user');
      await delay(50);
      return HttpResponse.json({ name: 'John Doe' });
    }),
    http.get('/api/users/:id/posts', async () => {
      userFetchOrder.push('posts');
      await delay(50);
      return HttpResponse.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, // Failed queries retry 3x by default β€” off for fast, deterministic tests
        gcTime: 0,    // TanStack Query v5 renamed cacheTime -> gcTime; 0 = no cross-test caching
      },
    },
  });
}

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(
    http.get('/api/users/123', () => {
      return HttpResponse.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(
    http.get('/api/users/123', () => {
      return new HttpResponse(null, { 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(
    http.post('/api/users', async ({ request }) => {
      const body = (await request.json()) as Record<string, unknown>;
      return HttpResponse.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(
    http.get('/api/users', () => {
      queryCount++;
      return HttpResponse.json([{ id: '1', name: `User ${queryCount}` }]);
    }),
    http.post('/api/users', () => {
      return HttpResponse.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 gcTime: 0 (the v5 name for cacheTime) 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(
    http.get('/api/posts', ({ request }) => {
      const page = new URL(request.url).searchParams.get('page') ?? '1';
      requestCount++;

      return HttpResponse.json({
        posts: [
          { id: `${page}-1`, title: `Post ${page}-1` },
          { id: `${page}-2`, title: `Post ${page}-2` }
        ],
        hasMore: Number(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(
    http.get('/api/posts', ({ request }) => {
      const page = Number(new URL(request.url).searchParams.get('page') ?? '1');

      return HttpResponse.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(
    http.get('/api/search', ({ request }) => {
      requestCount++;
      const query = new URL(request.url).searchParams.get('q');

      return HttpResponse.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(
    http.get('/api/search', async ({ request }) => {
      const query = new URL(request.url).searchParams.get('q');
      searches.push(query || '');

      // Simulate slow search
      await delay(100);

      return HttpResponse.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(
    http.get('/api/counter', () => {
      currentCount++;
      return HttpResponse.json({ count: currentCount });
    })
  );

  render(<LiveCounter />);

  // Initial render
  expect(screen.getByText('Count: 0')).toBeInTheDocument();

  // Each async advance fires the interval AND flushes the fetch it triggers,
  // so the DOM is up to date by the time we assert (no findBy needed).
  await vi.advanceTimersByTimeAsync(1000);
  expect(screen.getByText('Count: 1')).toBeInTheDocument();

  await vi.advanceTimersByTimeAsync(1000);
  expect(screen.getByText('Count: 2')).toBeInTheDocument();

  await vi.advanceTimersByTimeAsync(1000);
  expect(screen.getByText('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(
    http.post('/api/todos/:id/toggle', async () => {
      // Simulate slow network
      await delay(500);
      return new HttpResponse(null, { 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(
    http.post('/api/todos/:id/toggle', () => {
      return new HttpResponse(null, { 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(
      http.get('/api/users/123/profile', () => {
        return HttpResponse.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(
      http.get('/api/users/123/profile', () => {
        return new HttpResponse(null, { status: 500 });
      })
    );

    render(<UserProfileLoader userId="123" />);

    expect(
      await screen.findByText('Error: Failed to load profile')
    ).toBeInTheDocument();
  });

  it('refetches when userId changes', async () => {
    server.use(
      http.get('/api/users/:id/profile', ({ params }) => {
        const { id } = params;
        return HttpResponse.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(
      http.get('/api/users/123/profile', async () => {
        await delay(100);
        return HttpResponse.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 () => {
    // With fake timers, hand user-event the advance fn so its keystroke
    // waits resolve against the same fake clock
    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
    let saveCount = 0;

    server.use(
      http.put('/api/documents/doc1', () => {
        saveCount++;
        return new HttpResponse(null, { status: 200 });
      })
    );

    render(<AutoSaveEditor documentId="doc1" />);

    const textarea = screen.getByRole('textbox');
    await user.type(textarea, 'Hello');

    // Should not save immediately β€” the debounce hasn't elapsed
    expect(saveCount).toBe(0);

    // Cross the 500ms debounce; the async advance flushes the PUT too
    await vi.advanceTimersByTimeAsync(500);
    expect(saveCount).toBe(1);
  });

  it('debounces multiple changes', async () => {
    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
    let saveCount = 0;

    server.use(
      http.put('/api/documents/doc1', () => {
        saveCount++;
        return new HttpResponse(null, { status: 200 });
      })
    );

    render(<AutoSaveEditor documentId="doc1" />);

    const textarea = screen.getByRole('textbox');

    // Type multiple times, never pausing long enough to trigger a save
    await user.type(textarea, 'H');
    await vi.advanceTimersByTimeAsync(100);
    await user.type(textarea, 'e');
    await vi.advanceTimersByTimeAsync(100);
    await user.type(textarea, 'l');
    await vi.advanceTimersByTimeAsync(100);
    await user.type(textarea, 'l');
    await vi.advanceTimersByTimeAsync(100);
    await user.type(textarea, 'o');

    // Only 100ms since the last keystroke β€” shouldn't save yet
    expect(saveCount).toBe(0);

    // Wait the full 500ms from the last change
    await vi.advanceTimersByTimeAsync(500);

    // Should have saved only once, for the final value
    expect(saveCount).toBe(1);
  });

  it('shows saving indicator', async () => {
    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });

    server.use(
      http.put('/api/documents/doc1', async () => {
        await delay(100);
        return new HttpResponse(null, { status: 200 });
      })
    );

    render(<AutoSaveEditor documentId="doc1" />);

    const textarea = screen.getByRole('textbox');
    await user.type(textarea, 'Test');

    // Fire the debounced save; the PUT is still in flight (100ms delay)
    await vi.advanceTimersByTimeAsync(500);

    // Should show the saving indicator while the request is pending
    expect(screen.getByText('Saving...')).toBeInTheDocument();

    // Let the request finish
    await vi.advanceTimersByTimeAsync(100);

    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(
  http.get('/api/data', () => {
    return HttpResponse.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(
      http.get('/api/data', () => {
        return new HttpResponse(null, { status: 500 });
      })
    );

    render(<DataComponent />);
    expect(await screen.findByText('Error')).toBeInTheDocument();
  });

  it('shows empty state', async () => {
    server.use(
      http.get('/api/data', () => {
        return HttpResponse.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(
    http.post('/api/users', async ({ request }) => {
      receivedBody = await request.json();
      return HttpResponse.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(
    http.get('/api/search', async ({ request }) => {
      requestCount++;
      const query = new URL(request.url).searchParams.get('q');
      await delay(100);
      return HttpResponse.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,
        gcTime: 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');

// With FAKE timers, prefer the async advance β€” it flushes the
// state updates each timer fires, so React re-renders inside act()
// for you (no manual act() wrapper, no warning):
await vi.advanceTimersByTimeAsync(1000);
                    

This is why every fake-timer test in this lesson uses vi.advanceTimersByTimeAsync() rather than wrapping a synchronous vi.advanceTimersByTime() in act() β€” it's the current, warning-free idiom.

10. Test Error Boundaries


test('error boundary catches async errors', async () => {
  const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
  
  server.use(
    http.get('/api/data', () => {
      return new HttpResponse(null, { 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."