Testing #4 — Async and Network Mocking with MSW
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:
- #1 Why test
- #2 Vitest setup and your first unit test
- #3 React Testing Library
- #4 Async and Network Mocking with MSW ← this post
- #5 User events and form testing
- #6 E2E and CI integration with Playwright
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.
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 liketanstack-query/swrneeds 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/1vs/posts/2) are awkward to set up.
The point we made in #1 — only 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:
pnpm add -D mswTo 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.
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.headerswork 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.
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);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. TheonUnhandledRequest: '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 aserver.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.
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:
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.
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.
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:
import { delay } from 'msw';
server.use(
http.get('/api/posts', async () => {
await delay(500); // 500ms delay
return HttpResponse.json([]);
})
);http.post('/api/posts', async ({ request }) => {
const body = await request.json();
expect(body).toMatchObject({ title: 'Hello' });
return HttpResponse.json({ id: 1, ...body });
})server.use(
http.get('/api/users/:id', () => {
return HttpResponse.error(); // network error
})
);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.
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.
// 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:
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.
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(...)andHttpResponse.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*orwaitFor.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: falseis 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.