Tips & Tricks (Updated: 6/3/2026)

MSW API Mocking with Claude Code: Practical Guide

Build realistic MSW API mocks with Claude Code for browser development, Node tests, auth, errors, and CI.

MSW API Mocking with Claude Code: Practical Guide

MSW, short for Mock Service Worker, intercepts HTTP traffic at the network boundary. In the browser it uses a Service Worker; in Node.js tests it patches request-issuing modules for the current process. The useful result is one set of request handlers that can support local UI development, Vitest, CI, and edge-case demos.

Claude Code helps when you ask for more than “return this JSON.” A production-ready mock should include auth, pagination, validation, error statuses, network failure, and response-contract checks. Otherwise the frontend may look complete while it only proves the happy path.

This article uses the current MSW 2 API confirmed from the official docs: http, HttpResponse, setupWorker, and setupServer. Start with the MSW Quick start, then check Browser integration and Node.js integration. For failure behavior, use the official pages on error responses and network errors.

For related testing workflows, pair this with advanced Vitest techniques, Playwright E2E testing, API testing automation, and CI/CD setup.

Where MSW Pays Off

MSW is not only for teams waiting on a backend. It becomes more valuable after a real API exists because it gives you deterministic versions of states that are hard to trigger on demand.

Use caseWhat to mockRisk if skipped
UI before backend is readyList, detail, create, empty stateThe screen ships with a contract mismatch
Auth and role checks401, 403, role-filtered payloadsAdmin actions appear for the wrong user
Failure-state UX500, 422, network failure, delayLoading never ends or retry UI is missing
CI contract guardJSON shape, required fields, status codesAPI drift reaches production unnoticed

The beginner mistake is a mock that always returns 200 OK. Real screens fail around expired sessions, invalid query parameters, malformed emails, server errors, and dropped connections. Those cases should be first-class handlers, not one-off manual tests.

Give Claude Code a precise prompt:

Create an MSW 2 user API mock.
Share the same handlers.ts between browser development and Vitest in Node.
Include required auth, pagination, role filtering, 422, 404, 500, and a network-error test.
Use TypeScript and leave no missing app-specific types.

Architecture

Keep the handlers in one file and connect them to setupWorker in the browser and setupServer in tests.

flowchart LR
  UI["Browser UI"] --> Worker["setupWorker"]
  Test["Vitest / CI"] --> Server["setupServer"]
  Worker --> Handlers["MSW handlers.ts"]
  Server --> Handlers
  Handlers --> Contract["API contract: status / JSON / auth / delay"]

Install

Install MSW as a development dependency. For browser usage, generate the worker script into your public asset directory. In Vite, that is usually public.

npm i -D msw vitest typescript
npx msw init public/ --save

Open http://localhost:5173/mockServiceWorker.js while the dev server is running. If it returns 404, the browser worker cannot intercept requests even if your handlers are correct.

Runnable Handlers

This example implements a small users API with list, detail, create, update, and delete. It includes auth, pagination, validation, 404, and delay. The URL is absolute so the same handlers work with native fetch in Node tests.

import { delay, http, HttpResponse } from "msw";

export const API_ORIGIN = "https://api.example.test";

type Role = "admin" | "editor" | "viewer";

export type User = {
  id: string;
  name: string;
  email: string;
  role: Role;
};

type CreateUserInput = {
  name: string;
  email: string;
  role?: Role;
};

type ErrorBody = {
  error: {
    code: string;
    message: string;
    requestId: string;
  };
};

type PageMeta = {
  total: number;
  page: number;
  perPage: number;
};

type UserListResponse = {
  data: User[];
  meta: PageMeta;
};

const seedUsers: User[] = [
  { id: "u_1", name: "Aki Tanaka", email: "aki@example.com", role: "admin" },
  { id: "u_2", name: "Bea Sato", email: "bea@example.com", role: "editor" },
  { id: "u_3", name: "Cal Mori", email: "cal@example.com", role: "viewer" },
];

let users: User[] = [...seedUsers];

const jsonError = (status: number, code: string, message: string) =>
  HttpResponse.json(
    { error: { code, message, requestId: "req_mock_001" } },
    { status }
  );

const requireAuth = (request: Request) => {
  const token = request.headers.get("authorization");
  return token === "Bearer demo-token"
    ? null
    : jsonError(401, "UNAUTHORIZED", "Missing or invalid bearer token");
};

const isRole = (value: string | null): value is Role =>
  value === "admin" || value === "editor" || value === "viewer";

export function resetMockData() {
  users = [...seedUsers];
}

export const handlers = [
  http.get(`${API_ORIGIN}/users`, async ({ request }) => {
    const authError = requireAuth(request);
    if (authError) return authError;

    await delay(120);
    const url = new URL(request.url);
    const page = Number(url.searchParams.get("page") ?? "1");
    const perPage = Number(url.searchParams.get("perPage") ?? "20");
    const role = url.searchParams.get("role");

    if (!Number.isInteger(page) || page < 1) {
      return jsonError(422, "INVALID_PAGE", "page must be a positive integer");
    }

    if (!Number.isInteger(perPage) || perPage < 1 || perPage > 50) {
      return jsonError(422, "INVALID_PER_PAGE", "perPage must be between 1 and 50");
    }

    if (role && !isRole(role)) {
      return jsonError(422, "INVALID_ROLE", "role must be admin, editor, or viewer");
    }

    const filtered = role ? users.filter((user) => user.role === role) : users;
    const start = (page - 1) * perPage;

    return HttpResponse.json({
      data: filtered.slice(start, start + perPage),
      meta: { total: filtered.length, page, perPage },
    });
  }),

  http.get(`${API_ORIGIN}/users/:id`, async ({ params, request }) => {
    const authError = requireAuth(request);
    if (authError) return authError;

    await delay(80);
    const user = users.find((item) => item.id === String(params.id));

    return user
      ? HttpResponse.json({ data: user })
      : jsonError(404, "USER_NOT_FOUND", "User was not found");
  }),

  http.post(`${API_ORIGIN}/users`, async ({ request }) => {
    const authError = requireAuth(request);
    if (authError) return authError;

    const body = (await request.json()) as Partial<CreateUserInput>;

    if (!body.name?.trim() || !body.email?.includes("@")) {
      return jsonError(422, "INVALID_INPUT", "name and a valid email are required");
    }

    if (body.role && !isRole(body.role)) {
      return jsonError(422, "INVALID_ROLE", "role must be admin, editor, or viewer");
    }

    const user: User = {
      id: `u_${Date.now()}`,
      name: body.name.trim(),
      email: body.email,
      role: body.role ?? "viewer",
    };

    users = [user, ...users];

    return HttpResponse.json({ data: user }, { status: 201 });
  }),

  http.patch(`${API_ORIGIN}/users/:id`, async ({ params, request }) => {
    const authError = requireAuth(request);
    if (authError) return authError;

    const index = users.findIndex((item) => item.id === String(params.id));
    if (index === -1) return jsonError(404, "USER_NOT_FOUND", "User was not found");

    const body = (await request.json()) as Partial<CreateUserInput>;

    if (body.email && !body.email.includes("@")) {
      return jsonError(422, "INVALID_EMAIL", "email must include @");
    }

    if (body.role && !isRole(body.role)) {
      return jsonError(422, "INVALID_ROLE", "role must be admin, editor, or viewer");
    }

    users[index] = { ...users[index], ...body };

    return HttpResponse.json({ data: users[index] });
  }),

  http.delete(`${API_ORIGIN}/users/:id`, async ({ params, request }) => {
    const authError = requireAuth(request);
    if (authError) return authError;

    users = users.filter((item) => item.id !== String(params.id));

    return new HttpResponse(null, { status: 204 });
  }),
];

Browser Setup

Use setupWorker in the browser. Await worker.start() before rendering the app; otherwise the first request can race ahead of the worker registration and hit the real API.

import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";

export const worker = setupWorker(...handlers);
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";

async function enableMocking() {
  if (!import.meta.env.DEV || import.meta.env.VITE_API_MOCKING !== "enabled") {
    return;
  }

  const { worker } = await import("./mocks/browser");

  await worker.start({
    onUnhandledRequest: "bypass",
  });
}

enableMocking().then(() => {
  ReactDOM.createRoot(document.getElementById("root")!).render(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  );
});

Enable it explicitly with VITE_API_MOCKING=enabled npm run dev. Never let mocks run silently in production; revenue links, login, checkout, and lead forms must talk to real services.

Vitest and CI

Use setupServer in Node tests. In CI, prefer onUnhandledRequest: "error" so an unexpected live API call fails the test immediately.

import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { API_ORIGIN, handlers, resetMockData } from "../src/mocks/handlers";

const server = setupServer(...handlers);

function authed(input: string, init: RequestInit = {}) {
  const headers = new Headers(init.headers);
  headers.set("authorization", "Bearer demo-token");
  return fetch(input, { ...init, headers });
}

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));

afterEach(() => {
  server.resetHandlers();
  resetMockData();
});

afterAll(() => server.close());

describe("users API mock", () => {
  it("returns a paginated user list", async () => {
    const response = await authed(`${API_ORIGIN}/users?page=1&perPage=2`);
    const body = (await response.json()) as {
      data: Array<Record<string, unknown>>;
      meta: Record<string, unknown>;
    };

    expect(response.status).toBe(200);
    expect(body.data).toHaveLength(2);
    expect(body.meta).toMatchObject({ total: 3, page: 1, perPage: 2 });
  });

  it("rejects missing auth", async () => {
    const response = await fetch(`${API_ORIGIN}/users`);
    const body = (await response.json()) as { error: { code: string } };

    expect(response.status).toBe(401);
    expect(body.error.code).toBe("UNAUTHORIZED");
  });

  it("simulates a network failure for retry UI", async () => {
    server.use(
      http.get(`${API_ORIGIN}/users`, () => {
        return HttpResponse.error();
      })
    );

    await expect(authed(`${API_ORIGIN}/users`)).rejects.toThrow();
  });

  it("guards against response contract drift", async () => {
    const response = await authed(`${API_ORIGIN}/users`);
    const body = (await response.json()) as {
      data: Array<Record<string, unknown>>;
      meta: Record<string, unknown>;
    };

    expect(Object.keys(body.data[0]).sort()).toEqual(["email", "id", "name", "role"]);
    expect(body.data[0]).toEqual(
      expect.objectContaining({
        id: expect.any(String),
        email: expect.stringContaining("@"),
      })
    );
    expect(body.meta).toEqual(expect.objectContaining({ page: 1, perPage: 20 }));
  });
});
name: msw-contract

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run test -- --run

Common Pitfalls

The first pitfall is a missing worker file. If /mockServiceWorker.js is 404, browser interception will not happen.

The second is leaking state between tests. Always call server.resetHandlers() and reset any in-memory data after each test.

The third is using onUnhandledRequest: "bypass" in CI. It is convenient during browser development, but tests should fail when a request is not mocked.

The fourth is ignoring auth. Many UI defects live between signed-in, expired, unauthorized, and wrong-role states.

The fifth is contract drift. Assert the fields your UI depends on, such as data, meta.total, and error.code.

Monetization CTA

MSW is especially useful on revenue paths: article CTAs, product purchases, lead forms, free trials, and checkout previews. Those paths need deliberate tests for 500 responses, slow APIs, expired auth, and validation errors. If you want reusable Claude Code prompts and review checklists, browse products or use Claude Code training to turn this setup into a team workflow.

Result

When Masa tried this structure on a real article and product-flow test plan, the biggest wins came from HttpResponse.error() and onUnhandledRequest: "error". A happy-path mock missed a hidden retry-button bug, a missing auth header, and a dropped meta.total field. Sharing the same handlers between local development and CI made the failures reproducible instead of dependent on backend timing.

#Claude Code #MSW #API mocking #testing #frontend
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.