Testing API Endpoints with cy.request

8 min read

cy.intercept watches what the browser sends. cy.request is how the test itself sends. It opens an HTTP connection from Cypress's Node side directly to your API, bypassing the browser entirely. This is the command you reach for when you want to seed test data via the backend, log in via API for speed, or assert on an endpoint's response without rendering any UI. Mastering it cuts ten seconds off every test that doesn't actually need to log in through a form.

What cy.request is — and isn't

A cy.request call sends an HTTP request and yields the response. There is no DOM, no browser, no JavaScript runtime in the middle:

cy.request("GET", "/api/products").then((response) => {
  expect(response.status).to.equal(200);
  expect(response.body).to.have.length.greaterThan(0);
});

That's a pure API test. No cy.visit, no cy.get, no UI. The test has just exercised the products endpoint and asserted on the JSON body — exactly what you'd write in Postman or with curl, but inside your spec file alongside your UI tests.

The crucial mental distinction:

  • cy.intercept(...) registers a listener for requests the app makes.
  • cy.request(...) sends a new request from the test runner.

If you're trying to log in by calling the login API directly, you want cy.request. If you're trying to wait on the login API the user just triggered, you want cy.intercept + cy.wait("@alias").

POST with a body

cy.request({
  method: "POST",
  url: "/api/users",
  body: {
    name: "Test User",
    email: "testuser@test.com",
    role: "tester",
  },
}).then((response) => {
  expect(response.status).to.equal(201);
  expect(response.body).to.have.property("id");
});

The single-argument options form takes method, url, body, headers, qs (query string), auth, failOnStatusCode, and a few others. The two-argument shorthand is cy.request(method, url) or cy.request(method, url, body) — useful when there are no headers to set.

body is serialised to JSON automatically when Content-Type: application/json is the default. Override headers if your endpoint expects something else (form-encoded, multipart, custom).

Auth and headers

Every real API needs a token or cookie. Pass them with headers:

cy.request({
  method: "GET",
  url: "/api/admin/users",
  headers: {
    Authorization: `Bearer ${token}`,
  },
});

cy.request also automatically includes any cookies the browser already has. If the user logged in earlier in the spec — even via cy.visit("/login") and a UI submit — the session cookie is on the request. This is what makes cy.request for test setup so powerful: log in once via the UI in before, and every subsequent cy.request you fire inherits the session.

For basic auth, the auth: { username, password } option packages the header for you:

cy.request({
  url: "/api/admin",
  auth: { username: "admin", password: "admin" },
});

The four canonical use cases

Most teams use cy.request for one (or all) of these:

1. Test setup — create data via API instead of UI

UI signup is slow. UI signup is also tested by exactly one of your specs — the one that's about signup. Every other test that needs a user can create one via API in a tenth of the time:

beforeEach(() => {
  cy.request("POST", "/api/test/users", {
    email: `alice-${Date.now()}@test.com`,
    password: "Sup3rS3cret!",
    role: "tester",
  }).its("body.id").as("userId");
});

The user is now in the database; subsequent cy.visit calls operate against real data without going through any forms.

2. Test cleanup — delete data after

afterEach(function () {
  if (this.userId) {
    cy.request("DELETE", `/api/test/users/${this.userId}`);
  }
});

Combined with the alias from setup, this leaves the database in the same state it was at the start of the run — important on shared environments.

3. Login via API for speed

A typed custom command that gets every test logged in without ever rendering a login form:

declare global {
  namespace Cypress {
    interface Chainable {
      apiLogin(email: string, password: string): Chainable<void>;
    }
  }
}
 
Cypress.Commands.add("apiLogin", (email, password) => {
  cy.request("POST", "/api/login", { email, password })
    .its("body.token")
    .then((token) => {
      window.localStorage.setItem("auth-token", token);
    });
});
 
export {};
beforeEach(() => {
  cy.apiLogin("alice@test.com", "Sup3rS3cret!");
  cy.visit("/dashboard");
});

The user lands on the dashboard logged in, with no UI form interaction. A 200-spec suite shaves 5+ minutes off its run with this single change. Chapter 6 covers the full cy.session / API-login pattern in depth.

4. Direct API testing alongside UI tests

Same spec file, two layers:

describe("Orders endpoint", () => {
  it("returns the user's orders", () => {
    cy.request("GET", "/api/orders").then((response) => {
      expect(response.status).to.equal(200);
      expect(response.body.orders).to.be.an("array");
    });
  });
});

This is plain API testing — Postman in your codebase. Pair it with UI tests for the same feature and you have full coverage from one runner.

Chaining requests for setup

Setup flows often need a sequence — log in, get a token, use the token for a second call:

cy.request("POST", "/api/login", {
  email: "admin@test.com",
  password: "AdminPass1!",
})
  .its("body.token")
  .then((token) => {
    cy.request({
      method: "GET",
      url: "/api/admin/dashboard",
      headers: { Authorization: `Bearer ${token}` },
    }).then((response) => {
      expect(response.status).to.equal(200);
      expect(response.body.users).to.have.length.greaterThan(0);
    });
  });

.its("body.token") is the typed accessor for "give me response.body.token as the next chain value." From there, .then drops you into a callback where you build the second request.

Testing error responses with failOnStatusCode

By default, cy.request fails the test when the response status is 4xx or 5xx. To assert on an error response without failing the test, set failOnStatusCode: false:

cy.request({
  url: "/api/users/9999999",
  failOnStatusCode: false,
}).then((response) => {
  expect(response.status).to.equal(404);
  expect(response.body.error).to.equal("User not found");
});

Use this whenever you're expecting a non-2xx response — auth checks, validation errors, missing-resource handling. Skipping failOnStatusCode means an actual 500 would fail the test silently for the wrong reason.

A typed helper for common verbs

Wrap the boilerplate in a typed custom command so the rest of the suite reads cleanly:

interface ApiUser { id: number; email: string; role: "admin" | "tester" }
 
declare global {
  namespace Cypress {
    interface Chainable {
      createUser(data: Partial<ApiUser>): Chainable<ApiUser>;
    }
  }
}
 
Cypress.Commands.add("createUser", (data) => {
  return cy
    .request<ApiUser>("POST", "/api/test/users", {
      email: `t-${Date.now()}@test.com`,
      role: "tester",
      ...data,
    })
    .its("body");
});
 
export {};

Now any test can do:

cy.createUser({ role: "admin" }).then((user) => {
  cy.visit(`/admin/users/${user.id}`);
  cy.get("[data-testid='user-email']").should("contain", user.email);
});

Typed end-to-end. The compiler enforces that data matches Partial<ApiUser> and the callback's user is fully typed.

cy.intercept vs cy.request — when to use which

Two complementary network commands

cy.intercept

  • Watches requests the app makes from the browser

  • Spies on, stubs, or modifies real responses

  • Use cy.wait('@alias') to pause until the request resolves

  • Use it for: synchronisation, stubbing, contract checks against UI flows

cy.request

  • Sends a new HTTP request from the test runner itself

  • No browser, no DOM — pure API call

  • Yields the response synchronously through .then

  • Use it for: test setup, cleanup, API-only tests, fast login

The two commands compose. A typical pre-flight: cy.request to seed data, cy.visit to load the page, cy.intercept to wait on the page's own fetch. Don't pick one over the other; they have different jobs.

⚠️ Common mistakes

  • Using cy.intercept to make API calls. cy.intercept only watches what the app fires. Doing cy.intercept("GET", "/api/users").as("get").wait("@get") without ever triggering the request via the UI hangs forever. If your test wants to call an API directly, use cy.request.
  • Forgetting failOnStatusCode: false when testing error responses. A test that expects a 404 fails immediately because the default cy.request treats 4xx/5xx as a hard failure. Pass failOnStatusCode: false whenever the assertion is on an intentional error.
  • Replacing every UI login with cy.request and never testing the login form again. Speed wins are real, but the UI login flow still needs coverage. Keep one dedicated UI-login spec; everything else uses the API path. This is the same balance the chapter-6 lesson on login strategies covers in depth.

🎯 Practice task

Wire up real cy.request-driven setup. 25-30 minutes.

  1. Set baseUrl: "https://jsonplaceholder.typicode.com" (a free public CRUD API). Create cypress/e2e/api-request.cy.ts.
  2. Write a describe("Posts API") with these tests, each using cy.request only:
    • GET — fetch /posts, assert status 200 and body length is 100.
    • POST — create a post with { title, body, userId }. Assert status 201 and that the response includes the same title.
    • PUT — update post 1 with a new title, assert status 200, assert body matches the update.
    • DELETE — delete post 1, assert status 200.
    • 404 — request /posts/99999 with failOnStatusCode: false, assert status 404.
  3. Chained request — fetch /users/1 to get a userId, then fetch /users/1/posts using that user's id. Confirm at least one post is returned and that every post's userId matches.
  4. Type the responses — define interface Post { id: number; userId: number; title: string; body: string } and use cy.request<Post[]>("GET", "/posts"). Confirm autocomplete works on response.body[0].title in .then.
  5. API-login custom command — write a typed cy.apiLogin(email, password) against any login endpoint you have (or stub one with cy.intercept for practice — yes, both can work together). Use it in a separate test that lands the user on a protected page after the API call.
  6. Stretch: in cypress/support/commands.ts, add a typed cy.createPost(data: Partial<Post>): Chainable<Post> command following the pattern in the lesson. Use it in two tests that each need a fresh post — note how the spec file becomes shorter and clearer.

The next lesson — fixtures — closes chapter 4 by tying together the data half of all this networking. Stubs, requests, intercepts, and seeded data all benefit from clean, typed fixture files.

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