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 the request and returns an HttpResponse.
  • Server — setupServer(...handlers) in Node test runners (Vitest, Jest); setupWorker in 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.
Where the mock sits When stubbing your own check function, the component and the stub run, but the request URL building, the fetch call, abort handling and response parsing never run. With MSW, the component, your real check function, fetch, the AbortSignal handling and your response parsing all run, and only the network response is replaced by a handler. Component + your check() Real code runs. URL, encoding, abort, parsing. fetch Real request object. Signal honoured. MSW handler Only the response is fake. Any status, any timing.

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

  1. Define default handlers for every endpoint the form calls. Happy-path responses keep most tests short; onUnhandledRequest: "error" catches requests to unexpected URLs.
  2. Reset between tests. resetHandlers removes per-test overrides, and clearing recorded calls keeps assertions independent.
  3. Override per test with server.use. A 429 with Retry-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.
  4. 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).
  5. 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.
  6. 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.

Reproducing the stale-response race The test types ada, and the form requests ada; the handler returns a deferred promise. The test types underscore l, and the form requests ada_l with another deferred promise, aborting the first request. The test releases the second response as available, and the form shows ada_l is available. The test then releases the first response as taken. Because the first request was aborted and its result is stale, the form keeps showing available. Test Form MSW handler type "ada" GET /usernames/ada (deferred #1) type "_l" GET /usernames/ada_l (deferred #2); abort #1 release #2: 404 available release #1 LAST: taken still "available"

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.

A scenario library for form endpoints A happy path returns 2xx and the test asserts success UI. A validation failure returns 422 with pointers and the test asserts errors on the right fields. A conflict returns 409 with the current record and the test asserts the conflict screen. A rate limit returns 429 with Retry-After and the test asserts a non-blocking unknown state. A server error returns 503 and the test asserts a retryable banner with input kept. A slow response uses a deferred promise and the test asserts pending UI and ordering. Scenario Handler returns Assert happy path 2xx success UI validation 422 + pointers errors on the right fields conflict 409 + current record conflict screen, edits kept rate limit 429 + Retry-After unknown state, not blocking outage 503 retryable banner, input kept slow / reordered deferred promise pending UI, no stale results

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.


Related

← Testing Form Validation