Node has excellent libraries for faking HTTP inside one process: nock intercepts requests at the module level, axios-mock-adapter stubs one axios instance, undici ships a built-in MockAgent, and MSW patches fetch with service-worker semantics. They're the right tools for fast unit tests โ and this guide's comparison table says so plainly.
But an intercepted request can't help when you need a URL:
fetch and want a free API students can also write to (JSONPlaceholder fakes its writes โ POST returns an id, then the record isn't there).The boring fix is a hosted mock API. Every snippet below was run verbatim against the live demo project before publishing (Node 22) โ you can paste and run them too. Native fetch needs Node 18+.
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H 'content-type: application/json' -d '{"name":"node-demo"}'
# โ {"id":"abc123","adminKey":"...","baseUrl":"https://mockbird.mockbird.workers.dev/m/abc123", ...}
curl -X POST https://mockbird.mockbird.workers.dev/api/projects/abc123/resources \
-H 'content-type: application/json' -H 'x-admin-key: YOUR_KEY' \
-d '{"name":"users","template":"users","seed":50}'
That's 50 realistic users (names, emails, avatars, cities) with full CRUD, filtering, sorting, search and pagination. The snippets below use the public demo project so they run with zero setup.
const BASE = "https://mockbird.mockbird.workers.dev/m/demo";
const r = await fetch(`${BASE}/products?page=1&limit=5&sortBy=price&order=desc`);
if (!r.ok) throw new Error(r.status);
const products = await r.json();
const total = Number(r.headers.get("x-total-count")); // real header, not a fixture
console.log(`${total} products total; page of ${products.length}`);
Output when we ran it: 30 products total; page of 5. Exact-match filters (?category=tools), range operators (?price_gte=100), substring match (?name_like=river) and full-text ?q= all compose โ see the parameter reference.
const created = await (await fetch(`${BASE}/products`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Test Widget", price: 9.99 }),
})).json(); // 201 + the stored record
const back = await (await fetch(`${BASE}/products/${created.id}`)).json();
console.assert(back.name === "Test Widget"); // it persisted
await fetch(`${BASE}/products/${created.id}`, { method: "DELETE" }); // 404s afterwards
PUT/PATCH/DELETE behave like a real backend. (This is the part JSONPlaceholder, FakeStoreAPI and DummyJSON fake โ details with receipts in their respective guides.) Add ?mock_validate=1 to get real 422s with per-field errors when the body doesn't match the schema.
// any status code on demand
const err = await fetch(`${BASE}/products?mock_status=503`);
console.assert(err.status === 503);
// a genuinely slow response vs your client timeout
try {
await fetch(`${BASE}/products?mock_delay=3000`, { signal: AbortSignal.timeout(1000) });
} catch (e) {
console.log("timeout branch actually ran:", e.name); // "TimeoutError"
}
That second one matters: an in-process interceptor answers instantly, so your AbortSignal.timeout() handling is never truly exercised. Here the server really holds the response for 3 seconds and the abort really fires.
axios-retry is subtle (which methods, which statuses, exponential delay, Retry-After honoring). Point it at an endpoint where ~half the requests fail with a random 5xx/429 and watch it earn its keep:
import axios from "axios";
import axiosRetry from "axios-retry";
const client = axios.create({ baseURL: "https://mockbird.mockbird.workers.dev/m/demo" });
axiosRetry(client, {
retries: 5,
retryDelay: axiosRetry.exponentialDelay,
retryCondition: (err) =>
axiosRetry.isNetworkOrIdempotentRequestError(err) ||
[429, 500, 502, 503, 504].includes(err.response?.status),
});
for (let i = 0; i < 5; i++) {
const r = await client.get("/products", { params: { mock_chaos: 0.5, limit: 1 } });
console.log(i, r.status); // all 200 โ the retries absorbed the injected failures
}
mock_chaos=0.5 fails that fraction of requests with a random status from {500, 502, 503, 504, 429}; injected failures carry an x-mockbird-chaos: injected header, and chaos-failed writes are not applied โ so it's safe to point retrying write logic at it too. For deterministic 429 testing (fixed window, real Retry-After and x-ratelimit-* headers), use ?mock_ratelimit=N.
async function allRecords(resource, limit = 10) {
let page = 1, out = [], total = Infinity;
while (out.length < total) {
const r = await fetch(`${BASE}/${resource}?page=${page}&limit=${limit}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
total = Number(r.headers.get("x-total-count"));
out = out.concat(await r.json());
page++;
}
return out;
}
console.assert((await allRecords("products")).length === 30); // 3 real round-trips of 10
There's also opaque cursor pagination (?cursor=) if the API you're mimicking paginates that way.
Shared mutable test data is how suites go flaky. Mockbird lets you save named snapshots of a project's entire dataset, then answer any GET from a snapshot โ read-only, live data untouched โ by sending one header. Set the scenarios up once:
ADMIN='x-admin-key: YOUR_KEY'
P=https://mockbird.mockbird.workers.dev/api/projects/abc123
curl -X POST $P/snapshots -H "$ADMIN" -H 'content-type: application/json' -d '{"name":"baseline"}'
curl -X POST $P/resources/users -H "$ADMIN" -H 'content-type: application/json' -d '{"seed":0}' # wipe
curl -X POST $P/snapshots -H "$ADMIN" -H 'content-type: application/json' -d '{"name":"empty"}'
curl -X POST $P/snapshots/baseline/restore -H "$ADMIN" # put live data back
Then each test pins the scenario it wants โ with the built-in node:test runner, zero dependencies. No beforeEach resets, no ordering constraints, and parallel test files can't race each other because nobody mutates anything:
// api.test.mjs โ run with: node --test
import { test } from "node:test";
import assert from "node:assert/strict";
const BASE = "https://mockbird.mockbird.workers.dev/m/abc123";
function api(snapshot = "baseline") {
return (path) =>
fetch(`${BASE}${path}`, { headers: { "X-Mockbird-Snapshot": snapshot } });
}
test("user list (baseline: always 5)", async () => {
const r = await api()("/users");
assert.equal((await r.json()).length, 5);
});
test("empty state", async () => {
const r = await api("empty")("/users");
assert.deepEqual(await r.json(), []);
assert.equal(r.headers.get("x-total-count"), "0");
});
We ran exactly this file before publishing: pass 2, fail 0, duration_ms 501. Want to try pinning with zero setup? The public demo ships with built-in empty and edge-cases snapshots (100-char names, unicode, price 0, HTML-ish strings):
await (await fetch("https://mockbird.mockbird.workers.dev/m/demo/products?mock_snapshot=empty")).json()
// โ []
The same trick scales per-scenario โ edge-cases, bug-repro-1234 โ see the deterministic test data guide.
If part of your suite should stay in-process (offline CI, zero latency), you don't have to choose. GET /m/<project>/msw.js generates a self-contained MSW v2 handlers module with your current data baked in โ CRUD, filters, search, pagination with X-Total-Count, all in memory:
curl -o msw.js https://mockbird.mockbird.workers.dev/m/demo/msw.js
# import { setupServer } from 'msw/node'
# import { handlers } from './msw.js'
# setupServer(...handlers).listen()
One dataset, two consumption modes: hosted URL for integration tests and teammates, generated handlers for unit tests. Details in the MSW guide.
The in-process tools are genuinely good, and for pure unit tests they're usually the better choice. The split:
| nock | axios-mock-adapter | undici MockAgent | MSW | Mockbird | |
|---|---|---|---|---|---|
| What it is | Intercepts http/https module calls | Stubs one axios instance | Built-in undici/fetch interceptor | Service-worker-style interceptor | Hosted mock API |
| Works offline / zero latency | โ | โ | โ | โ | โ (real network) |
| Works with native fetch | Partial (undici needs extra setup) | โ axios only | โ | โ | โ (it's just a URL) |
| Reachable by teammates, frontend, CI, other services | โ one process | โ one process | โ one process | โ one process | โ one https URL |
| Real timeouts / retry-after / slow responses | Simulated at best | Simulated | Simulated | Simulated | โ mock_delay/mock_ratelimit |
| CRUD, filters, pagination, search | You write every stub | You write it | You write it | You write handlers | Built in |
| State persists across processes/runs | โ | โ | โ | โ | โ (+ snapshots) |
| Verify "was this called?" | โ its superpower | โ history | โ assertions | โ handlers | Partial: request inspector |
| Free tier | free (library) | free (library) | free (built-in) | free (library) | free: 20 projects, 10k req/project/day |
Use both: keep nock or MockAgent for millisecond-fast unit tests, and point integration tests, scripts, teaching materials and not-yet-built-backend work at a hosted URL. (Or generate the in-process half from the hosted one.)
Mockbird follows plain REST conventions, so switching to the real backend is one base-URL change (const BASE = process.env.API_URL). Hand your backend team the live contract while they build it: https://mockbird.mockbird.workers.dev/m/abc123/openapi.json, a Postman collection, or generated TypeScript types (?format=zod for Zod schemas) for compile-time safety against the same shapes.