Stubbing API Responses in Cypress with cy.intercept(): When to Fake It and When Not To
cy.intercept() makes Cypress tests deterministic by stubbing or spying on HTTP requests — but heavy stubbing can drift from the real API. Here's how to use it well.
05 Sept 2025, 13:35 UTC

The flaky test that waits on the network
Your end-to-end test clicks a button, the UI fetches /api/users, and the test asserts a row appears. Against a real backend this works most of the time — until the backend is slow, seeded data changes, or you need to test what happens when the API returns an empty list or a 500. Fixed waits like cy.wait(2000) paper over the problem and make suites slow and flaky.
The practical takeaway: cy.intercept() lets you control specific HTTP requests from the browser — stubbing responses, delaying them, forcing errors — and cy.wait('@alias') lets you synchronize on a request actually completing instead of guessing at timing. Used well, it makes tests deterministic. Used everywhere, it can lie to you. This post covers both sides.
Version note: cy.intercept() has been the interception API since Cypress 6, and the older cy.server()/cy.route() API was removed in Cypress 12. If a tutorial shows cy.route(), it's outdated. Check your version with npx cypress --version in your project directory.
Matching and stubbing: three levels of control
An intercept matches outgoing requests by HTTP method plus a URL — a substring, a glob, a regex, or a full RouteMatcher object for finer conditions like headers or query strings. What you do with a matched request comes in three flavors:
- Static object: pass a plain object as the response body. Fastest to write, good for simple cases.
- Fixture file: prefix a path with
fixture:to serve JSON fromcypress/fixtures/. Better for realistic payloads you want to reuse. - Route handler function: a callback receiving the request, where you can reply dynamically, modify headers, or call
req.continue()to pass through to the real server with modifications.
One rule trips up almost everyone at least once: register the intercept before the action that triggers the request. Intercepts are matched when the request fires, not when the test asserts. If you call cy.visit() first and the page fetches data on load, that request has already gone to the real network before your intercept existed.
A worked example: stubbing users and forcing an error state
Assume a page that loads GET /api/users on visit and renders a table, plus an error banner when the request fails. First, create cypress/fixtures/users.json with a small array of user objects matching your real API's shape.
// cypress/e2e/users.cy.js
describe('users page', () => {
it('renders stubbed users', () => {
cy.intercept('GET', '/api/users', { fixture: 'users.json' }).as('getUsers');
cy.visit('/users');
cy.wait('@getUsers');
cy.get('[data-cy=user-row]').should('have.length', 3);
});
it('shows an error banner when the API fails', () => {
cy.intercept('GET', '/api/users', { statusCode: 500, body: { message: 'boom' } }).as('getUsersFail');
cy.visit('/users');
cy.wait('@getUsersFail');
cy.get('[data-cy=error-banner]').should('be.visible');
});
});Run this with npx cypress open (or run for headless) from your project root; no special permissions needed beyond your normal dev setup. Two things to verify: the stubbed rows actually render (confirming the intercept controlled the response, not a lucky real backend), and the error banner appears only in the second test. If the first test passes with an empty fixture, your intercept probably isn't matching — open the Cypress command log and check whether the request shows as stubbed.
The second test is the real payoff. Forcing a 500 against a real backend is awkward; here it's one line. The same trick covers empty lists, slow responses (a route handler can add a delay), and malformed payloads — the edge cases where UI bugs actually live.
Spying instead of stubbing
You don't have to replace the response. cy.intercept('GET', '/api/users').as('getUsers') with no response argument just observes the real request. After cy.wait('@getUsers') you can assert on interception.response.statusCode or the request body. This is useful for smoke-style tests: you keep the real backend contract but still get deterministic synchronization instead of arbitrary waits.
The trade-off: stubs drift from reality
Every stubbed response is a claim about your API's contract, and nothing enforces that claim. If the backend renames a field, your stubbed tests stay green while production breaks. Other limitations worth knowing:
cy.intercept()only sees requests from the browser under test. Server-to-server calls are invisible to it.- Matching order can surprise you: a broad intercept can shadow a more specific one depending on registration order and middleware settings. Prefer specific matchers and check the command log when something unexpected serves a response.
- Over-stubbing everything turns your e2e suite into an expensive component test suite.
A pragmatic split: stub for edge cases and error states, but keep a smaller set of tests hitting a real (or contract-tested) backend for the happy paths that matter most.
Where to start
Pick your flakiest network-dependent test. Replace any fixed cy.wait(ms) with an aliased intercept and cy.wait('@alias'), then add one stubbed error-state test for the same endpoint. If both pass and the command log shows the requests as intercepted, you've got the pattern — and a suite that's faster, more deterministic, and covers failure modes you couldn't easily reach before.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.