Stubbing API Responses

8 min read

Spying on a request tells you what is happening. Stubbing tells the app what will happen. With one extra argument to cy.intercept, the network call never reaches your real backend — Cypress synthesises the response, the app receives it, and the test runs against a deterministic state you control. This is the lever that lets you test empty states, error states, slow networks, and pagination cliffs without ever touching a database.

What stubbing is and why it matters

A stubbed response is a fake response. The browser still makes the request; Cypress catches it before it leaves the page; the response Cypress hands back is whatever you wrote in the test. The real server is never called.

Four reasons teams adopt heavy stubbing for frontend tests:

  • Speed. No network round-trip, no backend cold start. A spec that stubs 12 endpoints might run in 2 seconds where the real-backend version takes 25.
  • Reliability. Tests don't break because staging has stale data, the API team is mid-deploy, or the seed script lost a row last Tuesday.
  • Edge-case coverage. Empty states, 500 errors, network timeouts, and "the user has 10,000 items" are all hard to reproduce against a real backend. Trivial to stub.
  • Isolation. Frontend tests can fail for two reasons — the frontend broke or the backend broke. Stubbing eliminates the second so red runs always mean a frontend bug.

The trade-off: stubs can drift from reality. Chapter 4 will return to the "stub vs real backend" choice in the api-cy-request lesson; for now, learn the mechanics.

A minimal stub

Pass a response object as the third argument to cy.intercept:

cy.intercept("GET", "/api/products", {
  statusCode: 200,
  body: [
    { id: 1, name: "Laptop", price: 999.99 },
    { id: 2, name: "Phone", price: 699.99 },
  ],
}).as("getProducts");
 
cy.visit("/products");
cy.wait("@getProducts");
cy.get("[data-testid='product-card']").should("have.length", 2);

The app's GET /api/products is intercepted; the real server is never called; the app receives the two-product list verbatim and renders it. The test has now decoupled the frontend from any backend state.

Stubbing from a fixture

Inline data is fine for two products. Real stubs typically need ten or fifty entries — that lives in a fixture file:

// cypress/fixtures/products.json
[
  { "id": 1, "name": "Laptop", "price": 999.99 },
  { "id": 2, "name": "Phone", "price": 699.99 },
  { "id": 3, "name": "Headphones", "price": 199.99 }
  // ... and so on
]
cy.intercept("GET", "/api/products", { fixture: "products.json" })
  .as("getProducts");

The fixture key reads from cypress/fixtures/products.json and uses its parsed contents as the body. Lesson 5 of this chapter goes deeper into fixtures — for now, just know they keep stubbed data out of your spec files when the payload is large.

Stubbing error responses

Test how the UI handles a backend failure without crashing the backend:

it("shows an error banner when the products API fails", () => {
  cy.intercept("GET", "/api/products", {
    statusCode: 500,
    body: { error: "Internal server error" },
  }).as("getProductsError");
 
  cy.visit("/products");
  cy.wait("@getProductsError");
 
  cy.get("[data-testid='error-banner']")
    .should("be.visible")
    .and("contain", "Something went wrong");
  cy.get("[data-testid='product-card']").should("not.exist");
});

This is the test that would otherwise require an hour of backend cooperation — temporarily corrupting the DB, throwing a flag, restarting a service. With stubbing, it's three lines and runs in under a second.

The same pattern covers 401 (unauthorised), 403 (forbidden), 404 (missing), 422 (validation), 503 (service unavailable). Each one exercises a different branch of your error-handling code that's almost impossible to verify with a real backend.

Stubbing slow responses

Reproduce a slow-network scenario without leaving CI:

cy.intercept("GET", "/api/products", {
  statusCode: 200,
  body: [],
  delay: 3000,    // 3 seconds before the response is delivered
}).as("slowProducts");
 
cy.visit("/products");
cy.get("[data-testid='loading-spinner']").should("be.visible");
cy.wait("@slowProducts");
cy.get("[data-testid='loading-spinner']").should("not.exist");
cy.get("[data-testid='empty-state']").should("be.visible");

The delay option pauses the response by the given milliseconds. This is exactly how you write a deterministic test for "the loading spinner is shown, then hidden when the response arrives." Without delay, the response is so fast (synchronous, in-memory) that the spinner never renders and the assertion never runs.

A second knob — throttleKbps — caps bandwidth, useful for testing large-payload behaviour on a slow connection.

Dynamic stubbing with a route handler

When the response depends on the request — different bodies for different query parameters, different IDs, different times of day — pass a function instead of an object:

cy.intercept("GET", "/api/products*", (req) => {
  const page = Number(req.query.page ?? 1);
  req.reply({
    statusCode: 200,
    body: {
      page,
      total: 30,
      items: Array.from({ length: 10 }, (_, i) => ({
        id: i + (page - 1) * 10 + 1,
        name: `Product ${i + (page - 1) * 10 + 1}`,
        price: 9.99,
      })),
    },
  });
}).as("getProducts");

req.reply() ends the request with the response you describe. The handler runs once per request, so different page numbers get different bodies — full pagination coverage from a single intercept.

The req object also exposes req.headers, req.body, and req.url for branching: "if the auth header is missing, return 401; otherwise return the user list."

Modifying real responses

Sometimes you want most of the real response, with one field tweaked. req.continue lets the request go through, then hands you the response to modify before the app sees it:

cy.intercept("GET", "/api/products", (req) => {
  req.continue((res) => {
    // Real server replied; mutate the body before delivery.
    res.body.products[0].name = "Modified for test";
    res.send();
  });
});

This is useful when you want production-like data with one targeted change — e.g., force one product to have a name with special characters to test rendering, while every other product comes from the real backend. It's a hybrid mode: spy + selective stub.

Stub vs real call — what changes

Real network call vs Cypress-stubbed call

Real call

  • Browser → real backend → database

  • Real auth, real data, real failure modes

  • Slower; depends on backend availability

  • Hard to reproduce edge cases on demand

Stubbed call

  • Browser → cy.intercept → Cypress-built response

  • Backend never touched; tests are deterministic

  • Sub-millisecond per call; suite runs much faster

  • Empty state, 500, 401, slow network — one-line tests

A four-state test of one page

The classic e-commerce demo of stubbing — write four tests for one component, each exercising a different backend state:

describe("Product list — every state", () => {
  it("shows products when the API succeeds", () => {
    cy.intercept("GET", "/api/products", { fixture: "products/full.json" })
      .as("getProducts");
    cy.visit("/products");
    cy.wait("@getProducts");
    cy.get("[data-testid='product-card']").should("have.length", 12);
  });
 
  it("shows the empty state when the list is empty", () => {
    cy.intercept("GET", "/api/products", { body: [] }).as("getProducts");
    cy.visit("/products");
    cy.wait("@getProducts");
    cy.get("[data-testid='empty-state']").should("contain", "No products yet");
  });
 
  it("shows the error banner on a 500", () => {
    cy.intercept("GET", "/api/products", {
      statusCode: 500,
      body: { error: "Internal server error" },
    }).as("err");
    cy.visit("/products");
    cy.wait("@err");
    cy.get("[data-testid='error-banner']").should("be.visible");
  });
 
  it("renders the loading spinner before the response", () => {
    cy.intercept("GET", "/api/products", {
      statusCode: 200,
      body: [],
      delay: 1500,
    }).as("slow");
    cy.visit("/products");
    cy.get("[data-testid='loading-spinner']").should("be.visible");
    cy.wait("@slow");
    cy.get("[data-testid='loading-spinner']").should("not.exist");
  });
});

Four independent tests, four backend scenarios, zero backend dependencies. This is the pattern every QA engineer needs in their reflexes.

⚠️ Common mistakes

  • Stubbing a response shape that doesn't match what the real backend returns. A stub like { items: [] } when the real API returns { products: [] } produces a green test that completely fails to exercise reality. Keep stubs in sync — derive them from real responses, lean on TypeScript interfaces shared with the API client, or run a contract test in CI.
  • Using delay to simulate "the user has time to see the loading state" — and assuming the rest of the suite needs the same delay. delay slows just one intercept. If you stub three endpoints with delay: 3000 for a single test, the spec runs nine seconds longer than it needs to. Reserve delay for tests that genuinely assert on transient UI; everywhere else, omit it.
  • Stubbing every endpoint and never running the real-backend version. A frontend that's only tested against stubs eventually drifts from the contract. Most teams strike a balance: stub for unit-flavored tests of one component and one state; run a smaller, slower "real backend" suite that exercises the integration. Don't go all-or-nothing.

🎯 Practice task

Build a full four-state test suite for a single page. 25-30 minutes.

  1. In a fresh spec cypress/e2e/products-states.cy.ts, target any list-rendering page in your app (or use https://reqres.in/api/users as the API behind a small UI you control). Set baseUrl accordingly.
  2. Create three fixtures in cypress/fixtures/products/:
    • full.json — 12 products, varied prices.
    • empty.json[].
    • error.json{ "error": "Internal server error" }.
  3. Write four it blocks in one describe, each stubbing a different state (success-full, success-empty, 500-error, slow). Use delay: 1500 on the slow stub to assert the loading spinner appears.
  4. Add a dynamic-stub test — register cy.intercept("GET", "/api/products*", (req) => req.reply(...)) that returns different bodies based on req.query.page. Click the next-page button, assert page 2 contents differ from page 1.
  5. Add a hybrid test — use req.continue((res) => res.body.products[0].name = "MODIFIED") to mutate one real-server response field. Confirm the rest of the page renders normal data while the first product shows the mutated name.
  6. Stretch: type your most-stubbed endpoint with cy.intercept<RequestBody, ResponseBody>(...) generics. Edit the fixture to break the typed contract (rename a field). Watch the compiler complain. Fix. This is the workflow that keeps stubs honest as the API evolves.

The next lesson covers the other half of cy.intercept's power — cy.wait('@alias') patterns for sequencing multiple requests, paginated flows, and timing-sensitive tests.

// tip to track lessons you complete and pick up where you left off across devices.