Intercept Patterns and Best Practices — Building a Robust Network Control Layer

Individual intercepts are useful, but production Cypress suites need an intercept strategy — a set of patterns and conventions that keep network control consistent, maintainable, and balanced between isolation (stubs) and integration (spies). This lesson distils the patterns used by teams running 500+ Cypress tests in CI, covering authentication intercepts, pagination, error recovery, and the critical balance between stubbed and live tests.

Production Intercept Patterns and Strategy

These patterns address recurring scenarios that every Cypress suite encounters at scale.

// ── PATTERN 1: Auth intercept in beforeEach ──

// Set up auth intercept once — all tests inherit it
beforeEach(() => {
  // Spy on login to verify auth is working
  cy.intercept('POST', '/api/auth/**').as('auth');

  // Stub the user profile to avoid depending on user state
  cy.intercept('GET', '/api/user/profile', {
    fixture: 'auth/profile.json',
  }).as('profile');
});


// ── PATTERN 2: Pagination testing ──

it('should load more products on scroll', () => {
  // Page 1
  cy.intercept('GET', '/api/products?page=1*', {
    fixture: 'products/page1.json',
    headers: { 'X-Total-Pages': '3' },
  }).as('page1');

  // Page 2
  cy.intercept('GET', '/api/products?page=2*', {
    fixture: 'products/page2.json',
  }).as('page2');

  cy.visit('/shop');
  cy.wait('@page1');
  cy.get('.product-card').should('have.length', 20);

  // Scroll to trigger infinite scroll
  cy.scrollTo('bottom');
  cy.wait('@page2');
  cy.get('.product-card').should('have.length', 40);
});


// ── PATTERN 3: Error recovery flow ──

it('should show retry button on error and recover', () => {
  let attempt = 0;
  cy.intercept('GET', '/api/products', (req) => {
    attempt++;
    if (attempt === 1) {
      req.reply({ statusCode: 500, body: { error: 'Server error' } });
    } else {
      req.reply({ fixture: 'products/catalogue.json' });
    }
  }).as('products');

  cy.visit('/shop');
  cy.wait('@products');

  // Verify error state
  cy.get('[data-cy="error-message"]').should('contain.text', 'Something went wrong');
  cy.get('[data-cy="retry-button"]').should('be.visible');

  // Click retry — second attempt succeeds
  cy.get('[data-cy="retry-button"]').click();
  cy.wait('@products');
  cy.get('.product-card').should('have.length.greaterThan', 0);
  cy.get('[data-cy="error-message"]').should('not.exist');
});


// ── PATTERN 4: Form submission validation ──

it('should send correct order payload', () => {
  cy.intercept('POST', '/api/orders', (req) => {
    // Validate the payload structure
    expect(req.body).to.have.property('items').that.is.an('array');
    expect(req.body).to.have.property('shippingAddress');
    expect(req.body.shippingAddress).to.have.all.keys(
      'line1', 'city', 'postcode', 'country'
    );

    // Return a success response
    req.reply({
      statusCode: 201,
      body: { orderId: 'ORD-TEST-001', status: 'confirmed' },
    });
  }).as('submitOrder');

  // Fill and submit the order form
  cy.get('[data-cy="place-order"]').click();
  cy.wait('@submitOrder');
  cy.contains('ORD-TEST-001').should('be.visible');
});


// ── PATTERN 5: Intercept cleanup in support file ──

// cypress/support/e2e.ts — global intercept setup

/*
// Block analytics and tracking in all tests
beforeEach(() => {
  cy.intercept('POST', '**/analytics/**', { statusCode: 204 });
  cy.intercept('GET', '**/tracking/**', { statusCode: 204 });
  cy.intercept('POST', '**/sentry/**', { statusCode: 200 });
});
*/


// ── STRATEGY: Balancing stubs vs spies ──

const INTERCEPT_STRATEGY = {
  'Always stub': [
    'Analytics / tracking calls (block to speed up tests)',
    'Third-party services (Stripe, Twilio, SendGrid)',
    'Error scenarios (500, 503, 429, network failure)',
    'Edge cases (empty data, null fields, huge datasets)',
    'Loading state tests (need delay control)',
  ],
  'Always spy (real API)': [
    'Happy-path smoke tests (verify real integration works)',
    'Contract verification (response structure matches frontend expectations)',
    'Auth flow tests (verify real token exchange)',
    'At least 1 end-to-end test per feature (full integration confidence)',
  ],
  'Modify when needed': [
    'Injecting specific field values into real responses',
    'Adding delays to test timeout handling',
    'Simulating intermittent failures for retry testing',
    'Stripping auth headers for permission testing',
  ],
};

console.log('Intercept Strategy:');
Object.entries(INTERCEPT_STRATEGY).forEach(([mode, scenarios]) => {
  console.log(`\n  ${mode}:`);
  scenarios.forEach(s => console.log(`    - ${s}`));
});
Note: Blocking analytics and tracking requests in the global beforeEach is a widely adopted best practice. These requests add network traffic, slow down tests, and can cause flakiness (if the analytics server is slow or down). By stubbing them with empty 204 responses globally, you eliminate a source of noise without affecting any test that cares about application functionality. This typically reduces total suite execution time by 10-20% because the browser is not waiting for tracking pixels and analytics payloads to complete.
Tip: Create a cypress/support/intercepts.ts file with helper functions for common intercept setups: stubProducts(), stubAuth(), stubEmptyCart(). Import and call these in individual tests: stubProducts(); cy.visit('/shop');. This keeps intercept definitions DRY and makes tests readable — you see stubProducts() and immediately know the product API is returning fake data without reading the intercept configuration.
Warning: When multiple cy.intercept() calls match the same request, the last matching intercept wins. If beforeEach stubs /api/products with fixture data, and a specific test also stubs /api/products with an empty array, the test’s intercept takes precedence — which is usually what you want. But if the order is reversed (test first, then beforeEach), the beforeEach overrides the test’s intercept. Be mindful of intercept ordering, especially when combining global and per-test intercepts.

Common Mistakes

Mistake 1 — Not blocking third-party requests in tests

❌ Wrong: Tests hit real Google Analytics, Sentry, Intercom, and Hotjar endpoints — adding 2-5 seconds of latency and causing flakiness when those services are slow.

✅ Correct: Global beforeEach stubs all analytics, error tracking, and third-party chat widget requests with empty responses. Tests run faster and are not affected by external service availability.

Mistake 2 — Using only stubbed tests without any real API integration tests

❌ Wrong: 100% stubbed suite — every test uses fixture responses. The backend renames a field and all tests pass against stale data.

✅ Correct: 70% stubbed (fast, edge cases, error scenarios) + 30% spy (real API, happy path, contract validation). At least one spy-based smoke test per feature confirms the real integration works.

🧠 Test Yourself

Your Cypress suite has 200 tests, all using stubbed API responses. A backend developer renames the productName field to name in the API. What happens?