Bun's in-process mocking story is actually good: bun:test ships jest-style mock() / spyOn() that can stub the global fetch, and MSW works in Bun via msw/node β we verified both before writing this (details in Β§8). So this guide isn't "your tools are broken." It's about the cases where an in-process interceptor can't help, because what you need is a URL:
types.ts (Β§3).The boring fix is a hosted mock API. Every snippet below was run verbatim against the live server before publishing (Bun 1.3) β you can paste and run them too.
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H 'content-type: application/json' -d '{"name":"bun-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.
// quickstart.ts
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(`HTTP ${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}`);
bun run quickstart.ts
Output when we ran it: 30 products total; page of 5. No tsconfig, no build step β Bun runs the .ts file directly. 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.
Mockbird generates TypeScript interfaces for your schema at /m/<project>/types.ts. Pull the file once and import it β since Bun executes TypeScript natively, there's no codegen pipeline to configure:
curl -o types.ts https://mockbird.mockbird.workers.dev/m/demo/types.ts
// typed.ts
import type { Product } from "./types.ts";
const r = await fetch("https://mockbird.mockbird.workers.dev/m/demo/products?limit=1");
const [p]: Product[] = await r.json();
console.log(p.name, p.price); // p is fully typed: name, price, category, inStock, ratingβ¦
bun run typed.ts # runs as-is
bunx tsc --noEmit --strict --target es2022 --module esnext \
--moduleResolution bundler --allowImportingTsExtensions typed.ts # type-checks it
Bun itself doesn't type-check (it strips types), so the bunx tsc --noEmit line is the enforcement step for CI. The checking is real β when we fed it const p: Product = { id: 1, name: "x" }, tsc failed with TS2740: Type 'β¦' is missing the following properties from type 'Product': description, price, image, category, and 2 more. Change your schema in the dashboard, re-curl the file, and your client code is re-checked against the new contract. (Prefer runtime validation? ?format=zod serves Zod schemas instead.)
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" });
PUT/PATCH/DELETE behave like a real backend β after the DELETE, a GET for that id really 404s (we checked). 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.log("forced status:", 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 as Error).name); // "TimeoutError"
}
That second one matters: an in-process stub 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 β we saw TimeoutError, live, in Bun 1.3.
Bun has no retry helper in the standard library, but bun add p-retry took 135 ms on our machine. Point it at an endpoint where ~half the requests fail with a random 5xx/429 and watch it earn its keep:
import pRetry from "p-retry";
const BASE = "https://mockbird.mockbird.workers.dev/m/demo";
let absorbed = 0;
async function getProducts() {
const r = await fetch(`${BASE}/products?mock_chaos=0.5&limit=1`);
if (!r.ok) {
absorbed++;
throw new Error(`HTTP ${r.status}`); // make injected 5xx/429 throw so pRetry kicks in
}
return r.json();
}
for (let i = 0; i < 5; i++) {
await pRetry(getProducts, { retries: 6, minTimeout: 200, factor: 2, randomize: true });
console.log(i, 200);
}
console.log(`${absorbed} injected failures absorbed by p-retry`);
Our run: five 200s, 2 injected failures absorbed by p-retry. 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. (With chaos at 0.5 and 6 retries there's a small give-up branch per call β if p-retry surfaces the error, that's the mock doing its job.) For deterministic 429 testing (fixed window, real Retry-After and x-ratelimit-* headers), use ?mock_ratelimit=N.
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 Bun's jest-compatible built-in runner, no extra dev-dependency. No beforeEach resets, no ordering constraints, and parallel tests can't race each other because nobody mutates anything:
// api.test.ts β run with: bun test
import { test, expect } from "bun:test";
const BASE = "https://mockbird.mockbird.workers.dev/m/abc123";
function api(snapshot = "baseline") {
return (path: string) =>
fetch(`${BASE}${path}`, { headers: { "X-Mockbird-Snapshot": snapshot } });
}
test("user list (baseline: always 5)", async () => {
const r = await api()("/users");
expect(await r.json()).toHaveLength(5);
});
test("empty state", async () => {
const r = await api("empty")("/users");
expect(await r.json()).toEqual([]);
expect(r.headers.get("x-total-count")).toBe("0");
});
We ran exactly this file before publishing: 2 pass, 0 fail β Ran 2 tests across 1 file. [266.00ms] β two real network round-trips, still comfortably fast. 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):
bun -e 'console.log(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.
In-process mocks are the right call for millisecond-fast pure unit tests, and Bun's options genuinely work β we tested them:
| bun:test mock()/spyOn | MSW (msw/node) | Mockbird | |
|---|---|---|---|
| What it is | Jest-style stubs built into Bun's runner | Interceptor patching Node http/fetch internals | Hosted mock API |
| Works in Bun today | β (we stubbed globalThis.fetch with spyOn().mockResolvedValue() β passed) | β (verified: setupServer + http.get handler intercepted a fetch in bun test, Bun 1.3 / MSW 2.x) | β this guide's snippets ran against it |
| Works offline / zero latency | β | β | β (real network) |
| CRUD, filters, pagination, search | You write every response | You write handlers | Built in |
| Reachable by teammates, frontend, CI, other services | β one process | β one process | β one https URL |
| Real timeouts / retry-after / slow responses | Simulated | Simulated | β mock_delay/mock_ratelimit |
| types.ts generated from your schema | β | β | β /types.ts (Β§3) |
| State persists across processes/runs | β | β | β (+ snapshots) |
| Free tier | built in | free (library) | free: 20 projects, 10k req/project/day |
Use both: keep bun:test mocks for unit tests that must run offline, and point integration tests, scripts, teaching materials and not-yet-built-backend work at a hosted URL.
Mockbird follows plain REST conventions, so switching to the real backend is one base-URL change β and Bun auto-loads .env, so const BASE = Bun.env.API_URL needs no dotenv package (we verified the swap works with the mock URL in .env). Hand your backend team the live contract while they build it: https://mockbird.mockbird.workers.dev/m/abc123/openapi.json, a Postman collection, or the same types.ts your Bun code already imports β one schema, checked at both ends.