← All guides

Mock a REST API for Deno β€” typed fetch and Deno.test against a real URL

Deno's story for faking HTTP inside one process is thinner than Node's: @std/testing/mock can stub() the global fetch (fine for unit tests, but you re-implement every response by hand), the once-popular deno.land/x/mock_fetch module no longer even loads (its crux.land dependency is gone β€” we tried: Module not found "https://crux.land/router@0.0.5"), and MSW is built for Node and browsers, not Deno. And none of them help when you need a URL:

The boring fix is a hosted mock API. Every snippet below was run verbatim against the live server before publishing (Deno 2.9) β€” 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":"deno-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 β€” scoped permissions, 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}`);
deno run --allow-net=mockbird.mockbird.workers.dev quickstart.ts

Output when we ran it: 30 products total; page of 5. Note the permission flag is scoped to one host β€” Deno's security model composes nicely with a mock that lives at a single hostname. 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. Import your API's types straight from the URL

This is the Deno-only trick. Mockbird generates TypeScript interfaces for your schema at /m/<project>/types.ts β€” and since Deno imports modules by URL, your client code can consume them directly from the mock server, no codegen step, no npm package, nothing to commit:

// typed.ts
import type { Product } from "https://mockbird.mockbird.workers.dev/m/demo/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…
deno check --allow-import=mockbird.mockbird.workers.dev typed.ts   # type-checks against the live schema
deno run --allow-net=mockbird.mockbird.workers.dev typed.ts        # type-only import is erased at runtime

Two details we verified so you don't have to: deno check (and deno run --check) needs --allow-import for the remote host, because fetching the types is an import; plain deno run doesn't, because a type-only import never hits the network at runtime. And the checking is real β€” when we fed it const p: Product = { id: 1, name: "x" }, Deno 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-run deno check --reload, and your client code is re-checked against the new contract. That's schema-driven development with zero tooling. (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 (await fetch(`${BASE}/products/${created.id}`, { method: "DELETE" })).body?.cancel();

PUT/PATCH/DELETE behave like a real backend. (This is the part JSONPlaceholder, FakeStoreAPI and DummyJSON fake β€” details with receipts in their respective guides.) That trailing .body?.cancel() is good Deno hygiene: release response bodies you don't read. 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
await err.body?.cancel();

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

6. Exercise real retry logic with injected chaos

The standard library ships a production-grade exponential-backoff helper β€” retry() from @std/async. Point it at an endpoint where ~half the requests fail with a random 5xx/429 and watch it earn its keep:

import { retry } from "jsr:@std/async/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) {
    await r.body?.cancel();
    absorbed++;
    throw new Error(`HTTP ${r.status}`);   // make injected 5xx/429 throw so retry() kicks in
  }
  return r.json();
}

for (let i = 0; i < 5; i++) {
  await retry(getProducts, { maxAttempts: 6, minTimeout: 200, multiplier: 2, jitter: 0.5 });
  console.log(i, 200);
}
console.log(`${absorbed} injected failures absorbed by retry()`);

Our run: five 200s, 5 injected failures absorbed by 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 attempts there's a ~1.6% give-up branch per call β€” if RetryError surfaces, 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. Deno.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. No beforeEach resets, no ordering constraints, and parallel tests can't race each other because nobody mutates anything:

// api_test.ts β€” run with: deno test --allow-net=mockbird.mockbird.workers.dev
import { assertEquals } from "jsr:@std/assert";

const BASE = "https://mockbird.mockbird.workers.dev/m/abc123";

function api(snapshot = "baseline") {
  return (path: string) =>
    fetch(`${BASE}${path}`, { headers: { "X-Mockbird-Snapshot": snapshot } });
}

Deno.test("user list (baseline: always 5)", async () => {
  const r = await api()("/users");
  assertEquals((await r.json()).length, 5);
});

Deno.test("empty state", async () => {
  const r = await api("empty")("/users");
  assertEquals(await r.json(), []);
  assertEquals(r.headers.get("x-total-count"), "0");
});

We ran exactly this file before publishing: ok | 2 passed | 0 failed (305ms). 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.

8. Honest comparison

In-process stubs are the right call for millisecond-fast pure unit tests. The split:

@std/testing/mock (stub fetch)mock_fetch (deno.land/x)MSWMockbird
What it isReplaces globalThis.fetch with your functionRoute-pattern fetch interceptorService-worker-style interceptorHosted mock API
Status todayβœ” maintained (std)βœ– broken β€” crux.land dep is gone; module fails to load (verified Aug 2026)βœ” maintained, but targets Node/browser β€” no official Deno supportβœ” this guide's snippets ran against it
Works offline / zero latencyβœ”β€”βœ” (where it runs)βœ– (real network)
CRUD, filters, pagination, searchYou write every responseβ€”You write handlersBuilt in
Reachable by teammates, frontend, CI, other servicesβœ– one processβ€”βœ– one processβœ” one https URL
Real timeouts / retry-after / slow responsesSimulatedβ€”Simulatedβœ” mock_delay/mock_ratelimit
Types for your schema, importable by URLβœ–β€”βœ–βœ” /types.ts (Β§3)
State persists across processes/runsβœ–β€”βœ–βœ” (+ snapshots)
Free tierfree (std)freefree (library)free: 20 projects, 10k req/project/day

Use both: keep stub() 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 (const BASE = Deno.env.get("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 the same types.ts your Deno 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, Bun, 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.