Back to Blog
On this page5 sections

// tutorial

cy.intercept the right way: aliases, stubs, and the bug it usually catches

qa.codesqa.codes · 24 February 2026 · 8 min read
Intermediate
cypresstypescriptapi-testing

cy.intercept is the most powerful command in Cypress and the one teams most often misuse. Here's the playbook: when to alias, when to stub, when to spy, and the one bug that intercepts usually catch — race-condition-shaped assertions.

Spy vs stub: the line teams blur

The difference is simple. A spy intercepts the request and lets it go through to the real server. A stub intercepts the request and returns a fake response you control. Teams blur these constantly.

// Spy — request goes to the real API, you just watch it
cy.intercept('GET', '/api/users').as('getUsers');
 
// Stub — request is caught, real API never sees it
cy.intercept('GET', '/api/users', { fixture: 'users.json' }).as('getUsers');
 
// Stub with inline body
cy.intercept('GET', '/api/users', {
  statusCode: 200,
  body: [{ id: 1, name: 'Alice' }],
}).as('getUsers');

Use a spy when you need to assert on what the app sent or received from the real API. Use a stub when you need the response to be predictable — shape, latency, error codes.

The third form worth knowing is the callback stub, which lets you inspect the request before deciding what to respond with:

cy.intercept('POST', '/api/orders', (req) => {
  expect(req.body.total).to.be.greaterThan(0);
  req.reply({ statusCode: 201, body: { id: 'ord_123' } });
}).as('createOrder');

This is the pattern for testing that your app sends the right shape — not just that it handles the right response.

Aliases and cy.wait('@alias')

The .as() call gives the intercept a name. cy.wait('@name') blocks until that intercept has been matched by an actual request. This is the most important use of intercepts and the one most commonly skipped.

cy.intercept('GET', '/api/dashboard').as('loadDashboard');
cy.visit('/dashboard');
cy.wait('@loadDashboard');
 
// Now safe to assert — the data is loaded
cy.get('[data-testid="chart"]').should('be.visible');

Without cy.wait('@loadDashboard'), the assertion runs as soon as the DOM after cy.visit() is ready — which might be before the API call completes. If the chart renders from that API data, the assertion is a race.

cy.wait() resolves with the intercept object, so you can chain assertions on what the network exchanged:

cy.wait('@loadDashboard').then(({ request, response }) => {
  expect(response?.statusCode).to.equal(200);
  expect(response?.body.data).to.have.length.greaterThan(0);
});

This is underused. It's the cleanest way to assert on API contracts without a separate API test suite.

When to use fixture vs inline stub

Fixtures live in cypress/fixtures/ and are loaded with { fixture: 'filename.json' }. Inline stubs are plain objects in the test file. The rule is size and reuse.

Use a fixture when the stubbed response is large (more than 5–6 fields), shared across multiple tests, or mirrors a real API shape you want to version-control separately.

Use inline when the stub is small (an error response, a 3-key object), specific to one test, or when the shape is part of what the test is documenting.

// Fixture: 200-row product catalogue — lives in cypress/fixtures/products.json
cy.intercept('GET', '/api/products', { fixture: 'products.json' }).as('products');
 
// Inline: a 403 error shape — obvious from reading the test
cy.intercept('GET', '/api/orders', {
  statusCode: 403,
  body: { error: 'insufficient_permissions' },
}).as('ordersBlocked');

The mistake is defaulting to fixtures for everything. A test that uses { fixture: 'user-minimal-with-one-order.json' } is harder to read than a test that puts those three fields inline.

The race-condition trap: assertions that race the response

Here's the canonical bug cy.intercept + cy.wait catches. You visit a page, the page fires an async API call, and you immediately assert on the rendered result:

// Brittle: races the API response
cy.visit('/reports');
cy.get('[data-testid="report-count"]').should('have.text', '42');

If the API is fast, this passes in local development. In CI — with more latency, parallelism, and a cold app — it fails occasionally. The DOM renders before the data arrives, the text is empty or "Loading...", and the assertion times out.

The fix is explicit synchronisation:

cy.intercept('GET', '/api/reports/summary').as('summary');
cy.visit('/reports');
cy.wait('@summary');
cy.get('[data-testid="report-count"]').should('have.text', '42');

Now the assertion only runs after the network round-trip is done. Flake gone.

This pattern also applies when navigating between pages. If clicking a link triggers a fetch, intercept before the click and wait after:

cy.intercept('GET', '/api/user/preferences').as('prefs');
cy.get('[data-testid="settings-link"]').click();
cy.wait('@prefs');
cy.get('[data-testid="theme-selector"]').should('be.visible');

For reusable intercept setups that appear across many spec files, wrapping them in custom commands keeps your beforeEach hooks readable and the alias names consistent.

Two anti-patterns worth naming

Blanket stubs — intercepting every outbound request at the top of every test so nothing hits the real backend. This sounds like it speeds things up. In practice it means your tests are asserting against your own fake data, not the real integration. Stub what you need to control; let the rest through.

// Anti-pattern: stubs everything, tests nothing real
beforeEach(() => {
  cy.intercept('GET', '/api/**', { statusCode: 200, body: {} });
});

Forgetting the request body matchercy.intercept('POST', '/api/search') matches any POST to that URL regardless of what the app sends. If your test is verifying that a search sends the right query, you need to match on the body:

cy.intercept('POST', '/api/search', (req) => {
  if (req.body.query === 'cypress') {
    req.reply({ fixture: 'search-results.json' });
  }
}).as('search');

Without the body check, you get a passing test that doesn't verify the request shape at all.

cy.intercept is the right tool for API contracts, loading-state assertions, and eliminating timing flake. The teams that get the most out of it are the ones that treat cy.wait('@alias') as mandatory — not optional — whenever an assertion depends on network data.


// related

Tutorials·10 May 2026 · 7 min read

Custom Cypress commands that actually pay off

Most teams over-abstract too early. Four custom commands are worth writing on every Cypress project — login, seed, intercept, visit. The rest can wait.

cypresstypescriptpatterns