Frontend work blocks on the backend more often than it needs to. The endpoint is not built yet, or staging is down, or the one error you need to design for only happens when a third-party service times out. A mock API removes the dependency: the frontend talks to something that answers like the real API, on your schedule.
The tools differ mainly in where the mock runs, and that decides what each is good for.
Option 1: static fixtures
The simplest mock is a JSON file imported into a test:
import users from "./fixtures/users.json";
render(<UserTable users={users} />);
Fixtures are perfect for component and unit tests: no network, no setup, fully deterministic. Generate them with the JSON mock data generator rather than typing them, so they contain realistic variety — long names, nulls, Unicode — instead of "John Doe" three times.
Their limit is that the component under test never makes a request, so loading states, retries and error handling go untested.
Option 2: Mock Service Worker (MSW)
MSW intercepts requests at the network level: a service worker in the browser, a request interceptor in Node. Your application calls the real fetch, and MSW decides what comes back. The same handlers work in local development, in Vitest or Jest, and in Storybook.
// mocks/handlers.ts
import { http, HttpResponse, delay } from "msw";
import users from "./fixtures/users.json";
export const handlers = [
http.get("/api/v1/users", async () => {
await delay(300); // make loading states visible
return HttpResponse.json({ data: users, meta: { total: users.length } });
}),
http.get("/api/v1/users/:id", ({ params }) => {
const user = users.find((u) => String(u.id) === params.id);
return user
? HttpResponse.json(user)
: HttpResponse.json({ error: { code: "not_found" } }, { status: 404 });
}),
http.post("/api/v1/users", async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ id: users.length + 1, ...body }, { status: 201 });
}),
];
In the browser, generate the worker script once with npx msw init public/, then start it with setupWorker(...handlers).start() from msw/browser before your app renders. In tests, use setupServer(...handlers) from msw/node and call listen(), resetHandlers() and close() around your suite.
The real strength is overriding a handler for a single test:
server.use(
http.get("/api/v1/users", () => HttpResponse.json({ error: "boom" }, { status: 500 }))
);
Option 3: json-server
json-server turns a JSON file into a REST API with full CRUD:
npx json-server db.json
A db.json with a users array gives you GET /users, GET /users/1, POST /users, PATCH /users/1 and DELETE /users/1 on http://localhost:3000, with filtering, sorting and pagination through query parameters. Writes are saved back to the file, so the API behaves statefully. The query-parameter syntax changed in version 1, so check the README for the version you install.
It is the fastest route to a working backend for a prototype. It is less suited to testing failure modes, because every response is a success unless you add middleware.
Option 4: Prism, from an OpenAPI spec
If your API has an OpenAPI document, Prism serves it directly:
npx @stoplight/prism-cli mock openapi.yaml # examples from the spec, on port 4010
npx @stoplight/prism-cli mock -d openapi.yaml # dynamic, schema-valid random data
Prism also validates every incoming request against the spec and returns errors for invalid ones, which catches frontend bugs — a missing required field, a wrong type — before the real backend exists. Because the mock is derived from the contract, it cannot drift from it.
Option 5: Playwright route interception
For end-to-end tests, Playwright can intercept requests per test without any extra server:
test("shows an error when the user list fails", async ({ page }) => {
await page.route("**/api/v1/users", (route) =>
route.fulfill({ status: 503, json: { error: "Service Unavailable" }, headers: { "Retry-After": "30" } })
);
await page.goto("/admin/users");
await expect(page.getByRole("alert")).toContainText("try again");
});
Cypress offers the same through cy.intercept(), including { fixture: "users.json" } shorthand.
Choosing
| Need | Use |
|---|---|
| Component and unit tests | Static fixtures, or MSW in Node |
| Local development without a backend | MSW in the browser, or json-server |
| A quick CRUD prototype | json-server |
| You have an OpenAPI spec | Prism |
| End-to-end tests of rare failures | Playwright route or Cypress intercept |
Mock the unhappy paths
A mock that only returns 200 OK tests the screen that was already going to work. The ones that break in production are these:
- 401 and 403 — expired sessions and missing permissions. Does the app redirect, or show a blank page?
- 404 — a deleted record opened from an old link.
- 422 — validation errors returned per field. Are they shown next to the right input?
- 429 with
Retry-After— does the client back off? - 500 and 503 — a friendly error, a retry button, no infinite spinner.
- Slow responses — loading states that appear and disappear without layout shift.
- Empty lists — the empty state most designs forget.
- The last page — a partial page, a disabled "next" button, correct totals.
The REST API response simulator produces full responses — status line, headers and body — for each of these, and the pagination generator produces offset, cursor, Link-header and JSON:API pagination envelopes, including the empty page.
Keep mocks honest
The risk of any mock is that it keeps answering the old way after the real API changes. Three defences, from cheapest to strongest:
- Generate the mock from the spec (Prism), so there is nothing to drift.
- Validate mock responses against the spec in CI, using a JSON Schema or OpenAPI validator on your fixture files.
- Contract tests such as Pact, where the frontend's expectations are recorded and verified against the real provider.
Even with all three, keep a small suite of end-to-end tests against a real backend with seeded data. Test data for end-to-end tests covers how to make that suite reliable.