← All guides

Mock APIs in Cypress — cy.intercept, a hosted mock, or both

First, the part most "mock API for Cypress" posts skip: cy.intercept is genuinely excellent. It stubs at the network layer of the browser under test, works offline, answers instantly, and its alias + cy.wait('@alias') pattern is the cleanest way anywhere to assert that your app made a request. If everything that needs the fake data is the page Cypress is driving, use cy.intercept and stop reading.

But cy.intercept has a boundary that bites people, and it's right in the Cypress docs: it only sees requests made by the page. Requests you make with cy.request() — the tool Cypress itself recommends for API testing, seeding, and login shortcuts — go out from the Cypress Node process and are never intercepted. §3 below is a runnable test that proves it. So the moment you want API-level tests, seeded state, or data that anything other than the browser-under-test can see, you need a real URL. That's what a hosted mock is for — and the two tools compose nicely.

Every snippet below was run verbatim before publishing (Cypress 15.19, Node 22, Electron headless — 6 tests, all passing, including the 3-second real-latency run in §5).

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":"cy-demo","preset":"ecommerce"}'
# → {"id":"abc123","adminKey":"...","baseUrl":"https://mockbird.mockbird.workers.dev/m/abc123", ...}

One curl → a seeded multi-resource CRUD API (products, orders, customers, reviews) with filtering, sorting, pagination, relations and CORS. Prefer a UI? One click creates the same project in the dashboard. The snippets below use the public demo project so they run with zero setup.

2. API tests with cy.request

Cypress is a perfectly good API test runner — cy.request plus a mock URL means your API specs run before the backend exists, and writes are real state you can read back in the next step (something a static cy.intercept fixture can't do without you hand-maintaining the bookkeeping):

// cypress/e2e/api.cy.js
const API = "https://mockbird.mockbird.workers.dev/m/demo";

describe("products API (hosted mock)", () => {
  it("lists seeded products with pagination", () => {
    cy.request(`${API}/products?_page=2&_limit=5`).then((res) => {
      expect(res.status).to.eq(200);
      expect(res.body).to.have.length(5);
      expect(res.headers).to.have.property("x-total-count", "30"); // real header
    });
  });

  it("create → read back → delete (real state)", () => {
    cy.request("POST", `${API}/products`, {
      name: "Cypress Test Lamp",
      price: 49.5,
    }).then((create) => {
      expect(create.status).to.eq(201);
      const id = create.body.id;
      cy.request(`${API}/products/${id}`).its("body.name")
        .should("eq", "Cypress Test Lamp");
      cy.request("DELETE", `${API}/products/${id}`).its("status").should("eq", 200);
    });
  });

  it("error state is one query param", () => {
    cy.request({
      url: `${API}/products?mock_status=503`,
      failOnStatusCode: false,
    }).its("status").should("eq", 503);
  });
});

Config is minimal — no baseUrl needed for absolute URLs:

// cypress.config.js
const { defineConfig } = require("cypress");
module.exports = defineConfig({
  e2e: { supportFile: false, video: false, screenshotOnRunFailure: false },
});

3. The blind spot, proved

Here's the test. We register an intercept that would replace the products list with an empty array and count how often it fires — then hit the same URL with cy.request:

// cypress/e2e/intercept-blindspot.cy.js
const API = "https://mockbird.mockbird.workers.dev/m/demo";

describe("cy.intercept blind spot", () => {
  it("intercept handler never fires for cy.request", () => {
    let hits = 0;
    cy.intercept(`${API}/products*`, (req) => {
      hits += 1;
      req.reply({ statusCode: 200, body: [] });
    });
    cy.request(`${API}/products?_limit=3`).then((res) => {
      // Real data came back — the stub was NOT applied.
      expect(res.body).to.have.length(3);
      expect(hits).to.eq(0);
    });
  });
});

Result: passing. Three real products, hits === 0. This isn't a bug — the Cypress docs state that cy.request is never stubbed. The practical consequence: any test strategy built purely on cy.intercept covers only the browser page. Your seeding commands, API assertions, and login-by-request shortcuts all talk to whatever URL is really there — so make it a mock URL you control.

4. Loading and error states without stub bookkeeping

Because the mock itself understands simulation params, slow-API and broken-API tests don't need an intercept at all — the page under test experiences real latency end-to-end:

// cypress/e2e/ui.cy.js — app fetches `${API}/products?_limit=5&mock_delay=2000`
describe("loading state", () => {
  it("shows the spinner while the API is slow, then real data", () => {
    cy.visit("http://localhost:8123/?delay=2000");
    cy.get("#spinner").should("be.visible");          // still loading at t≈0
    cy.get("#list li", { timeout: 6000 }).should("have.length", 5);
    cy.get("#spinner").should("not.be.visible");
  });
});

Ran in 3.1s — the two seconds of spinner are real. The same trick covers the whole error matrix: ?mock_status=503, ?mock_status=401, ?mock_chaos=0.5 for flaky-API retry tests, ?mock_jitter=800 for realistic variance. QA can reproduce any of these states in a normal browser tab by pasting the URL — no Cypress required, which is exactly what an in-run intercept can never give you.

5. Per-spec data scenarios: one header

Empty list, edge cases, bug repro — instead of maintaining fixture files per scenario, save named snapshots once and pin any request to one, read-only, without touching live data:

// cypress/e2e/scenarios.cy.js
const API = "https://mockbird.mockbird.workers.dev/m/demo";

describe("empty-state scenario", () => {
  it("same URL serves the pinned empty snapshot", () => {
    cy.request({
      url: `${API}/products`,
      headers: { "X-Mockbird-Snapshot": "empty" },
    }).then((res) => {
      expect(res.status).to.eq(200);
      expect(res.body).to.deep.eq([]);
    });
    // live data untouched
    cy.request(`${API}/products?_limit=1`).its("body").should("have.length", 1);
  });
});

For page fetches, add the header app-side in test builds, or append ?mock_snapshot=empty to the URL. Specs running on different machines (Cypress parallelization, CI shards) can each pin a different scenario against one shared project — no restore races, no per-shard seed scripts.

6. Honest comparison

cy.intercept + fixturesHosted mock (Mockbird)
Browser-page stubbingExcellent — instant, offline, per-test isolationReal network round-trip (~50–200ms)
Asserting "app made this call"Best in class (cy.wait('@alias'))Request inspector (after the fact)
cy.request / API tests✗ never intercepted (§3)✔ it's a real URL
State across test stepsManual fixture bookkeeping✔ real CRUD persistence
Backend doesn't exist yetYou hand-write every response✔ schema + seeded data in one curl
Seen by teammates / QA / mobile / curl✗ exists only inside the run✔ shareable URL
Slow / flaky API simulationStatic delay per stubmock_delay, mock_chaos, mock_jitter per request
Data scenariosFixture file per scenario✔ named snapshots, pinned per request
Works offline✗ needs network

7. Use both — they compose

The pattern that works: point the app's API_URL at the hosted mock (env var in your test build), let cy.request seed and assert against it, and keep cy.intercept for the two things it's best at — spying (cy.wait('@alias') to assert a call happened with the right payload) and surgical overrides (force one specific response mid-flow while everything else stays real). Stub the exception, host the rule.

Data here is fake and seeded (no LLM involved); requests to the shared demo project are capped daily. Create your own project for real work — free, no signup: one click or one curl (§1). Related: Playwright · Jest · Vitest · deterministic test data · loading & error states.