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
delayto simulate "the user has time to see the loading state" — and assuming the rest of the suite needs the same delay.delayslows just one intercept. If you stub three endpoints withdelay: 3000for 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.
- In a fresh spec
cypress/e2e/products-states.cy.ts, target any list-rendering page in your app (or usehttps://reqres.in/api/usersas the API behind a small UI you control). SetbaseUrlaccordingly. - Create three fixtures in
cypress/fixtures/products/:full.json— 12 products, varied prices.empty.json—[].error.json—{ "error": "Internal server error" }.
- Write four
itblocks in onedescribe, each stubbing a different state (success-full, success-empty, 500-error, slow). Usedelay: 1500on the slow stub to assert the loading spinner appears. - Add a dynamic-stub test — register
cy.intercept("GET", "/api/products*", (req) => req.reply(...))that returns different bodies based onreq.query.page. Click the next-page button, assert page 2 contents differ from page 1. - 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. - 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.