Testing and mocking JSON APIs
Build fixtures and factories, generate data from a schema, mock at the network boundary, and stop snapshots from making every change a failure.
Fixtures and factories
// fixtures/order.json - a real recorded response, trimmed
{
"data": {
"id": "ord_9f2c",
"total_minor": 3998,
"currency": "GBP",
"status": "placed",
"placed_at": "2026-09-18T11:04:22Z",
"line_items": [{ "sku": "A-1", "quantity": 2 }]
},
"meta": { "request_id": "req_7c1a" }
}// fixtures/order.js - a factory with explicit overrides
import base from "./order.json" with { type: "json" };
export function anOrder(overrides = {}) {
return structuredClone({
...base,
data: { ...base.data, ...(overrides.data ?? {}) },
meta: { ...base.meta, ...(overrides.meta ?? {}) },
});
}
// the test states only what it cares about
const order = anOrder({ data: { status: "cancelled", cancelled_at: "2026-09-18T12:00:00Z" } });- Record fixtures from the real service once, then trim them: remove the fields nobody reads, so a change to those fields cannot break the test.
- A factory spreads a base and applies overrides, which keeps each test focused on one property.
- Keep fixtures small. A 900-line recorded response makes the test unreadable and the diff unexplained.
- Add a comment naming where a recorded fixture came from and when - without it nobody dares to update it.
Generated data and schema checks
# random-but-valid data from the schema
npx json-schema-faker schemas/order.json --count 5 > fixtures/orders.random.json
# a property-style check on the parser itself
npx fast-check --exampleimport { test, expect } from "vitest";
import { validate } from "../src/validate-order";
import { anOrder } from "./fixtures/order";
test("accepts a valid order", () => {
expect(validate(anOrder().data)).toBe(true);
});
test("rejects a negative total", () => {
expect(validate(anOrder({ data: { total_minor: -1 } }).data)).toBe(false);
});
test("rejects an unknown currency", () => {
expect(validate(anOrder({ data: { currency: "XYZ" } }).data)).toBe(false);
});| Test type | Catches | Cost |
|---|---|---|
| Fixture with a recorded response | Your parsing and rendering | Fixtures drift from reality |
| Schema-validated fixture | Both sides of the contract | Needs a shared schema |
| Generated data | Edge cases you did not think of | Non-reproducible failures without a seed |
| Contract test against the real service | Drift between your fixture and the API | Needs a running environment |
| Snapshot of a whole response | Unintended changes | Fails on every legitimate change |
💡
Faking data by hand is cheap and always wrong eventually: the fake drifts from the real payload, and the test keeps passing while the integration breaks. Validate fixtures against a real schema, and keep one test per endpoint that talks to the real service.
Mocking at the boundary
// MSW: intercept at the network layer, so the code under test is unchanged
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
import { anOrder } from "./fixtures/order";
const server = setupServer(
http.get("https://api.example.com/orders/:id", ({ params }) =>
HttpResponse.json(anOrder({ data: { id: String(params.id) } }))
),
http.post("https://api.example.com/orders", async ({ request }) => {
const body = await request.json();
if (body.quantity < 1) {
return HttpResponse.json(
{ error: { code: "VALIDATION_FAILED", message: "Quantity must be at least 1." } },
{ status: 422 }
);
}
return HttpResponse.json(anOrder().data, { status: 201 });
})
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("surfaces a validation error", async () => {
await expect(placeOrder("A-1", 0)).rejects.toThrow("Quantity must be at least 1.");
});
test("an unexpected request fails the test", async () => {
await fetch("https://api.example.com/unknown"); // rejected by onUnhandledRequest
});- Mock at the network boundary rather than replacing your own client, so the serialisation and error handling are still exercised.
- Set
onUnhandledRequest: "error". A mock layer that silently passes through unmocked calls hides the very requests you meant to control. - Mock the error paths deliberately: a 500, a malformed body, a timeout. Those are the branches that are never exercised against a healthy environment.
- Reset handlers between tests so one test's mock cannot leak into the next.
- Do not mock the thing you are testing. If the test is about your HTTP client, mock the socket, not the client.
// a snapshot that fails for the right reasons
expect(anOrder().data).toMatchObject({
currency: "GBP",
status: "placed",
line_items: [{ sku: "A-1", quantity: 2 }],
});// the snapshot that fails for every reason
expect(response).toMatchSnapshot();
// 400 lines, regenerated with --update-snapshots whenever it breaks,
// and therefore never actually reviewedFAQ
Are snapshots of JSON responses a good idea?
Rarely for whole payloads. They are large, brittle and, once reviewers get used to regenerating them, they stop being read. Assert on the fields your code depends on with
toMatchObject, and keep a snapshot only for a small, stable structure whose exact shape matters.How do I keep fixtures from drifting from the real API?
Validate fixtures against a shared schema, and keep a small number of tests that call the real service on a schedule rather than on every commit. When the scheduled test fails, you know the contract moved and can update the fixtures deliberately.
Related
Validating with JSON Schema JSON in HTTP APIs
Last refreshed 2026-09-18.