Lesson 25 / 34
Mock Servers
Writing a server that produces sample responses from a schema, developing the client against it, measuring that the same code works once it moves to the real server, and counting the cases the mock server hides.
Contents
Expectation files have been used one-directionally so far: the consumer wrote, the provider tested. The same contract also does its job in the reverse direction. When a new consumer starts being developed, the provider may not have written that endpoint yet; but the consumer waiting is unnecessary, because the contract is already known.
A mock server turns the contract into something executable: it produces sample responses from the schema in the definition and stands in for the real server. This lesson writes such a server, develops a client against it, then connects the same code to the real server and measures two things — whether the response shape is the same, and which of the client’s branches never ran.
Adding Examples to the Schema
Producing an example is not possible for every field. Type information is enough to
produce a string, but not enough to produce a string that matches the ^U-\d{4}$ pattern.
That is why fields with a pattern have to carry an example; JSON Schema defines the
examples keyword for this.
// definition.mjs — the loan service's machine-readable definition (with example values) // Every field that has a pattern carries an example; the mock server uses them. const LOAN_REQUEST = { type: "object", required: ["member", "items"], additionalProperties: false, properties: { member: { type: "string", pattern: "^U-\\d{4}$", examples: ["U-1001"] }, items: { type: "array", items: { type: "object", required: ["isbn"], additionalProperties: false, properties: { isbn: { type: "string", pattern: "^97[89]-\\d{10}$", examples: ["978-0262033848"] } } } }, branch: { type: "string", enum: ["central", "shore", "hill"] }, }, }; const LOAN_RESPONSE = { type: "object", required: ["id", "member", "items", "returnDate", "status"], additionalProperties: false, properties: { id: { type: "string", pattern: "^O-\\d+$", examples: ["O-1"] }, member: { type: "string", pattern: "^U-\\d{4}$", examples: ["U-1001"] }, items: { type: "array", items: { type: "object", required: ["isbn"], additionalProperties: false, properties: { isbn: { type: "string", pattern: "^97[89]-\\d{10}$", examples: ["978-0262033848"] } } } }, returnDate: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$", examples: ["2026-04-15"] }, status: { type: "string", enum: ["open", "closed"] }, }, }; const PROBLEM = { type: "object", required: ["type", "title", "status", "detail", "instance"], properties: { type: { type: "string", examples: ["https://example.library/problems/example"] }, title: { type: "string", examples: ["Example problem"] }, status: { type: "integer", examples: [0] }, detail: { type: "string", examples: ["Example detail."] }, instance: { type: "string", examples: ["oc-0001"] }, errors: { type: "array", items: { type: "object", required: ["path", "code"], properties: { path: { type: "string", examples: ["/member"] }, code: { type: "string", examples: ["format"] } } } }, }, }; export const DEFINITION = { "POST /loans": { request: LOAN_REQUEST, response: { 201: LOAN_RESPONSE, 422: PROBLEM } }, "GET /loans/{id}": { request: null, response: { 200: LOAN_RESPONSE, 404: PROBLEM } }, };
// example.mjs — produces an example value from a schema // Rule: enum uses the first value, examples uses the first example, a pattern requires an example. export function makeExample(schema, path = "") { if (schema.enum) return schema.enum[0]; if (schema.examples?.length) return schema.examples[0]; if (schema.pattern) throw new Error(`${path || "/"}: no examples written for a field with a pattern`); switch (schema.type) { case "string": return "text"; case "integer": case "number": return 0; case "boolean": return false; case "array": return schema.items ? [makeExample(schema.items, `${path}/0`)] : []; case "object": { const obj = {}; for (const [name, sub] of Object.entries(schema.properties ?? {})) obj[name] = makeExample(sub, `${path}/${name}`); return obj; } default: return null; } }
When there is a pattern but no example, the generator errors. This is a new condition placed on the contract: the schema must be complete enough that an example can be produced from it.
// missing-example.mjs — a patterned field without an example does not let the mock server start import { makeExample } from "./example.mjs"; const COMPLETE = { type: "object", required: ["barcode"], properties: { barcode: { type: "string", pattern: "^BK-\\d{6}$", examples: ["BK-004312"] } } }; const INCOMPLETE = { type: "object", required: ["barcode"], properties: { barcode: { type: "string", pattern: "^BK-\\d{6}$" } } }; console.log("schema with example ->", JSON.stringify(makeExample(COMPLETE))); try { makeExample(INCOMPLETE); } catch (h) { console.log("schema without example ->", h.message); }
schema with example -> {"barcode":"BK-004312"}
schema without example -> /barcode: no examples written for a field with a pattern
The Mock Server
The mock server recognizes the paths in the definition, produces the examples once, and returns them. Because examples are produced up front, a missing-example problem shows up while the server is starting, not on the first request.
// mock.mjs — server that produces sample responses from the definition // Usage: node mock.mjs <port> [problem] "problem": also produces error paths import { createServer } from "node:http"; import { DEFINITION } from "./definition.mjs"; import { makeExample } from "./example.mjs"; const PORT = Number(process.argv[2] ?? 8439); const PROBLEM_MODE = process.argv[3] === "problem"; // Examples are produced once; a field with a pattern but no example errors here. const EXAMPLES = Object.fromEntries( Object.entries(DEFINITION).flatMap(([a, t]) => Object.entries(t.response).map(([k, s]) => [`${a} ${k}`, makeExample(s)]))); const readBody = (req) => new Promise((resolve) => { let v = ""; req.on("data", (p) => (v += p)); req.on("end", () => resolve(v)); }); createServer(async (req, res) => { res.sendDate = false; const path = req.url.split("?")[0]; const key = req.method === "POST" && path === "/loans" ? "POST /loans" : req.method === "GET" && /^\/loans\/[^/]+$/.test(path) ? "GET /loans/{id}" : null; if (!key) { res.writeHead(404).end(); return; } await readBody(req); // Which status code to produce: success, or an error code in problem mode. const codes = Object.keys(DEFINITION[key].response).map(Number); const success = codes.find((k) => k < 400); const code = PROBLEM_MODE && req.headers["example-problem"] ? Number(req.headers["example-problem"]) : success; const body = structuredClone(EXAMPLES[`${key} ${code}`]); if (body && code >= 400) body.status = code; // the real code is written into the example body res.writeHead(code, { "content-type": code >= 400 ? "application/problem+json; charset=utf-8" : "application/json; charset=utf-8" }); res.end(JSON.stringify(body)); }).listen(PORT, "127.0.0.1", () => console.log(`mock server 127.0.0.1:${PORT}${PROBLEM_MODE ? " (problem mode)" : ""}`));
The client receives the server address from outside. The switch between the mock and the real server happens by changing this single dependency; nothing else changes.
// client.mjs — client that shows a loan record; the server address is supplied externally export async function showLoan(base, id, extraHeaders = {}) { const response = await fetch(`${base}/loans/${id}`, { headers: extraHeaders }); const body = await response.json(); const fields = Object.keys(body).join(","); if (response.status === 200) return { case: "success", fields, line: `${body.id} · ${body.member} · ${body.items.length} items · ${body.returnDate} · ${body.status}` }; if (response.status === 404) return { case: "not-found", fields, line: body.detail }; if (response.status === 422) return { case: "validation", fields, line: body.errors.map((h) => `${h.path}=${h.code}`).join(", ") }; return { case: "unknown", fields, line: `unexpected code ${response.status}` }; }
// real.mjs — real server that conforms to the definition import { createServer } from "node:http"; const RECORDS = new Map([["O-1", { id: "O-1", member: "U-1001", items: [{ isbn: "978-0201896831" }], returnDate: "2026-05-02", status: "closed" }]]); createServer((req, res) => { res.sendDate = false; const id = req.url.split("?")[0].replace("/loans/", ""); const record = RECORDS.get(id); if (record) { res.writeHead(200, { "content-type": "application/json; charset=utf-8" }); return res.end(JSON.stringify(record)); } res.writeHead(404, { "content-type": "application/problem+json; charset=utf-8" }); res.end(JSON.stringify({ type: "https://example.library/problems/resource-not-found", title: "Resource not found", status: 404, detail: `Loan record ${id} does not exist.`, instance: "oc-0007" })); }).listen(8440, "127.0.0.1", () => console.log("real server 127.0.0.1:8440"));
Same Code, Three Servers
The runner below runs the client against a given address, writes the returned body’s field set, and counts how many distinct branches of the client ran.
// run.mjs — runs the same client code against a given address, reports which cases // ran and the shape of the response. // Usage: node run.mjs <base-address> [problem] import { showLoan } from "./client.mjs"; const BASE = process.argv[2]; const PROBLEM = process.argv[3] === "problem"; const CASES = PROBLEM ? [["existing record", "O-1", {}], ["missing record", "O-9", { "example-problem": "404" }]] : [["existing record", "O-1", {}], ["missing record", "O-9", {}]]; const cases = new Set(); for (const [name, id, headers] of CASES) { const s = await showLoan(BASE, id, headers); cases.add(s.case); console.log(`${name.padEnd(16)} case=${s.case.padEnd(12)} ${s.line}`); console.log(`${"".padEnd(16)} fields: ${s.fields}`); } console.log(`distinct cases run: ${cases.size}/4 (${[...cases].join(", ")})`);
#!/usr/bin/env bash # Same client code: first against the mock server, then against the real server. node mock.mjs 8439 & mock=$! node real.mjs & real=$! sleep 0.6 echo "== mock server (success examples only) ==" node run.mjs http://127.0.0.1:8439 kill "$mock"; wait "$mock" 2>/dev/null node mock.mjs 8439 problem & mock=$! sleep 0.5 echo "== mock server (problem mode) ==" node run.mjs http://127.0.0.1:8439 problem kill "$mock"; wait "$mock" 2>/dev/null echo "== real server ==" node run.mjs http://127.0.0.1:8440 kill "$real"; wait "$real" 2>/dev/null
real server 127.0.0.1:8440
mock server 127.0.0.1:8439
== mock server (success examples only) ==
existing record case=success O-1 · U-1001 · 1 items · 2026-04-15 · open
fields: id,member,items,returnDate,status
missing record case=success O-1 · U-1001 · 1 items · 2026-04-15 · open
fields: id,member,items,returnDate,status
distinct cases run: 1/4 (success)
mock server 127.0.0.1:8439 (problem mode)
== mock server (problem mode) ==
existing record case=success O-1 · U-1001 · 1 items · 2026-04-15 · open
fields: id,member,items,returnDate,status
missing record case=not-found Example detail.
fields: type,title,status,detail,instance,errors
distinct cases run: 2/4 (success, not-found)
== real server ==
existing record case=success O-1 · U-1001 · 1 items · 2026-05-02 · closed
fields: id,member,items,returnDate,status
missing record case=not-found Loan record O-9 does not exist.
fields: type,title,status,detail,instance
distinct cases run: 2/4 (success, not-found)
The third section confirms the actual claim: client code developed against the mock server worked unchanged once connected to the real server. The success body’s field set is identical across both servers; the values differ, the shape does not. The only thing that changed to move the client to the real server was the address.
The first section shows the mock server’s trap. A mock server that only produces success examples returns 200 even when you ask for a record that does not exist. Only one of the client’s four branches runs, and the developer considers the job done without ever seeing the error paths. This is the most common cause of breakage in the move to the real server: what is missing is not the body shape but an error branch that was never written.
The second section gives the solution. When the mock server can learn which status code to produce from the request, all of the client’s paths can be run during development. The count of branches run went from one to two, reaching the same number as the real server. Being able to request every status code defined in the contract from the mock server turns it from a demo tool into a development tool.
The Mock Server’s Generosity
The field sets of the 404 bodies in the second and third sections differ: the mock server
also produced the errors field, the real server did not. Both conform to the contract,
because errors is not among the required fields.
The risk here is one-directional. Because the mock server also produces optional fields, the client can assume they will always be present. That assumption collapses in the move to the real server. There is no risk in the reverse direction: the required fields the mock server produces are also present in the real server.
This is why whether the example generator produces optional fields should be a configurable option. A client run in both modes has shown that it can stay standing with only the required fields present — which is what the contract actually says.
Summary
- The mock server turns the contract into something executable and lets the consumer develop without waiting for the provider.
- An example cannot be produced for fields that have a pattern; the schema must be complete enough that an example can be produced from it, and the gap shows up while the server is starting.
- The same client code runs unchanged on the mock and the real server; the success body’s field set is the same on both, only the values differ.
- A mock server that only produces a success example runs one of the client’s four branches and leaves the error paths never written.
- Being able to choose the status code requested from the mock server brings the count of branches run up to the same level as the real server.
- Because the mock server also produces optional fields, it can convince the client they will always be present; the generator should also have a mode that works with only the required fields.
Next Step
Throughout this topic, the contract was always built the same way: the server decides which resource to give with which fields, and the client makes do with what it is given. Partial response and field selection loosened this rigidity, but the server still drew the boundary. The query-based approach reverses this relationship: a type system is defined on the server, the client decides which fields come back, and the response takes the shape of the query. The next topic builds out this approach in detail; its first lesson writes the schema and type system, defines object, scalar, interface, and union types, and demonstrates them on a small runtime that actually resolves types.
To keep your progress and take notes, Log in
My notes
Log in to take notes.