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.interceptto make API calls.cy.interceptonly watches what the app fires. Doingcy.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, usecy.request. - Forgetting
failOnStatusCode: falsewhen testing error responses. A test that expects a 404 fails immediately because the defaultcy.requesttreats 4xx/5xx as a hard failure. PassfailOnStatusCode: falsewhenever the assertion is on an intentional error. - Replacing every UI login with
cy.requestand 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.
- Set
baseUrl: "https://jsonplaceholder.typicode.com"(a free public CRUD API). Createcypress/e2e/api-request.cy.ts. - Write a
describe("Posts API")with these tests, each usingcy.requestonly:- 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/99999withfailOnStatusCode: false, assert status 404.
- GET — fetch
- Chained request — fetch
/users/1to get auserId, then fetch/users/1/postsusing that user's id. Confirm at least one post is returned and that every post'suserIdmatches. - Type the responses — define
interface Post { id: number; userId: number; title: string; body: string }and usecy.request<Post[]>("GET", "/posts"). Confirm autocomplete works onresponse.body[0].titlein.then. - API-login custom command — write a typed
cy.apiLogin(email, password)against any login endpoint you have (or stub one withcy.interceptfor practice — yes, both can work together). Use it in a separate test that lands the user on a protected page after the API call. - Stretch: in
cypress/support/commands.ts, add a typedcy.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.