Mocking an async validator by stubbing your own checkUsername function tests everything except the parts that break in production — the request URL, the query encoding, the abort handling, the response parsing — and it cannot produce the interesting failures: a slow response arriving after a fast one, a 429 with Retry-After, a 422 body in the server’s real format.
Mock Service Worker (MSW) intercepts requests at the network layer, in the browser with a service worker and in Node with request interception, so your real fetch code runs against controlled responses. This page, part of testing form validation, sets up handlers for a form’s endpoints and builds the deferred-response technique that makes race conditions reproducible.
Context and prerequisites
MSW concepts used here:
- Handlers —
http.get(url, resolver),http.post(...); the resolver receives therequestand returns anHttpResponse. - Server —
setupServer(...handlers)in Node test runners (Vitest, Jest);setupWorkerin the browser (Storybook, Playwright component tests). - Per-test overrides —
server.use(handler)adds a handler that takes precedence for the rest of the test;server.resetHandlers()removes them after each test. - Request inspection — resolvers can read the URL, headers and body, and record them for assertions.
onUnhandledRequest: "error"— fails tests that make requests nobody mocked, catching typos in URLs.
The core pattern: handlers, overrides and deferred responses
// test/msw.ts
import { http, HttpResponse, delay } from "msw";
import { setupServer } from "msw/node";
export const calls: { url: string; body?: unknown }[] = [];
export const handlers = [
http.get("/api/usernames/:name", ({ params, request }) => {
calls.push({ url: request.url });
return params.name === "taken" ? HttpResponse.json({ id: 1 }) : new HttpResponse(null, { status: 404 });
}),
http.post("/api/signup", async ({ request }) => {
const body = await request.json();
calls.push({ url: request.url, body });
return HttpResponse.json({ ok: true }, { status: 201 });
}),
];
export const server = setupServer(...handlers);
// A response you resolve by hand: the key to reproducing races.
export function deferred() {
let release!: (r: Response) => void;
const promise = new Promise<Response>((r) => { release = r; });
return { promise, release };
}
// vitest.setup.ts
import { server, calls } from "./test/msw";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => { server.resetHandlers(); calls.length = 0; });
afterAll(() => server.close());
// UsernameField.test.tsx — an out-of-order race, reproduced deterministically
import { http, HttpResponse } from "msw";
import { server, deferred } from "../test/msw";
it("never shows a stale result when an older request resolves last", async () => {
const first = deferred(); // for "ada"
const second = deferred(); // for "ada_l"
server.use(
http.get("/api/usernames/ada", () => first.promise),
http.get("/api/usernames/ada_l", () => second.promise),
);
const user = userEvent.setup();
render(<UsernameField debounceMs={0} />);
const input = screen.getByLabelText("Username");
await user.type(input, "ada"); // request #1 in flight
await user.type(input, "_l"); // request #2 in flight; #1 should be aborted
second.release(new HttpResponse(null, { status: 404 })); // newer: available
expect(await screen.findByText("ada_l is available")).toBeInTheDocument();
first.release(HttpResponse.json({ id: 1 })); // older: "taken" arrives LAST
await new Promise((r) => setTimeout(r, 0));
expect(screen.queryByText(/taken/i)).toBeNull(); // stale answer ignored
});
it("maps a 422 onto the right fields", async () => {
server.use(http.post("/api/signup", () =>
HttpResponse.json({
type: "https://api.example.com/problems/validation",
errors: [{ pointer: "#/email", detail: "That email is used by another account." }],
}, { status: 422, headers: { "Content-Type": "application/problem+json" } })));
// …fill and submit, then:
const email = await screen.findByLabelText("Email address");
expect(email).toHaveAccessibleDescription(/used by another account/i);
});
Step-by-step walkthrough
- Define default handlers for every endpoint the form calls. Happy-path responses keep most tests short;
onUnhandledRequest: "error"catches requests to unexpected URLs. - Reset between tests.
resetHandlersremoves per-test overrides, and clearing recorded calls keeps assertions independent. - Override per test with
server.use. A 429 withRetry-After, a 5xx, a 422 in Problem Details format, a 409 with the current record — each is one handler, as used in handling timeouts and 429s in async validators. - Use deferred responses for ordering. Return a promise the test resolves by hand; release responses in the order that exposes the bug (newest first, oldest last).
- Assert on what was sent. Record request URLs and bodies in resolvers and assert the form sent the right payload, the right idempotency key, and no request for values that failed synchronous validation.
- Assert user-visible outcomes. Messages,
aria-invalid, accessible descriptions and focus — not internal state.
Why deferred responses beat artificial delays
It is tempting to reproduce races with await delay(500) in one handler and delay(100) in another. That works until CI is slow, a debounce changes, or fake timers are enabled — then the ordering shifts and the test either flakes or silently stops testing the race. A deferred response makes ordering explicit and independent of time: the test decides exactly when each response arrives, relative to user actions and to each other. The race in the example — the older request resolving last — is the one that breaks naive async validators, and with deferred responses it reproduces identically on every run.
Asserting on requests, not only on screens
Network mocks are also a precise way to check what the form sends. Recording each request in the handler lets a test assert that a malformed email never produced an availability request (synchronous validation gated it), that a debounced field sent one request rather than five, that a retried submission carried the same idempotency key as the first attempt, and that hidden or disabled fields were left out of the payload. These are behaviours users never see directly but that servers, rate limits and data quality depend on. A form that displays everything correctly while sending an extra request per keystroke, or a stale field in its payload, still has a bug — and only a network-level mock observes it without instrumenting the application code.
Failure modes and edge cases
1. Aborted requests still hitting the handler
When the form aborts a request, the handler may already have run and recorded it. Assertions like “only one request was made” should account for aborted ones; record request.signal.aborted in the resolver if you need to distinguish.
2. Relative URLs in Node
In Node, fetch("/api/…") needs a base URL. Configure the test environment’s location (jsdom does this) or use absolute URLs in the app’s API client.
3. Handlers leaking between tests
A server.use without resetHandlers in afterEach changes later tests. Always reset, and prefer per-test overrides over editing default handlers.
4. Testing abort behaviour
To assert that a request was cancelled, resolve the deferred response after the abort and assert the form ignored it — or check request.signal.aborted inside the resolver. See cancelling stale async validation with AbortController.
5. Sharing handlers with Storybook and end-to-end tests
The same handler modules can back Storybook stories (via the browser worker) and Playwright tests. Keep handlers in a shared folder so designers, QA and tests exercise the same error scenarios.
Verification checklist
Frequently Asked Questions
Why not mock fetch with vi.fn()?
A mocked fetch bypasses Request construction, headers, signals and response parsing, and each test must build response objects by hand. MSW exercises the real code path with realistic Response objects and is reusable across unit tests, Storybook and the browser.
Does MSW work with fake timers?
Yes, with care: MSW’s own delay() uses timers, so under fake timers you must advance the clock for delayed responses. Deferred responses avoid the issue entirely because they are released by the test, not by time.
Can I use MSW for GraphQL form mutations?
Yes — graphql.mutation("Signup", resolver) intercepts by operation name, and resolvers can return errors arrays or data with user errors, whichever your API uses.