← All guides

Mock APIs in Jest — fix "fetch is not defined" and test against a real URL

Jest's in-process mocking is the most battle-tested there is: jest.mock() for modules, jest.spyOn(global, "fetch") for the global, and MSW's docs treat Jest as a first-class citizen. 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:

The boring answer is a hosted mock API. Every snippet below was run verbatim before publishing (Jest 30.4, Node 22, plain zero-config CommonJS — no Babel, no ts-jest) — the final run: Test Suites: 7 passed · Tests: 14 passed. Paste and run them yourself.

1. First, the classic: ReferenceError: fetch is not defined

Node has shipped a global fetch since v18, and in Jest's default node test environment it's there — we checked (typeof fetch === "function", Jest 30 / Node 22). But the moment a file uses the jsdom environment for component tests, fetch is gone, because jsdom doesn't implement it:

/** @jest-environment jsdom */
test("component that fetches", async () => {
  const res = await fetch("https://mockbird.mockbird.workers.dev/m/demo/products?limit=1");
  // ReferenceError: fetch is not defined
});

Two clean fixes, pick one:

npm i -D cross-fetch
// tests/jsdom-fetch.test.js
/**
 * @jest-environment jsdom
 */
// jsdom does not implement fetch — without the polyfill line below,
// every fetch() in this file throws "fetch is not defined".
require("cross-fetch/polyfill");

test("fetch works under jsdom once polyfilled", 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
});

That file passed against the live mock, under jsdom, in the same run as everything below.

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

curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H 'content-type: application/json' -d '{"name":"jest-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 jest        # that's it — no config file needed for any test here

3. Your first test against the mock

// tests/products.test.js
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) => p.price);
    expect(prices).toEqual([...prices].sort((a, b) => b - a));
    for (const p of products) expect(p.inStock).toBe(true);
  });
});

No jest.mock, no mockResolvedValue chains, 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.

4. Error states and real timeouts

Simulation is a query parameter, so each test picks its own failure — no mockImplementationOnce sequencing to keep in sync with call order:

// tests/errors.test.js
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, { 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 (§6), mock_ratelimit with real Retry-After headers.

5. Parallel test files without racing on data

Jest runs separate test files in parallel worker processes by default (maxWorkers). 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.js
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 workers!) do to the live data.
const pinned = (path) =>
  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.js
const API = "https://mockbird.mockbird.workers.dev/m/demo";

const pinned = (path) =>
  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) => p.name);

    // a name longer than any layout was designed for
    expect(names.some((n) => n.length > 100)).toBe(true);
    // unicode + markup-looking characters that must be escaped, not rendered
    expect(names.some((n) => n.includes("<em>"))).toBe(true);
  });

  it("handles price extremes", async () => {
    const products = await (await pinned("/products?limit=100")).json();
    const prices = products.map((p) => 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.

6. Retry logic vs. injected chaos

?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. (Bonus honesty: the popular p-retry is ESM-only, and plain CommonJS Jest can't require it without transform config — so here's the twelve-line version with nothing to configure.)

// tests/retry.test.js
const API = "https://mockbird.mockbird.workers.dev/m/demo";

let absorbed = 0;

// The code under test: exponential backoff around fetch.
// (p-retry is ESM-only, which plain CommonJS Jest can't require —
// twelve lines by hand and there's nothing to configure.)
async function getWithRetry(url, retries = 6, delayMs = 100) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url);
    if (res.ok) return res;
    if (attempt >= retries) throw new Error(`http ${res.status}`);
    absorbed++;
    await new Promise((r) => setTimeout(r, delayMs * 2 ** attempt));
  }
}

describe("resilience", () => {
  it("absorbs a 50% server failure rate", 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`);
  }, 30_000);
});

When we ran it: five green responses, absorbed 4 injected failures — the retry loop hid a 50% failure rate completely, against real HTTP errors with real backoff delays. A jest.spyOn(global, "fetch") version of this test asserts that your stub works, not that your retry policy does.

7. Contract tests with Zod

Every hand-written mock encodes an assumption about response shapes. Check the assumption itself (zod is a plain CommonJS-friendly install):

// tests/contract.test.js
const { z } = require("zod");

const API = "https://mockbird.mockbird.workers.dev/m/demo";

// Mirror of the API contract. TypeScript projects don't even write this —
// GET /m/demo/types.ts?format=zod generates it from the live schema.
const ProductSchema = z.object({
  id: z.number(),
  name: z.string(),
  price: z.number(),
  inStock: z.boolean(),
  category: z.string(),
  image: z.string().url(),
});
const ProductListSchema = z.array(ProductSchema);

describe("contract", () => {
  it("list response matches the 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. TypeScript projects skip writing the schema entirely — GET /m/demo/types.ts?format=zod serves generated Zod schemas for the project's live schema (plain interfaces at /types.ts); the Vitest guide shows that flow end to end.

8. Honest comparison: jest.mock / MSW / hosted mock

jest.mock / jest.spyOnMSWHosted mock (Mockbird)
Works offline / zero network latency✖ real HTTP
Per-call assertions (toHaveBeenCalledWith)✔ best in classpartial✖ (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 responsessimulatedsimulatedmock_delay/mock_chaos/mock_ratelimit
Parallel-safe frozen scenariosmanual fixturesmanual handlers✔ snapshot pinning (§5)
fetch under jsdomn/a (stubbed anyway)needs the same polyfillpolyfill or node env (§1)
Free tierbuilt infree (library)free: 20 projects, 10k req/project/day

Use both. Keep jest.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.

9. Ship day

Mockbird follows plain REST conventions, so switching to the real backend is one base-URL change: read process.env.API_URL and swap it per environment. Hand your backend team the live contract while they build: openapi.json, a Postman collection, or generated TypeScript types + Zod schemas — one schema, checked at both ends.

On Vitest instead? The Vitest guide is this guide's sibling (same suite, generated Zod contract tests included). The same project also 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 →
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.