Intercepting Network Requests with cy.intercept

9 min read

cy.intercept is the most powerful single command in Cypress. It lets your test sit between the browser and the network, observing every request, asserting on the payload, and — when you want — replacing the response entirely. Once you understand it, you stop writing tests that depend on a particular database state, stop writing flaky waits, and start covering the error states the backend rarely emits on demand. The next four lessons build on this one; spend the time to internalise it.

What cy.intercept actually does

A modern web app is dozens of small HTTP requests glued together: a GET /api/products to load a page, a POST /api/cart when the user clicks Add to cart, a GET /api/users/me for the avatar, and so on. cy.intercept registers a listener that fires whenever the browser makes a matching request. You decide what to do with it:

  • Spy — let the request go through to the real server, but record it for inspection.
  • Stub — block the request from reaching the server and return a fake response you control.
  • Modify — let the request go through, then alter the response before the app sees it.

The same command does all three. The shape of the second argument decides which mode you're in. This lesson covers spying and inspection; the next two cover stubbing and waiting.

Spying on a request

The simplest form: register an intercept, alias it, and wait on the alias:

cy.intercept("GET", "/api/products").as("getProducts");
cy.visit("/products");
cy.wait("@getProducts").its("response.statusCode").should("eq", 200);

Three lines, three things happen:

  1. cy.intercept("GET", "/api/products") tells Cypress to watch every GET to /api/products. No second-argument body or stub object means don't change anything — pass the request through and record it.
  2. .as("getProducts") gives the intercept a name, alias-style, that the rest of the test can refer to.
  3. cy.wait("@getProducts") pauses the test until the request fires and resolves, then yields the captured interception object ({ request, response }). Chaining .its("response.statusCode").should("eq", 200) asserts the server responded with a 200.

This is condition-based waiting — the test pauses until a real network event happens, not until a fixed timer runs out. It's the antidote to cy.wait(2000).

Matching by URL pattern

Real apps rarely hit a single static URL. The matcher accepts wildcards, regex, and full options objects:

// Wildcard — any product ID
cy.intercept("GET", "/api/products/*");
 
// Multiple wildcards
cy.intercept("GET", "/api/users/*/orders");
 
// Glob-style — any path under /api/
cy.intercept("GET", "/api/**");
 
// Regex for tighter control
cy.intercept("GET", /\/api\/products\?page=\d+/);
 
// Full route matcher object — match by query params, headers, etc.
cy.intercept({
  method: "GET",
  url: "/api/products",
  query: { category: "electronics" },
});

The matcher object is the most flexible. Every field is optional and they AND together. query: { category: "electronics" } only matches requests whose query string contains category=electronics — useful when the same path serves several distinct flows.

Inspecting requests and responses

The interception object captured by cy.wait is your full record of what happened:

cy.intercept("POST", "/api/login").as("login");
 
cy.get("[data-testid='email']").type("alice@test.com");
cy.get("[data-testid='password']").type("Sup3rS3cret!");
cy.get("[data-testid='submit']").click();
 
cy.wait("@login").then((interception) => {
  // request side
  expect(interception.request.method).to.equal("POST");
  expect(interception.request.body).to.deep.include({
    email: "alice@test.com",
  });
  expect(interception.request.headers).to.have.property("content-type");
 
  // response side
  expect(interception.response?.statusCode).to.equal(200);
  expect(interception.response?.body).to.have.property("token");
});

You can assert on anything the network actually carried — request method, request URL, request body, request headers, response status, response body, response headers. This is what makes cy.intercept a contract test as much as a synchronisation tool. The test fails if the frontend stops sending email correctly, even if the UI still looks fine.

interception.response is typed as optional because some intercepts (e.g. failed/aborted requests) won't have one. The ?. chain handles the rare case safely.

Typing intercepts in TypeScript

cy.intercept accepts generics for the request and response shapes — fully compatible with the kind of typed contract you'd already have in your app:

interface LoginRequest {
  email: string;
  password: string;
}
 
interface LoginResponse {
  token: string;
  user: { id: number; name: string };
}
 
cy.intercept<LoginRequest, LoginResponse>("POST", "/api/login").as("login");
 
cy.wait("@login").then((interception) => {
  // interception.request.body is typed as LoginRequest
  expect(interception.request.body.email).to.equal("alice@test.com");
  // interception.response?.body is typed as LoginResponse
  expect(interception.response?.body.token).to.be.a("string");
});

If the frontend ever drifts from the declared contract — accidentally sending userEmail instead of email, for instance — the compiler flags it before the test runs. The cost is one interface definition; the benefit is permanent.

For projects that already have typed API clients (Zod schemas, OpenAPI-generated types, GraphQL codegen), reuse those types in the test layer rather than redeclaring. The contract has one source of truth.

Order matters — register before the action

cy.intercept is a listener, not a retroactive capture. It must be registered before the action that triggers the request:

// ✅ Correct
cy.intercept("POST", "/api/login").as("login");
cy.get("[data-testid='submit']").click();
cy.wait("@login");
 
// ❌ Wrong — the request fires first, the intercept attaches second
cy.get("[data-testid='submit']").click();
cy.intercept("POST", "/api/login").as("login");
cy.wait("@login");   // hangs forever, no request to wait for

The shape of every intercept-driven test is the same: register, act, wait. Get into the habit early.

For requests that fire on page load (GET /api/products on /products), register the intercept before cy.visit:

cy.intercept("GET", "/api/products").as("getProducts");
cy.visit("/products");
cy.wait("@getProducts");

Multiple intercepts coexist

You can register as many intercepts as the page has endpoints. Each one captures matching requests independently:

cy.intercept("GET", "/api/users/me").as("currentUser");
cy.intercept("GET", "/api/notifications").as("notifications");
cy.intercept("GET", "/api/products").as("products");
 
cy.visit("/dashboard");
 
cy.wait(["@currentUser", "@notifications", "@products"]);
cy.get("[data-testid='dashboard-loaded']").should("be.visible");

cy.wait(["@a", "@b", "@c"]) pauses until all three resolve. The order doesn't matter — Cypress waits for each independently.

A complete spying test

Tying the whole pattern into one realistic spec:

describe("Login flow — network spy", () => {
  beforeEach(() => {
    cy.intercept("POST", "/api/login").as("login");
    cy.intercept("GET", "/api/users/me").as("getMe");
    cy.visit("/login");
  });
 
  it("sends correct credentials and receives a token", () => {
    cy.get("[data-testid='email']").type("alice@test.com");
    cy.get("[data-testid='password']").type("Sup3rS3cret!");
    cy.get("[data-testid='submit']").click();
 
    cy.wait("@login").then((i) => {
      expect(i.request.body).to.deep.equal({
        email: "alice@test.com",
        password: "Sup3rS3cret!",
      });
      expect(i.response?.statusCode).to.equal(200);
      expect(i.response?.body).to.have.property("token");
    });
 
    cy.wait("@getMe").its("response.statusCode").should("eq", 200);
    cy.url().should("include", "/dashboard");
  });
});

Two intercepts, one user action, two waits. The test asserts both that the frontend sent the right credentials and that the server returned what the UI expected. A bug in either layer fails the test with a clear message — not the silent "the URL didn't change" failure you'd get without intercepts.

The intercept lifecycle

Step 1 of 5

Register

cy.intercept('METHOD', 'url').as('alias') — Cypress installs a listener for matching requests. Must come BEFORE the trigger.

⚠️ Common mistakes

  • Registering cy.intercept after the action that triggers the request. The intercept attaches after the request is already on the wire and never matches. Symptom: cy.wait("@alias") hangs and times out. Always register intercepts before the click, type, or visit that fires the request.
  • Asserting interception.response.body without the optional-chain. Aborted, redirected, or network-failed requests have no response. interception.response.statusCode throws "Cannot read property of undefined." Use interception.response?.body (TypeScript optional chaining) or assert .exist first.
  • Using cy.intercept to make API calls directly. cy.intercept only watches requests the app initiates. To make your own HTTP call from the test (for setup, login, or pure API testing), use cy.request — covered in lesson 4 of this chapter.

🎯 Practice task

Spy on every network call in a real app's login flow. 25-30 minutes.

  1. Set baseUrl: "https://www.saucedemo.com" and create cypress/e2e/intercept-spy.cy.ts. Sauce Demo is mostly client-side; we'll target a public app with real network traffic. Switch to https://reqres.in for the request side and use the form fields it documents.
  2. Open the Cypress runner and visit https://reqres.in/. Use the network tab in browser devtools to confirm at least one GET fires on page load. Note the URL.
  3. Write a test that:
    • Registers cy.intercept("GET", "/api/users**").as("getUsers") before cy.visit("https://reqres.in/").
    • Visits the page.
    • Calls cy.wait("@getUsers") and asserts the response status is 200 and the body has a data property that's an array.
  4. Switch to a public CRUD demo: https://jsonplaceholder.typicode.com/. Set baseUrl accordingly. Write a second spec that registers cy.intercept("GET", "/posts/1").as("getPost"), then triggers the request via cy.request is wrong here — instead, use a fetch from the page. (If your test app doesn't fire it on its own, this is a hint that intercept is for app-driven requests.)
  5. Type a real intercept — define interface User { id: number; email: string; first_name: string; last_name: string } and interface UsersResponse { data: User[]; total: number }. Use the generic form cy.intercept<unknown, UsersResponse>("GET", "/api/users**") and assert interception.response?.body.data[0].email is a string. Watch the autocomplete on interception.response?.body.* light up.
  6. Force a registration-order bug — move the cy.intercept line to after the cy.visit. Re-run. Note that cy.wait times out. This is the single most common mistake new Cypress users make; experiencing it once burns the rule in.
  7. Stretch: open the Cypress commands cheat sheet and find the full RouteMatcher options. Note times, query, headers, auth, and middleware — each one is a tool you'll reach for in chapter 4 lessons 2 and 3.

You've spied. The next lesson takes the same intercept and replaces the response — the start of stubbing, where Cypress goes from "test against a real backend" to "test the frontend in complete isolation."

// tip to track lessons you complete and pick up where you left off across devices.