Testing Async Components with MSW: Mocking API Calls

Shunku

Testing components that make API calls can be challenging. Mock Service Worker (MSW) intercepts requests at the network level, allowing you to test components exactly as they run in production.

Why MSW?

Traditional mocking approaches have limitations:

flowchart TD
    subgraph Traditional["Traditional Mocking"]
        A[Mock fetch/axios directly]
        B[Coupled to implementation]
        C[Different from production]
    end

    subgraph MSW["MSW Approach"]
        D[Intercept at network level]
        E[Implementation agnostic]
        F[Same as production behavior]
    end

    style Traditional fill:#ef4444,color:#fff
    style MSW fill:#10b981,color:#fff
Approach Pros Cons
Mock fetch Simple setup Tightly coupled to implementation
Mock axios Easy if using axios Breaks if you switch libraries
MSW Works with any library Slightly more setup

MSW intercepts requests before they leave your app, so your component's fetch code runs exactly as it would in production.

Setting Up MSW

Installation

npm install -D msw

Creating Handlers

Define how your mock server responds to requests:

// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  // GET request
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ]);
  }),

  // GET with path parameter
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params;
    return HttpResponse.json({ id: Number(id), name: 'John Doe' });
  }),

  // POST request
  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      { id: 3, ...body },
      { status: 201 }
    );
  }),
];

Setting Up the Server

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

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

Test Setup

Configure MSW to run during tests:

// src/test/setup.ts
import '@testing-library/jest-dom';
import { server } from '../mocks/server';

// Start server before all tests
beforeAll(() => server.listen());

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

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

Update your Vite/Jest config:

// vite.config.ts
export default defineConfig({
  test: {
    setupFiles: './src/test/setup.ts',
  },
});

Testing Components That Fetch Data

Basic Async Component

// UserList.tsx
function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/users')
      .then((res) => res.json())
      .then((data) => {
        setUsers(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err.message);
        setLoading(false);
      });
  }, []);

  if (loading) return <div>Loading...</div>;
  if (error) return <div role="alert">{error}</div>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Testing the Happy Path

import { render, screen } from '@testing-library/react';
import { UserList } from './UserList';

test('displays users after loading', async () => {
  render(<UserList />);

  // Shows loading state
  expect(screen.getByText('Loading...')).toBeInTheDocument();

  // Wait for users to appear
  expect(await screen.findByText('Alice')).toBeInTheDocument();
  expect(screen.getByText('Bob')).toBeInTheDocument();

  // Loading is gone
  expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});

Testing Error States

Override handlers for specific tests:

import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';

test('displays error message on failure', async () => {
  // Override handler for this test
  server.use(
    http.get('/api/users', () => {
      return HttpResponse.json(
        { message: 'Server error' },
        { status: 500 }
      );
    })
  );

  render(<UserList />);

  // Wait for error to appear
  expect(await screen.findByRole('alert')).toBeInTheDocument();
});

Testing Loading States

import { delay, http, HttpResponse } from 'msw';
import { server } from '../mocks/server';

test('shows loading state while fetching', async () => {
  // Add delay to response
  server.use(
    http.get('/api/users', async () => {
      await delay(100);
      return HttpResponse.json([]);
    })
  );

  render(<UserList />);

  expect(screen.getByText('Loading...')).toBeInTheDocument();

  // Wait for loading to finish
  await waitForElementToBeRemoved(() => screen.queryByText('Loading...'));
});

Testing Forms That Submit Data

Form Component

// CreateUserForm.tsx
function CreateUserForm({ onSuccess }) {
  const [name, setName] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState(null);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setSubmitting(true);
    setError(null);

    try {
      const res = await fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name }),
      });

      if (!res.ok) throw new Error('Failed to create user');

      const user = await res.json();
      onSuccess(user);
    } catch (err) {
      setError(err.message);
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="name">Name</label>
      <input
        id="name"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <button type="submit" disabled={submitting}>
        {submitting ? 'Creating...' : 'Create User'}
      </button>
      {error && <div role="alert">{error}</div>}
    </form>
  );
}

Testing Form Submission

import userEvent from '@testing-library/user-event';
import { CreateUserForm } from './CreateUserForm';

test('submits form and calls onSuccess', async () => {
  const user = userEvent.setup();
  const handleSuccess = jest.fn();

  render(<CreateUserForm onSuccess={handleSuccess} />);

  await user.type(screen.getByLabelText('Name'), 'Jane Doe');
  await user.click(screen.getByRole('button', { name: 'Create User' }));

  // Wait for submission to complete
  await waitFor(() => {
    expect(handleSuccess).toHaveBeenCalledWith(
      expect.objectContaining({ name: 'Jane Doe' })
    );
  });
});

test('shows error on submission failure', async () => {
  server.use(
    http.post('/api/users', () => {
      return HttpResponse.json(
        { message: 'Validation failed' },
        { status: 400 }
      );
    })
  );

  const user = userEvent.setup();
  render(<CreateUserForm onSuccess={jest.fn()} />);

  await user.type(screen.getByLabelText('Name'), 'Jane');
  await user.click(screen.getByRole('button'));

  expect(await screen.findByRole('alert')).toHaveTextContent(
    'Failed to create user'
  );
});

test('disables button while submitting', async () => {
  server.use(
    http.post('/api/users', async () => {
      await delay(100);
      return HttpResponse.json({ id: 1, name: 'Jane' });
    })
  );

  const user = userEvent.setup();
  render(<CreateUserForm onSuccess={jest.fn()} />);

  await user.type(screen.getByLabelText('Name'), 'Jane');
  await user.click(screen.getByRole('button'));

  // Button shows loading state
  expect(screen.getByRole('button')).toHaveTextContent('Creating...');
  expect(screen.getByRole('button')).toBeDisabled();

  // Wait for completion
  await waitFor(() => {
    expect(screen.getByRole('button')).toHaveTextContent('Create User');
  });
});

Advanced MSW Patterns

Inspecting Request Data

test('sends correct data to API', async () => {
  let capturedRequest;

  server.use(
    http.post('/api/users', async ({ request }) => {
      capturedRequest = await request.json();
      return HttpResponse.json({ id: 1, ...capturedRequest });
    })
  );

  const user = userEvent.setup();
  render(<CreateUserForm onSuccess={jest.fn()} />);

  await user.type(screen.getByLabelText('Name'), 'Jane Doe');
  await user.click(screen.getByRole('button'));

  await waitFor(() => {
    expect(capturedRequest).toEqual({ name: 'Jane Doe' });
  });
});

Sequential Responses

test('handles retry after failure', async () => {
  let callCount = 0;

  server.use(
    http.get('/api/users', () => {
      callCount++;
      if (callCount === 1) {
        return HttpResponse.json({ message: 'Error' }, { status: 500 });
      }
      return HttpResponse.json([{ id: 1, name: 'Alice' }]);
    })
  );

  const user = userEvent.setup();
  render(<UserListWithRetry />);

  // First request fails
  expect(await screen.findByRole('alert')).toBeInTheDocument();

  // Click retry
  await user.click(screen.getByRole('button', { name: 'Retry' }));

  // Second request succeeds
  expect(await screen.findByText('Alice')).toBeInTheDocument();
});

Network Errors

test('handles network failure', async () => {
  server.use(
    http.get('/api/users', () => {
      return HttpResponse.error();
    })
  );

  render(<UserList />);

  expect(await screen.findByRole('alert')).toBeInTheDocument();
});

Request Headers

test('sends authentication header', async () => {
  let authHeader;

  server.use(
    http.get('/api/users', ({ request }) => {
      authHeader = request.headers.get('Authorization');
      return HttpResponse.json([]);
    })
  );

  render(<AuthenticatedUserList token="bearer-token-123" />);

  await waitFor(() => {
    expect(authHeader).toBe('Bearer bearer-token-123');
  });
});

Testing with React Query

MSW works great with React Query:

// UserProfile.tsx
function UserProfile({ userId }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div role="alert">Error loading user</div>;

  return <div>{user.name}</div>;
}
// UserProfile.test.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

function renderWithQueryClient(ui) {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false, // Don't retry failed requests in tests
      },
    },
  });

  return render(
    <QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
  );
}

test('displays user profile', async () => {
  server.use(
    http.get('/api/users/1', () => {
      return HttpResponse.json({ id: 1, name: 'John Doe' });
    })
  );

  renderWithQueryClient(<UserProfile userId={1} />);

  expect(await screen.findByText('John Doe')).toBeInTheDocument();
});

Testing Patterns

waitFor vs findBy

// findBy - for elements that will appear
const element = await screen.findByText('Data');

// waitFor - for assertions that need to become true
await waitFor(() => {
  expect(mockFn).toHaveBeenCalled();
});

// waitForElementToBeRemoved - for elements that will disappear
await waitForElementToBeRemoved(() => screen.queryByText('Loading...'));

Test Organization

describe('UserList', () => {
  describe('when loading', () => {
    test('shows loading indicator', () => {
      render(<UserList />);
      expect(screen.getByText('Loading...')).toBeInTheDocument();
    });
  });

  describe('when loaded successfully', () => {
    test('displays users', async () => {
      render(<UserList />);
      expect(await screen.findByText('Alice')).toBeInTheDocument();
    });
  });

  describe('when request fails', () => {
    beforeEach(() => {
      server.use(
        http.get('/api/users', () => HttpResponse.error())
      );
    });

    test('shows error message', async () => {
      render(<UserList />);
      expect(await screen.findByRole('alert')).toBeInTheDocument();
    });
  });
});

Summary

Concept Description
http.get/post/put/delete Define mock handlers
HttpResponse.json() Return JSON response
server.use() Override handlers for specific tests
delay() Simulate network latency
findBy* Wait for elements to appear
waitFor() Wait for assertions to pass

Key takeaways:

  • MSW intercepts requests at the network level, making tests more realistic
  • Define default handlers in a handlers file, override in specific tests
  • Use server.use() to test error states and edge cases
  • Use findBy queries to wait for async data to appear
  • Reset handlers after each test to avoid test pollution
  • Test loading, success, and error states for complete coverage

MSW provides a powerful, realistic way to test components that interact with APIs. Your tests run the same code as production, giving you confidence that your components work correctly.

References