Testing #4 — Async and Network Mocking with MSW

9 min read

The components up through #3 just took props and rendered them as-is. This post is about components that fetch external data. Two decisions show up at this moment — how do we verify async behavior, and how do we fake the network.

Where this post sits in the Testing series:

Two pieces pair up in this post — intercepting the network with MSW + verifying async with findBy* / waitFor.

Don’t mock fetch directly #

Let’s start with the most common antipattern.

Antipattern — mocking fetch directly
import { vi } from 'vitest';

beforeEach(() => {
  global.fetch = vi.fn().mockResolvedValue({
    ok: true,
    json: async () => ({ id: 1, name: 'Alice' }),
  } as Response);
});

Why this is bad:

  • It only fakes part of the Response object. The moment your code calls response.headers.get('...'), it breaks.
  • If your code uses axios, you need yet another mocking approach. Code running on top of abstractions like tanstack-query / swr needs separate handling too.
  • The shape of your fake Response can drift from the real one — the test passes, but production breaks.
  • Scenarios where one handler responds differently to different endpoints (/posts/1 vs /posts/2) are awkward to set up.

The point we made in #1only mock at system boundaries. That boundary is the network, not the fetch function. The clean approach: let fetch actually run, and have us only define where its calls go and what response they get.

MSW — intercepting the network layer #

MSW (Mock Service Worker) intercepts fetch / XHR / axios — anything — right before the network call and returns the response you defined. From your code’s perspective, it’s indistinguishable from a real round-trip to the network.

Install:

Install MSW
pnpm add -D msw

To run in the browser, you need the service worker file in public/, but in the test environment (node) that’s not necessary. Just set up the Node version.

Writing handlers #

The simplest form.

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

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: Number(params.id),
      name: 'Alice',
    });
  }),

  http.get('/api/posts', () => {
    return HttpResponse.json([
      { id: 1, title: 'First' },
      { id: 2, title: 'Second' },
    ]);
  }),

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

http.get, http.post, etc. correspond 1:1 with real HTTP methods. The second argument is the function that builds the actual response.

  • params — path parameters (:id).
  • request — a standard Request object. request.url, request.json(), request.headers work as-is.
  • HttpResponse.json(...) / HttpResponse.text(...) / new HttpResponse(...) — for building responses.

This is what’s different from faking fetch. Real Request/Response objects flow through. They’re standard Web API, so there’s nothing confusing.

Server setup (Node environment) #

Spin up the server when tests start, tear it down when they end.

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

export const server = setupServer(...handlers);
vitest.setup.ts
import '@testing-library/jest-dom/vitest';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './src/test/server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

What each hook is for:

  • beforeAll(server.listen) — runs once before all tests. The onUnhandledRequest: 'error' option matters — any request to an undefined endpoint immediately raises an error. You catch missing handlers fast.
  • afterEach(server.resetHandlers) — after each test, resets any added handlers to the initial state. So a server.use(...) in one test doesn’t leak into another.
  • afterAll(server.close) — cleanup after all tests finish.

Your first async test — UserCard #

A component that fetches data and displays it.

src/components/UserCard.tsx
import { useEffect, useState } from 'react';

type User = { id: number; name: string };

export function UserCard({ id }: { id: number }) {
  const [user, setUser] = useState<User | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch(`/api/users/${id}`)
      .then((res) => {
        if (!res.ok) throw new Error('Failed to load');
        return res.json();
      })
      .then(setUser)
      .catch((e) => setError(e.message));
  }, [id]);

  if (error) return <p role="alert">{error}</p>;
  if (!user) return <p>Loading...</p>;

  return (
    <article>
      <h2>{user.name}</h2>
      <p>id: {user.id}</p>
    </article>
  );
}

A first attempt at the test:

UserCard.test.tsx — happy path
import { render, screen } from '@testing-library/react';
import { UserCard } from './UserCard';

it('fetches user data and displays it', async () => {
  render(<UserCard id={1} />);

  // Loading at first
  expect(screen.getByText('Loading...')).toBeInTheDocument();

  // Once data arrives, the name should be visible
  expect(await screen.findByRole('heading')).toHaveTextContent('Alice');
});

The key piece is findByRole — the “appears after a moment” query we flagged in #3. It polls for up to 1 second by default, waiting for the element to show up. Only then, if it can’t find it, does it error.

If you tried getByRole('heading') — it checks immediately, before the data arrives, and fails. Async results always go through findBy* or waitFor.

waitFor — the more general async assertion #

findBy* is for “wait until a specific element appears.” For more general async conditions, waitFor takes over.

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

it('asynchronously verifies how many times a function was called', async () => {
  render(<MyComponent />);

  await waitFor(() => {
    expect(mockFn).toHaveBeenCalledTimes(2);
  });
});

The callback to waitFor is the assertion. If it fails, it retries; if it can’t pass within 1 second, it throws the last error.

When to use findBy* vs waitFor:

  • Waiting for an element to appear on screen → findBy* is more concise.
  • Async verification beyond the DOM (call counts, the state of an external mock) → waitFor.

findBy* actually has waitFor inside it. Same tool, different ergonomics.

Error path testing — overriding handlers with server.use #

Testing only the happy path is half the picture. You also need to verify behavior on error responses. To give a single test a different response, override the handler for the duration of that test with server.use.

error path
import { http, HttpResponse } from 'msw';
import { server } from '@/test/server';

it('shows an error message when the server returns 500', async () => {
  server.use(
    http.get('/api/users/:id', () => {
      return new HttpResponse(null, { status: 500 });
    })
  );

  render(<UserCard id={1} />);

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

Because afterEach(server.resetHandlers) is in the setup, this handler only takes effect within this test. The next test gets the response from the original handlers again.

Various scenarios #

Common variations you’ll write:

Simulating network latency
import { delay } from 'msw';

server.use(
  http.get('/api/posts', async () => {
    await delay(500);  // 500ms delay
    return HttpResponse.json([]);
  })
);
Asserting on the request body
http.post('/api/posts', async ({ request }) => {
  const body = await request.json();
  expect(body).toMatchObject({ title: 'Hello' });
  return HttpResponse.json({ id: 1, ...body });
})
A network failure itself
server.use(
  http.get('/api/users/:id', () => {
    return HttpResponse.error();  // network error
  })
);
Response headers / cookies
http.get('/api/me', () => {
  return HttpResponse.json(
    { id: 1 },
    {
      headers: { 'X-Total-Count': '42' },
    }
  );
})

HttpResponse.error() produces, from fetch’s perspective, the same result as “TypeError: Failed to fetch.” Use it when you want to simulate offline / CORS failure.

Working with TanStack Query #

In real projects, you don’t call fetch directly — something like @tanstack/react-query enters the picture. MSW pairs well — since MSW intercepts the network layer, it doesn’t matter how the query library calls it.

UserCard with TanStack Query
import { useQuery } from '@tanstack/react-query';

export function UserCard({ id }: { id: number }) {
  const { data, error, isPending } = useQuery({
    queryKey: ['user', id],
    queryFn: () => fetch(`/api/users/${id}`).then((res) => res.json()),
  });

  if (isPending) return <p>Loading...</p>;
  if (error) return <p role="alert">{error.message}</p>;

  return <article><h2>{data.name}</h2></article>;
}

For testing, you have to wrap with QueryClientProvider. It’s safest to create a fresh client for each test so every test starts with a clean cache.

custom render with QueryClient
// src/test/utils.tsx
import { render as rtlRender } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

export function render(ui: React.ReactElement) {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },  // turn retry off in tests
  });

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

export * from '@testing-library/react';

retry: false matters here. The default is 3 retries — in an error test, you want one failure and done, but with 3 retries you frequently hit a timeout instead.

Common async pitfalls #

This is where debugging is hardest. Common cases you’ll run into:

act warning — “An update was not wrapped in act” — a sign that an async change wasn’t awaited. Wait properly with findBy* / waitFor, or check that every user action is await-ed.

Tests fail intermittently (flaky) — code that uses setTimeout or other non-deterministic timing. Control time with fake timers (vi.useFakeTimers()). Or bump up the timeout option on waitFor (though longer timeouts usually aren’t the answer).

MSW handler not picked up — URL matching failure. Maybe fetch went to an absolute URL but the handler was defined as relative, or vice versa. Keep onUnhandledRequest: 'error' on and you’ll catch it immediately.

findByRole('alert') matches but the text is empty — the element with alert role is up, but its text is filled in asynchronously. Use waitFor(() => expect(el).toHaveTextContent('...')) to wait for the text too.

Loading... assertion and the data assertion mixed in one test — you want to assert Loading right after render, but if queryFn finishes synchronously (especially on a cache hit), you can miss the loading phase entirely. Split the loading assertion into a separate test, where you intentionally delay the response.

State seems to leak between tests — missing server.resetHandlers(). Or a single QueryClient instance is being reused and the cache stays around.

Debugging — screen.debug() + console #

A common pattern when an async test breaks:

Inspecting intermediate state
render(<UserCard id={1} />);

screen.debug();  // DOM in the Loading state

await waitFor(() => {
  screen.debug();  // prints DOM on every attempt — track what's visible
  expect(screen.getByText('Alice')).toBeInTheDocument();
});

One more — confirm whether your MSW handler is actually being called.

Tracking MSW calls
server.events.on('request:start', ({ request }) => {
  console.log('MSW intercepted:', request.method, request.url);
});

Wrap-up #

  • Don’t mock fetch directly. MSW, intercepting the network layer, is the canonical approach.
  • MSW handlers use standard Request/Response as-is. With http.get/post(...) and HttpResponse.json(...) you have most of what you need.
  • beforeAll(server.listen) / afterEach(resetHandlers) / afterAll(server.close) — set this up once and you’re done.
  • For async results, use findBy* or waitFor. getBy* won’t match the timing.
  • Different response in just one test — override with server.use(...), and afterEach restores automatically.
  • MSW works the same on top of libraries like TanStack Query. In tests, retry: false is recommended.
  • Keeping onUnhandledRequest: 'error' on lets you catch missing handlers fast.

The next post (#5 User events and form testing) covers input and submit. The detailed methods of userEvent, testing on top of form libraries like React Hook Form, and validation-error scenarios.

X