Mocking an API is not just a testing convenience — it is a development multiplier. Frontend teams should not block on backend teams; test suites should not make real HTTP requests. In 2026 the tooling for API mocking is mature, ergonomic, and consistent across browser and Node environments.
What changed in 2026
- MSW v2 unified browser and Node. The same handlers work in a browser service worker, Node tests (Vitest/Jest), and the new Bun runtime. No more duplicated mock logic.
- OpenAPI is everywhere. Most teams generate stubs automatically from their spec using
@stoplight/prism-cli, eliminating hand-written mock data.
- Contract testing went mainstream. Pact and OpenAPI-based contract tests run in CI and catch API drift before it reaches production.
- Edge runtimes changed test environments. Cloudflare Workers and Deno use the Web Fetch API natively; MSW's HTTP handler approach fits them without polyfills.
The three layers of API mocking
| Layer |
Tool |
Best for |
| Network interception |
MSW |
Unit and integration tests |
| Local HTTP stub |
json-server, Prism |
Frontend dev without backend |
| Record + replay |
Polly.js, nock.back |
Slow/paid third-party APIs |
MSW: network-level mocking
Install once, use everywhere:
npm install -D msw
Define handlers:
// src/mocks/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/products", () => {
return HttpResponse.json([
{ id: "1", name: "Widget", price: 9.99 },
{ id: "2", name: "Gadget", price: 24.99 },
]);
}),
http.post("/api/orders", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: "ord_123", ...body }, { status: 201 });
}),
// Simulate an error
http.get("/api/products/:id", ({ params }) => {
if (params.id === "999") {
return new HttpResponse(null, { status: 404 });
}
return HttpResponse.json({ id: params.id, name: "Widget" });
}),
];
Node test setup (Vitest/Jest):
// src/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
Browser dev setup:
// src/mocks/browser.ts
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);
// main.tsx
if (import.meta.env.DEV) {
const { worker } = await import("./mocks/browser");
await worker.start({ onUnhandledRequest: "warn" });
}
Prism: OpenAPI-driven stubs
If you have an OpenAPI spec, Prism generates a fully working stub server in seconds:
npx @stoplight/prism-cli mock openapi.yaml
# Stub running on http://localhost:4010
Prism validates requests against your schema and returns example responses from the spec. Zero hand-written mock data required.
json-server: rapid local REST API
For CRUD prototyping before the backend exists:
npm install -D json-server
db.json:
{
"users": [{ "id": 1, "name": "Alice" }],
"posts": [{ "id": 1, "userId": 1, "title": "Hello" }]
}
npx json-server db.json --port 3001
# GET /users, POST /users, PUT /users/1, DELETE /users/1 — all work
How to pick a mocking strategy
- Writing unit or integration tests? → MSW in Node mode.
- Developing frontend without backend? → MSW in browser mode or Prism.
- OpenAPI spec exists? → Prism for instant stub server.
- Third-party API that costs money or is slow? → Record real responses with Polly.js, replay in CI.
- Need to validate API contracts don't drift? → Add Pact or Dredd to CI.
Contract testing to keep mocks honest
The biggest risk with mocks: they diverge from reality. A contract test runs against the real API and asserts the shape you mocked still matches:
// pact.test.ts (consumer side)
import { PactV3, MatchersV3 } from "@pact-foundation/pact";
const provider = new PactV3({ consumer: "Frontend", provider: "OrdersAPI" });
it("returns an order", async () => {
await provider
.addInteraction({
uponReceiving: "a request for order 1",
withRequest: { method: "GET", path: "/api/orders/1" },
willRespondWith: {
status: 200,
body: { id: MatchersV3.string(), total: MatchersV3.number() },
},
})
.executeTest(async (mockServer) => {
const res = await fetch(`${mockServer.url}/api/orders/1`);
expect(res.status).toBe(200);
});
});
Common mistakes
Never resetting handlers between tests. One test's server.use(...) override bleeds into the next. Always call server.resetHandlers() in afterEach.
Mocking modules instead of the network. vi.mock("axios") is fragile; it breaks when you swap HTTP clients. Mock the network; let the client code run for real.
Static mock data that never exercises edge cases. Write handlers for 404, 429, 500, and empty arrays — not just the happy path.
Over-mocking in E2E tests. End-to-end tests should hit the real API. Mocking in E2E defeats the purpose.
What to skip
- Manual
fetch global overrides — they're global state and cause hard-to-debug test pollution.
- WireMock for pure JS/TS stacks — MSW does everything WireMock does without spinning up a JVM.
- Copying mock data manually between test files — centralise handlers in
src/mocks/handlers.ts and import everywhere.
FAQ
Does MSW work with axios or ky?
Yes — MSW intercepts at the network level, not the library level. Any HTTP client that uses XMLHttpRequest or fetch is intercepted automatically.
Can I use MSW in Storybook?
Yes — Storybook has official MSW support via storybook-addon-mock or by importing worker.start() in your Storybook preview file.
How do I simulate slow responses?
Add a delay in your MSW handler: await new Promise(r => setTimeout(r, 2000)) before returning the response.
What is the difference between a stub and a mock?
A stub returns canned data; it doesn't verify behaviour. A mock also asserts how it was called (e.g. that a POST was made with specific params). MSW is primarily a stub; add vi.spyOn or Pact for verification.
Where to go next