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.
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.
// 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.
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.)
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.
// 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.
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.
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.
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) | MSW | Mockbird | |
|---|---|---|---|---|
| What it is | Replaces globalThis.fetch with your function | Route-pattern fetch interceptor | Service-worker-style interceptor | Hosted 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, 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 for your schema, importable by URL | β | β | β | β /types.ts (Β§3) |
| State persists across processes/runs | β | β | β | β (+ snapshots) |
| Free tier | free (std) | free | free (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.
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.