Free Ebook cover Cypress End-to-End Testing Basics: Stable UI Tests for Web Apps

Cypress End-to-End Testing Basics: Stable UI Tests for Web Apps

New course

10 pages

Network Stubbing Basics with cy.intercept for Deterministic Cypress E2E Tests

Capítulo 6

Estimated reading time: 10 minutes

+ Exercise

1) Why real networks cause instability

UI tests become flaky when they depend on network behavior you do not control. Even if your app is correct, the test can fail because the environment changes between runs.

  • Latency and timing variance: slow responses can delay UI updates, causing assertions to run before the UI is ready.
  • Backend availability: transient 500s, deploys, or maintenance windows can break tests unrelated to the UI behavior you want to validate.
  • Data drift: real databases change; a “search result exists” today may not exist tomorrow.
  • Third-party dependencies: analytics, feature flags, payment providers, and CDNs can introduce unpredictable responses.
  • Rate limits and auth: shared test accounts can be locked, tokens can expire, and rate limits can kick in.

Network stubbing makes tests deterministic by controlling responses for specific requests. With cy.intercept(), you can return stable data, simulate edge cases, and assert that the app sends the right requests—without relying on real services for every scenario.

2) Identifying requests to stub using DevTools and Cypress logs

Use browser DevTools (Network tab)

When you perform an action (login, search, save), open DevTools → Network and look for:

  • Request URL (path and query string)
  • HTTP method (GET/POST/PUT/DELETE)
  • Status code and response body
  • Headers (especially auth headers)
  • Timing (slow endpoints are common flake sources)

Copy the request as cURL (or just note method + URL + payload). This becomes your intercept target.

Use Cypress runner logs

In the Cypress runner, you can make network activity visible and debuggable by aliasing intercepts and waiting on them. This helps you confirm you are stubbing the correct call and that the UI waits for it deterministically.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

// Example: observe a request and wait for it deterministically
cy.intercept('GET', '/api/search*').as('search')
cy.get('[data-testid="search-input"]').type('cypress{enter}')
cy.wait('@search')

If cy.wait('@search') times out, you likely targeted the wrong method/URL pattern, or the request is not being made (UI bug, debounce, validation, etc.).

3) cy.intercept patterns: static fixtures, inline objects, conditional stubs

cy.intercept() can stub responses in multiple ways. Choose the simplest approach that makes the test deterministic and readable.

Pattern A: Static fixtures (stable canned responses)

Use a fixture file when the response is larger or reused across tests. Even though fixtures were covered earlier, the key point here is how to wire them into cy.intercept to control the network.

// Stubbing with a fixture file
cy.intercept('GET', '/api/search*', { fixture: 'search-results.json' }).as('search')

Best for: repeatable lists (search results, product catalogs), complex nested payloads, and responses reused across multiple specs.

Pattern B: Inline objects (small, scenario-focused payloads)

Inline stubs keep the test self-contained for small payloads and make the scenario obvious.

// Inline stub response
cy.intercept('GET', '/api/me', {
  statusCode: 200,
  body: { id: 'u_123', name: 'Test User', plan: 'free' }
}).as('me')

Best for: small responses, single-object endpoints, and tests where readability matters more than reuse.

Pattern C: Conditional stubs (vary response based on request)

Conditional stubs let you simulate different server outcomes depending on request body, query params, or headers.

// Conditional stub based on query string
cy.intercept('GET', '/api/search*', (req) => {
  const q = req.query.q

  if (q === 'empty') {
    req.reply({ statusCode: 200, body: { results: [] } })
  } else if (q === 'error') {
    req.reply({ statusCode: 500, body: { message: 'Internal error' } })
  } else {
    req.reply({
      statusCode: 200,
      body: { results: [{ id: 'p1', title: 'Cypress Book' }] }
    })
  }
}).as('search')

Best for: testing UI branching (empty states, errors, different roles) without duplicating test setup.

Step-by-step example: stubbing login deterministically

Goal: test the login UI flow without relying on a real auth service. You will stub the login request and any follow-up “current user” request so the app behaves as if the user is authenticated.

Step 1: Stub the login POST

Identify the login endpoint in DevTools (commonly POST /api/login or POST /auth/token). Then stub it with a stable token payload.

cy.intercept('POST', '/api/login', (req) => {
  // Optional: validate request payload early
  expect(req.body).to.have.keys(['email', 'password'])

  req.reply({
    statusCode: 200,
    body: {
      token: 'fake-jwt-token',
      user: { id: 'u_1', name: 'Test User', email: req.body.email }
    }
  })
}).as('login')

Step 2: Stub the “who am I” / session bootstrap call

Many apps call GET /api/me (or similar) after login or on page load. Stub it so the UI can render the authenticated state deterministically.

cy.intercept('GET', '/api/me', {
  statusCode: 200,
  body: { id: 'u_1', name: 'Test User', email: 'test@example.com' }
}).as('me')

Step 3: Drive the UI and wait on network aliases

Waiting on aliases removes timing guesswork and makes the test resilient to UI rendering speed.

cy.visit('/login')
cy.get('[data-testid="email"]').type('test@example.com')
cy.get('[data-testid="password"]').type('secret123')
cy.get('[data-testid="submit"]').click()

cy.wait('@login').its('response.statusCode').should('eq', 200)
cy.wait('@me')

cy.get('[data-testid="nav-user"]').should('contain', 'Test User')

Tip: if your app stores tokens in localStorage/cookies, you can still stub the network and let the app perform its normal storage logic, which keeps the test closer to real behavior while remaining deterministic.

Step-by-step example: stubbing search results (success, empty, and debounced calls)

Goal: test the search UI without relying on changing backend data. You will stub the search endpoint and assert the UI renders results deterministically.

Step 1: Intercept the search request with a flexible URL pattern

Search endpoints often include query params like ?q=.... Use a wildcard pattern so you don’t have to match the full URL exactly.

cy.intercept('GET', '/api/search*', (req) => {
  if (req.query.q === 'cypress') {
    req.reply({
      statusCode: 200,
      body: {
        results: [
          { id: 'r1', title: 'Cypress Testing Basics' },
          { id: 'r2', title: 'Stable UI Tests' }
        ]
      }
    })
  } else {
    req.reply({ statusCode: 200, body: { results: [] } })
  }
}).as('search')

Step 2: Trigger search and wait for the request

If the UI debounces input, the request may fire after a short delay. Waiting on the alias is more reliable than arbitrary cy.wait(500).

cy.visit('/search')
cy.get('[data-testid="search-input"]').type('cypress')
cy.get('[data-testid="search-submit"]').click()

cy.wait('@search')
cy.get('[data-testid="search-result"]').should('have.length', 2)
cy.get('[data-testid="search-result"]').first().should('contain', 'Cypress Testing Basics')

Step 3: Test the empty state deterministically

cy.get('[data-testid="search-input"]').clear().type('nope')
cy.get('[data-testid="search-submit"]').click()

cy.wait('@search')
cy.get('[data-testid="empty-state"]').should('be.visible')

Step-by-step example: stubbing error states to test UI handling

Goal: verify that your UI shows an error banner/message and offers retry behavior when the server fails. This is hard to test reliably against real services, so stubbing is ideal.

Step 1: Stub a failing response (500) for a specific scenario

cy.intercept('GET', '/api/search*', (req) => {
  if (req.query.q === 'trigger-error') {
    req.reply({ statusCode: 500, body: { message: 'Internal error' } })
  } else {
    req.reply({ statusCode: 200, body: { results: [] } })
  }
}).as('search')

Step 2: Trigger the error and assert the UI response

cy.visit('/search')
cy.get('[data-testid="search-input"]').type('trigger-error')
cy.get('[data-testid="search-submit"]').click()

cy.wait('@search').its('response.statusCode').should('eq', 500)
cy.get('[data-testid="error-banner"]').should('be.visible')
cy.get('[data-testid="error-banner"]').should('contain', 'Something went wrong')

Step 3: Simulate a successful retry

You can change behavior on subsequent calls by using a counter in the intercept handler.

let attempt = 0
cy.intercept('GET', '/api/search*', (req) => {
  attempt += 1
  if (attempt === 1) {
    req.reply({ statusCode: 500, body: { message: 'Internal error' } })
  } else {
    req.reply({ statusCode: 200, body: { results: [{ id: 'r1', title: 'Recovered' }] } })
  }
}).as('search')

cy.visit('/search')
cy.get('[data-testid="search-input"]').type('anything')
cy.get('[data-testid="search-submit"]').click()
cy.wait('@search')
cy.get('[data-testid="retry"]').click()
cy.wait('@search')
cy.get('[data-testid="search-result"]').should('contain', 'Recovered')

4) Asserting on request/response (status code, payload shape)

Stubbing is not only about returning data; it also lets you verify that your app sends correct requests and handles responses correctly. Use alias waits to access the full request/response object.

Assert on request method, URL, query, and body

cy.intercept('POST', '/api/login').as('login')

// ...perform UI actions that trigger login...

cy.wait('@login').then((interception) => {
  expect(interception.request.method).to.eq('POST')
  expect(interception.request.url).to.include('/api/login')
  expect(interception.request.body).to.have.property('email')
  expect(interception.request.body).to.have.property('password')
})

Assert on response status code and payload shape

Even when you stub, asserting on the response your app received helps ensure the UI is wired to the right endpoint and that the test is exercising the intended path.

cy.wait('@search').then((interception) => {
  expect(interception.response.statusCode).to.eq(200)
  expect(interception.response.body).to.have.property('results')
  expect(interception.response.body.results).to.be.an('array')
})

Assert that a request did (or did not) happen

For example, you might want to ensure search does not fire for short queries.

cy.intercept('GET', '/api/search*').as('search')
cy.visit('/search')
cy.get('[data-testid="search-input"]').type('a')

// Instead of waiting, assert the UI state that implies no request-driven results
cy.get('[data-testid="hint"]').should('contain', 'Type at least 2 characters')

If you truly need to assert “no request happened,” you can track calls in the intercept handler and assert the counter remains zero after the UI action.

5) Balancing realism: when to stub and when to hit real services

Network stubbing is powerful, but overusing it can reduce confidence that the full system works. A practical strategy is to stub most tests for determinism, and keep a smaller set of “contract” or “smoke” flows that hit real services.

Good candidates for stubbing

  • Flaky or slow endpoints that cause timeouts and inconsistent UI timing.
  • Third-party services (payments, analytics, feature flags) where you only need to validate UI behavior.
  • Edge cases that are hard to reproduce (500 errors, timeouts, empty states, permission errors).
  • Data-dependent features like search and recommendations where real data changes frequently.

Good candidates for real network calls

  • One or two critical end-to-end smoke tests per environment to catch integration issues (routing, auth wiring, deployments).
  • API contract confidence when you want to detect breaking changes between frontend and backend.
  • Backend-specific logic that the UI depends on and you explicitly want to validate (e.g., server-side validation rules), ideally in a controlled test environment.

Practical balancing approach

  • Default to stubbing for UI behavior tests: rendering, loading states, empty states, error banners, retry flows.
  • Keep stubs realistic: mirror real payload shapes (keys, nesting, types) so you don’t accidentally test an impossible response.
  • Use a small real-network suite that runs less frequently (nightly or pre-release) to validate integration without making every PR run flaky.
  • Assert on requests even when stubbing, so you still validate that the UI calls the correct endpoints with correct payloads.

Now answer the exercise about the content:

In Cypress UI tests, what is the main benefit of aliasing an intercepted request and waiting on it (for example, using cy.intercept(...).as('search') and cy.wait('@search'))?

You are right! Congratulations, now go to the next page

You missed! Try again.

Aliasing and waiting on an intercept synchronizes the test with a known request/response, removing guesswork from UI timing and making runs more deterministic than arbitrary delays.

Next chapter

Handling Waits Correctly in Cypress: Auto-Retry, Timeouts, and Avoiding Arbitrary Delays

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.