← All guides

Mock a REST API for Bun β€” typed fetch and bun test against a real URL

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:

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.

1. Get an API (10 seconds, no signup)

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.

2. fetch quickstart β€” real pagination headers

// 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.

3. Type-check your client against the live schema

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.)

4. Writes are real

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.

5. Sad paths: force any status, hit a real timeout

// 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.

6. Exercise real retry logic with injected chaos

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.

7. bun test: pin each test to a frozen snapshot

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.

8. Honest comparison

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()/spyOnMSW (msw/node)Mockbird
What it isJest-style stubs built into Bun's runnerInterceptor patching Node http/fetch internalsHosted 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, searchYou write every responseYou write handlersBuilt in
Reachable by teammates, frontend, CI, other servicesβœ– one processβœ– one processβœ” one https URL
Real timeouts / retry-after / slow responsesSimulatedSimulatedβœ” mock_delay/mock_ratelimit
types.ts generated from your schemaβœ–βœ–βœ” /types.ts (Β§3)
State persists across processes/runsβœ–βœ–βœ” (+ snapshots)
Free tierbuilt infree (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.

9. Ship day

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.

Also mocking for a frontend? The same project feeds React, Vue, Angular, Next.js, SvelteKit, React Native, Flutter and Android at once β€” one dataset for the whole team. Node.js, Deno, Python, Go, Java, Ruby, PHP, Rust, Elixir or C#/.NET service in the mix too? Same drill. Docs β†’
⚑ Skip the terminal: this link creates a live, seeded backend (products, orders, customers, reviews) in the dashboard β€” real URL, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.