Guides

Cypress API Testing Tutorial: cy.request() Guide (2026)

Rishi GauravUpdated Aug 19, 202611 min read

Quick answer

Cypress tests APIs with cy.request(), which sends an HTTP call without visiting a page, asserted on with Chai's expect() inside Mocha's describe/it structure. Unlike Playwright's request fixture, cy.request() still launches a browser context even for pure API tests, and it fails the test automatically on any non-2xx/3xx status unless you pass failOnStatusCode: false. It's the natural choice for teams already using Cypress for end-to-end tests who want API coverage in the same runner.

Reviewed by Sushant Joshi

Share:
Bar chart comparing test-run startup overhead between pytest, Playwright, and Cypress, which always launches a browser

Cypress API testing means using Cypress's cy.request() command to send an HTTP call directly and assert on the response with Chai, inside the same describe/it structure Cypress uses for end-to-end tests — without visiting a page or interacting with the DOM. Teams already running Cypress for UI automation get API coverage in the same framework and CI job, the same value proposition Playwright offers for its own ecosystem.

This guide covers cy.request() from scratch: basic requests, Cypress's default fail-on-error-status behavior (a common source of confusing beginner failures), chaining data between requests, custom commands for authentication, parametrized tests, and running the suite in CI/CD. Every example runs as written against JSONPlaceholder, a free public fake REST API.

Table of Contents

  1. Why Cypress for API Testing
  2. What You Need
  3. Your First cy.request() Test
  4. The failOnStatusCode Gotcha
  5. Testing POST, PUT, and DELETE
  6. Chaining Requests with Aliases
  7. Custom Commands for Authentication
  8. Parametrizing Tests with a Loop
  9. Running in CI/CD with GitHub Actions
  10. Common Pitfalls in Cypress API Testing
  11. Cypress vs Playwright vs pytest for API Testing
  12. When to Move Beyond Hand-Written Tests
  13. FAQ

Why Cypress for API Testing

cy.request() predates Cypress's newer component-testing features and has always served two purposes: setting up state for an E2E test without driving the UI (logging in via an API call instead of clicking through a login form), and standalone API assertions. For teams already invested in Cypress for end-to-end coverage, using it for API tests too means one test runner, one CI configuration, and one set of custom commands shared across both layers.

The tradeoff against a lighter tool: Cypress's architecture launches a browser context for every test run, even one that never calls cy.visit(). That is real overhead a pure API framework like pytest or Playwright's request fixture does not carry — the right call if you already run Cypress for E2E, a real cost if you are choosing a tool from scratch for API tests alone.

Cypress API testing stack showing cy.request, Mocha and Chai, custom commands, and CI/CD stages

What You Need

npm install -D cypress
  • Node.js 18+
  • cypress — bundles Mocha (test runner), Chai (assertions), and cy.request()

Your First cy.request() Test

// cypress/e2e/users.cy.js
describe('Users API', () => {
  it('gets a user and returns 200', () => {
    cy.request('GET', 'https://jsonplaceholder.typicode.com/users/1').then((response) => {
      expect(response.status).to.eq(200);
      expect(response.body).to.have.property('email');
      expect(response.body.email).to.include('@');
    });
  });
});

Run it:

npx cypress run --spec "cypress/e2e/users.cy.js"

Setting baseUrl in cypress.config.js lets every request use a relative path instead of the full URL — cy.request() prefixes relative URLs with baseUrl the same way cy.visit() does:

// cypress.config.js
const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    baseUrl: 'https://jsonplaceholder.typicode.com',
  },
});
cy.request('GET', '/users/1').then((response) => {
  expect(response.status).to.eq(200);
});

The failOnStatusCode Gotcha

This is the single most common source of confusion for Cypress API testing beginners: cy.request() automatically fails the test on any response outside the 2xx/3xx range — unlike fetch or axios, which just resolve with whatever status came back. Testing an expected error requires opting out explicitly:

it('returns 404 for a nonexistent user', () => {
  cy.request({
    url: '/users/999',
    failOnStatusCode: false,
  }).then((response) => {
    expect(response.status).to.eq(404);
  });
});

Without failOnStatusCode: false, this test would fail with a Cypress-generated error before your own expect() assertion ever runs — the test "fails," but not for the reason you'd assume from reading the assertion alone.

Testing POST, PUT, and DELETE

describe('Posts API', () => {
  it('creates a post', () => {
    cy.request('POST', '/posts', {
      title: 'foo',
      body: 'bar',
      userId: 1,
    }).then((response) => {
      expect(response.status).to.eq(201);
      expect(response.body.title).to.eq('foo');
    });
  });

  it('updates a post', () => {
    cy.request('PUT', '/posts/1', {
      id: 1,
      title: 'updated',
      body: 'bar',
      userId: 1,
    }).then((response) => {
      expect(response.status).to.eq(200);
      expect(response.body.title).to.eq('updated');
    });
  });

Ready to shift left with your API testing?

Try our no-code API test automation platform free. Generate tests from OpenAPI, run in CI/CD, and scale quality.

it('deletes a post', () => { cy.request('DELETE', '/posts/1').then((response) => { expect(response.status).to.eq(200); }); }); });


Passing a plain object as the third argument to `cy.request(method, url, body)` serializes it to JSON automatically, the same convenience `requests` and Playwright's `request` fixture give you.

## Chaining Requests with Aliases

Cypress commands are queued and asynchronous — you cannot assign a request's result to a plain JavaScript variable and use it later. Use `.then()` for a direct callback, or `.its()` and `.as()` to create a named alias:

```js
it('creates a post, then fetches it', () => {
  cy.request('POST', '/posts', { title: 'foo', body: 'bar', userId: 1 })
    .its('body.id')
    .as('newPostId');

  cy.get('@newPostId').then((newPostId) => {
    cy.request('GET', `/posts/${newPostId}`).then((response) => {
      expect(response.status).to.eq(200);
    });
  });
});

.its('body.id') extracts a single field from the response and yields it directly, so .as('newPostId') stores just the ID — not the whole response object — under that alias.

Custom Commands for Authentication

Cypress.Commands.add() centralizes a repeated action, like authenticating, into a single reusable command instead of repeating the login request in every test:

// cypress/support/commands.js
Cypress.Commands.add('apiLogin', () => {
  return cy
    .request('POST', '/auth/login', {
      username: Cypress.env('TEST_USERNAME'),
      password: Cypress.env('TEST_PASSWORD'),
    })
    .its('body.token');
});
it('gets a protected resource', () => {
  cy.apiLogin().then((token) => {
    cy.request({
      url: '/account',
      headers: { Authorization: `Bearer ${token}` },
    }).then((response) => {
      expect(response.status).to.eq(200);
    });
  });
});

Cypress.env() reads from cypress.env.json, the --env CLI flag, or environment variables prefixed with CYPRESS_ — set credentials as CI secrets exposed via CYPRESS_TEST_USERNAME/CYPRESS_TEST_PASSWORD, never as literal strings in a spec file.

Parametrizing Tests with a Loop

Like Playwright, Cypress has no built-in @parametrize decorator — loop over your cases and call it() inside the loop:

const cases = [
  { userId: 1, expectedStatus: 200 },
  { userId: 10, expectedStatus: 200 },
  { userId: 999, expectedStatus: 404 },
];

cases.forEach(({ userId, expectedStatus }) => {
  it(`GET /users/${userId} returns ${expectedStatus}`, () => {
    cy.request({
      url: `/users/${userId}`,
      failOnStatusCode: false,
    }).then((response) => {
      expect(response.status).to.eq(expectedStatus);
    });
  });
});

failOnStatusCode: false is required here for the same reason covered earlier — without it, the 999 case fails before the assertion runs.

Running in CI/CD with GitHub Actions

# .github/workflows/api-tests.yml
name: API Tests

on: [push, pull_request]

jobs:
  cypress-run:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: cypress-io/github-action@v6
        with:
          spec: cypress/e2e/**/*.cy.js
        env:
          CYPRESS_TEST_USERNAME: ${{ secrets.TEST_USERNAME }}
          CYPRESS_TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}

The official cypress-io/github-action handles dependency installation and caching automatically — check the action's Marketplace listing for the current version tag before pinning. See our step-by-step CI/CD guide for GitLab CI and Jenkins equivalents.

Common Pitfalls in Cypress API Testing

  • Forgetting failOnStatusCode: false when testing an expected error status. The test fails before your own assertion runs.
  • Assigning a cy.request() result to a plain variable. Cypress commands are queued, not synchronous — use .then() or an alias instead.
  • Using Cypress for a pure API suite with no E2E tests at all. It works, but carries browser-launch overhead a framework like pytest or Playwright's request fixture doesn't — a reasonable choice only if the team already knows Cypress.
  • Asserting only the status code. Cypress has no built-in JSON Schema validator; check specific fields with expect(response.body).to.have.property(...), or bring in ajv for full structural validation the same way a Playwright suite would.
  • Committing credentials into cypress.env.json. Gitignore that file and read secrets from CYPRESS_-prefixed CI environment variables instead.
  • No negative-path coverage. Loop invalid IDs and malformed payloads into the same parametrized array as valid cases, remembering failOnStatusCode: false for the ones expecting a non-2xx response.

Free Guided worksheet

Build Your Testing Strategy in 30 Minutes

A structured worksheet that walks you through defining your testing strategy in 30 minutes. Cover architecture, tools, layers, and team responsibilities.

Download Free

Cypress vs Playwright vs pytest for API Testing

ApproachLanguageNeeds a browser?CI/CD fitBest for
Cypress (cy.request)JavaScriptYes — always launches a browser contextNative — same runner as E2E testsTeams already using Cypress for E2E
Playwright (request fixture)TypeScript/JavaScriptNo — pure API tests skip the browserNative — same runner as E2E testsTeams already using Playwright for E2E, or wanting a lighter API-only tool
pytest + requestsPythonNoNative — same runner as unit testsPython teams
AI-generated (Total Shift Left)None requiredNoNative CI pluginsTeams testing many endpoints across services

The practical distinction from Playwright is architectural, not capability: Cypress's browser-context requirement is real overhead if API testing is all you need, and irrelevant if you already run Cypress for E2E and want to add API coverage to the same suite. See API testing vs UI testing for how the two layers complement each other either way.

When to Move Beyond Hand-Written Tests

The economics track every other framework in this series: twice the endpoints means roughly twice the it() blocks, custom commands, and fixtures to maintain. For a service with a dozen endpoints — especially one a Cypress-standardized team is already testing at the UI layer — hand-written cy.request() tests are a fast, reasonable choice. Somewhere past a few dozen endpoints across multiple services, the suite's maintenance cost starts competing with the API's own development for engineering time.

The alternative is generating the suite directly from the OpenAPI specification instead of hand-translating it into Cypress spec files. Total Shift Left imports your spec, generates positive, negative, and boundary test cases for every endpoint, and re-generates automatically when the spec changes — so schema drift updates the suite instead of silently breaking it.

Frequently Asked Questions

Can Cypress test APIs without opening a browser? Not entirely — cy.request() sends the call directly, but Cypress's architecture still launches a browser context for the test runner itself, unlike Playwright's request fixture which runs with no browser at all.

Why does my Cypress API test fail on a 404 I expected? cy.request() automatically fails on any non-2xx/3xx response by default. Pass { failOnStatusCode: false } to test an expected error status yourself.

How do I chain data between Cypress API requests? Use .then() for a callback, or .its() and .as() to create an alias retrieved later with cy.get('@alias') — Cypress commands are queued, not synchronous.

Cypress vs Playwright for API testing — which is better? Whichever framework your team already uses for E2E tests, to avoid a second tool. Playwright is lighter for a pure API suite since it needs no browser.

How do I run Cypress API tests in CI/CD? Run npx cypress run, which exits non-zero on any failure — the official cypress-io/github-action wraps this for GitHub Actions with caching and artifact upload built in.

Can a Cypress API suite fully replace an AI-generated API test suite? For a handful of endpoints, hand-written Cypress is often faster, especially if the team already knows the framework. Past a few dozen endpoints across services, generating the suite from an OpenAPI spec keeps coverage complete without the manual upkeep.

Key Takeaways

  • cy.request() still launches a browser context — real overhead if API testing is all you need, irrelevant if you already run Cypress for E2E.
  • failOnStatusCode: false is required to test an expected error status — this trips up nearly every Cypress API testing beginner at least once.
  • Cypress commands are queued, not synchronous. Use .then() or .its()/.as() aliases to chain data between requests.
  • Cypress.Commands.add() centralizes repeated setup like authentication into one reusable command.
  • No built-in JSON Schema validator — check specific fields manually or bring in ajv for full structural validation.
  • Hand-written suites scale linearly with endpoint count. Past a few dozen endpoints across services, generating tests from an OpenAPI spec becomes the more maintainable path.

Generate the Equivalent Suite from Your OpenAPI Spec

Every test in this guide can also be generated automatically. Import your OpenAPI specification into Total Shift Left and get positive, negative, and boundary test cases for every endpoint — regenerated the moment your spec changes, with no Cypress spec files to maintain by hand.

Start your free trial and compare the generated suite against your own, or see plans and pricing if you're already evaluating.

Ready to shift left with your API testing?

Try our no-code API test automation platform free.