Vitest's built-in mocking is genuinely good: vi.mock() for modules, vi.stubGlobal("fetch", ...) for the global, vi.spyOn for call assertions — and MSW integrates cleanly when you want request-level handlers. If you're unit-testing pure logic, use those and stop reading. This guide is for the tests where an in-process stub is the wrong tool, because what you need is a URL:
AbortSignal.timeout for real;vi.mock of your API layer is a hand-written assumption about response shapes — nothing checks it against the actual contract (§6 fixes that with generated Zod schemas);The boring answer is a hosted mock API. Every snippet below was run verbatim before publishing (Vitest 4.1, Node 22) — the final run: Test Files 6 passed (6) · Tests 13 passed (13), files executing in parallel. Paste and run them yourself.
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H 'content-type: application/json' -d '{"name":"vitest-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":"products","template":"products","seed":30}'
That's 30 realistic products with full CRUD, filtering, sorting, search and pagination. The tests below use the public demo project so they run with zero setup:
npm i -D vitest # that's it — Node 18+ has global fetch built in
// tests/products.test.ts
import { describe, it, expect } from "vitest";
const API = "https://mockbird.mockbird.workers.dev/m/demo";
describe("product listing", () => {
it("returns the fields the UI needs", async () => {
const res = await fetch(`${API}/products?limit=5`);
expect(res.status).toBe(200);
expect(res.headers.get("x-total-count")).toBe("30");
const products = await res.json();
expect(products).toHaveLength(5);
for (const p of products) {
expect(p).toMatchObject({
id: expect.any(Number),
name: expect.any(String),
price: expect.any(Number),
inStock: expect.any(Boolean),
});
}
});
it("filters and sorts like the real backend will", async () => {
const res = await fetch(`${API}/products?inStock=true&sortBy=price&order=desc&limit=3`);
const products = await res.json();
const prices = products.map((p: any) => p.price);
expect(prices).toEqual([...prices].sort((a, b) => b - a));
for (const p of products) expect(p.inStock).toBe(true);
});
});
No vi.mock, no handler registry, no fixture import — the test exercises the same HTTP path your app uses, including a real X-Total-Count pagination header. 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.
Simulation is a query parameter, so each test picks its own failure — no handler swapping:
// tests/errors.test.ts
import { describe, it, expect } from "vitest";
const API = "https://mockbird.mockbird.workers.dev/m/demo";
// The code under test — a tiny client with the error handling you
// actually want to verify. In your app this lives in src/, not the test.
async function getProducts(url: string, { timeoutMs = 1000 } = {}) {
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
if (res.status === 503) throw new Error("maintenance");
if (!res.ok) throw new Error(`http ${res.status}`);
return res.json();
}
describe("error handling", () => {
it("surfaces maintenance mode as a friendly error", async () => {
await expect(
getProducts(`${API}/products?mock_status=503`),
).rejects.toThrow("maintenance");
});
it("maps other 5xx to a generic error", async () => {
await expect(
getProducts(`${API}/products?mock_status=500`),
).rejects.toThrow("http 500");
});
it("gives up when the API is slower than our timeout", async () => {
// mock_delay=3000 makes the endpoint take 3s; the client aborts at 1s.
await expect(
getProducts(`${API}/products?mock_delay=3000`),
).rejects.toThrow(/timeout|abort/i);
});
});
All three pass — and the timeout test takes a real 1.0s, because the server genuinely holds the response for 3s while the client aborts at 1s. That's your actual AbortSignal code path running, not a stub pretending. Also available: mock_delay up to 5s, mock_chaos (§5), mock_ratelimit with real Retry-After headers.
Vitest runs separate test files in parallel by default. If your files share one mutable dataset — every "reset the DB in beforeEach" strategy — they race. The fix: named snapshots, pinned per request with a header. Reads become frozen and read-only; no file can corrupt another's world.
The public demo has two prepared snapshots, empty and edge-cases. Two test files, each pinning its own:
// tests/empty-state.test.ts
import { describe, it, expect } from "vitest";
const API = "https://mockbird.mockbird.workers.dev/m/demo";
// This whole file sees the "empty" snapshot — read-only, no matter what
// other test files (running in parallel!) do to the live data.
const pinned = (path: string) =>
fetch(`${API}${path}`, { headers: { "X-Mockbird-Snapshot": "empty" } });
describe("empty state", () => {
it("renders the zero-products case", async () => {
const res = await pinned("/products");
expect(res.status).toBe(200);
expect(await res.json()).toEqual([]);
});
it("single-record lookups 404 cleanly", async () => {
const res = await pinned("/products/1");
expect(res.status).toBe(404);
});
});
// tests/edge-cases.test.ts
import { describe, it, expect } from "vitest";
const API = "https://mockbird.mockbird.workers.dev/m/demo";
const pinned = (path: string) =>
fetch(`${API}${path}`, { headers: { "X-Mockbird-Snapshot": "edge-cases" } });
describe("edge cases the seeded data never has", () => {
it("handles brutal product names", async () => {
const products = await (await pinned("/products?limit=100")).json();
const names = products.map((p: any) => p.name);
// a name longer than any layout was designed for
expect(names.some((n: string) => n.length > 100)).toBe(true);
// unicode + markup-looking characters that must be escaped, not rendered
expect(names.some((n: string) => n.includes("<em>"))).toBe(true);
});
it("handles price extremes", async () => {
const products = await (await pinned("/products?limit=100")).json();
const prices = products.map((p: any) => p.price);
expect(Math.min(...prices)).toBe(0); // free item — don't divide by price!
expect(Math.max(...prices)).toBeGreaterThan(1_000_000);
});
it("does not assume ids are sequential", async () => {
const res = await pinned("/products/9999");
expect(res.status).toBe(200);
expect((await res.json()).name).toBe("Non-Sequential ID");
});
});
The edge-cases snapshot is the data your seeded happy-path never contains: a 118-character product name, unicode + markup that must be escaped, a price of 0, a price over a million, and a non-sequential id — frozen, so the assertions are exact.
Both files passed in the same parallel run as every other file in this guide. On your own project you create snapshots with one POST per scenario (baseline, empty, bug-1234...) — the deterministic test data guide covers the workflow.
?mock_chaos=0.5 makes the server fail half of all requests with a random 500/502/503/504/429. Point real backoff code at it:
// tests/retry.test.ts
import { describe, it, expect } from "vitest";
import pRetry from "p-retry";
const API = "https://mockbird.mockbird.workers.dev/m/demo";
let absorbed = 0;
// The code under test: exponential backoff around fetch.
async function getWithRetry(url: string) {
return pRetry(
async () => {
const res = await fetch(url);
if (!res.ok) throw new Error(`http ${res.status}`);
return res;
},
{
retries: 6,
minTimeout: 100,
factor: 2,
onFailedAttempt: () => absorbed++,
},
);
}
describe("resilience", () => {
it("absorbs a 50% server failure rate", { timeout: 30_000 }, async () => {
// mock_chaos=0.5 makes the mock fail half of all requests with a
// random 500/502/503/504/429 — the retry loop should hide all of it.
for (let i = 0; i < 5; i++) {
const res = await getWithRetry(`${API}/products?limit=1&mock_chaos=0.5`);
expect(res.status).toBe(200);
}
console.log(`absorbed ${absorbed} injected failures`);
});
});
When we ran it: five green responses, absorbed 8 injected failures — the retry loop hid a 50% failure rate completely, against real HTTP errors with real backoff delays. A vi.stubGlobal version of this test asserts that your stub works, not that your retry policy does.
The mock serves generated Zod schemas for its current schema at /types.ts?format=zod (plain TypeScript interfaces at /types.ts):
curl -o src/apiSchemas.ts \
"https://mockbird.mockbird.workers.dev/m/demo/types.ts?format=zod"
npm i zod
// tests/contract.test.ts
import { describe, it, expect } from "vitest";
import { ProductListSchema, ProductSchema } from "../src/apiSchemas";
const API = "https://mockbird.mockbird.workers.dev/m/demo";
describe("contract", () => {
it("list response matches the generated schema", async () => {
const data = await (await fetch(`${API}/products?limit=10`)).json();
// .parse throws with a precise path if any field drifts
const products = ProductListSchema.parse(data);
expect(products).toHaveLength(10);
});
it("single-record response matches too", async () => {
const data = await (await fetch(`${API}/products/1`)).json();
const product = ProductSchema.parse(data);
expect(product.id).toBe(1);
});
});
Now shape drift fails loudly with a precise path (products[3].price: expected number) instead of surfacing as a weird UI bug three components downstream. Re-download the file whenever the schema changes — or fetch it in globalSetup so it's always current.
Component tests usually set environment: "jsdom", and a classic worry is "is fetch even defined there?" Yes — Vitest keeps Node's global fetch available under jsdom, so component tests can hit the mock URL directly. We verified:
// tests/jsdom-check.test.ts
// @vitest-environment jsdom
import { it, expect } from "vitest";
it("fetch works under the jsdom environment too", async () => {
const res = await fetch("https://mockbird.mockbird.workers.dev/m/demo/products?limit=1");
expect(res.status).toBe(200);
expect(document).toBeDefined(); // we really are in jsdom
});
| vi.mock / vi.stubGlobal | MSW | Hosted mock (Mockbird) | |
|---|---|---|---|
| Works offline / zero network latency | ✔ | ✔ | ✖ real HTTP |
Per-call assertions (toHaveBeenCalledWith) | ✔ best in class | partial | ✖ (request inspector instead) |
| Zero mock code to write/maintain | ✖ stubs per test | ✖ handlers | ✔ schema → API |
| Same data for browser, teammates, CI, other services | ✖ one process | ✖ one process | ✔ one https URL |
| Real timeouts, retry-after, slow responses | simulated | simulated | ✔ mock_delay/mock_chaos/mock_ratelimit |
| Parallel-safe frozen scenarios | manual fixtures | manual handlers | ✔ snapshot pinning (§4) |
| Schema/types generated for you | ✖ | ✖ | ✔ /types.ts + Zod (§6) |
| Free tier | built in | free (library) | free: 20 projects, 10k req/project/day |
Use both. Keep vi.mock for pure unit tests that must run offline and make call-level assertions; point integration tests, contract tests, resilience tests and not-yet-built-backend work at a hosted URL. They compose in one suite — the in-process tools simply ignore requests to hosts they don't stub.
Mockbird follows plain REST conventions, so switching to the real backend is one base-URL change: read process.env.API_URL (Vite apps: import.meta.env.VITE_API_URL) and swap it per environment. Hand your backend team the live contract while they build: openapi.json, a Postman collection, or the same Zod schemas your tests already import — one schema, checked at both ends.
fetch is not defined. The same project feeds Playwright, bun test, Deno and node:test suites at once — plus React, Vue, SvelteKit and Next.js apps in dev. Migrating off nock? See the nock guide. Docs →