I spend most of my working hours writing Cypress tests. UI flows, login forms, dashboards, the usual. But the tests that have saved me the most time and headaches over the past few years are the ones that never open a browser at all.
They hit the API directly with cy.request().
And almost nobody writes them.
The bug that only the API knew about
A few months ago I was testing a project management app for a client. The UI looked perfect. You could create a task, assign it, mark it done. All green in Cypress. Ship it.
Except the API was returning a 500 on every third POST request when the task description contained special characters. The frontend was silently swallowing the error and showing a success toast anyway because the developer had wrapped everything in a try-catch that defaulted to "ok."
The user would create a task, see a success message, and the task would simply not exist. No error. No feedback. Just gone.
I caught it by accident when I added a cy.request() test for the create endpoint. The UI tests had been green for weeks.
That's the problem. If you only test through the UI, you're testing the frontend's ability to hide failures. You're not testing whether the backend actually works.
Why cy.request() and not Postman?
Fair question. At BetterQA we use both. Postman is great for exploratory API testing and for sharing collections with developers. But when I need API tests running in the same pipeline as my UI tests, using the same config, the same env variables, the same reporting, cy.request() wins.
No extra tooling. No separate runner. No "well the Postman tests passed in Newman but the Cypress tests failed" confusion.
If your team already has Cypress installed, you have an API testing framework. You're just not using it yet.
The basics: hitting an endpoint and checking what comes back
Here's what a real API test looks like. Nothing fancy.
describe('Users API', () => {
it('returns a list of users with the expected shape', () => {
cy.request('GET', '/api/users').then((response) => {
expect(response.status).to.eq(200);
expect(response.body).to.be.an('array');
expect(response.body.length).to.be.greaterThan(0);
const user = response.body[0];
expect(user).to.have.property('id');
expect(user).to.have.property('email');
});
});
it('creates a user and gets back a real ID', () => {
cy.request({
method: 'POST',
url: '/api/users',
body: {
name: 'Test User',
email: `test-${Date.now()}@example.com`,
},
}).then((response) => {
expect(response.status).to.eq(201);
expect(response.body.name).to.eq('Test User');
expect(response.body.id).to.be.a('number');
});
});
});
Notice the Date.now() in the email. I learned this the hard way: if your test creates data, make it unique every run. Otherwise your second pipeline run fails with a "duplicate email" error and you waste 20 minutes debugging a test problem that isn't a test problem.
Authentication: the part people get stuck on
Most real APIs need auth. Here are the two patterns I use constantly.
Bearer tokens (JWT, OAuth, etc.):
describe('Protected endpoints', () => {
let authToken;
before(() => {
cy.request({
method: 'POST',
url: '/api/auth/login',
body: {
email: Cypress.env('TEST_USER_EMAIL'),
password: Cypress.env('TEST_USER_PASSWORD'),
},
}).then((response) => {
authToken = response.body.token;
});
});
it('returns profile data with valid token', () => {
cy.request({
method: 'GET',
url: '/api/profile',
headers: {
Authorization: `Bearer ${authToken}`,
},
}).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.email).to.eq(Cypress.env('TEST_USER_EMAIL'));
});
});
it('rejects requests with no token', () => {
cy.request({
method: 'GET',
url: '/api/profile',
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(401);
});
});
});
API key in a header:
cy.request({
method: 'GET',
url: '/api/data',
headers: {
'X-API-Key': Cypress.env('API_KEY'),
},
});
Keep your credentials in cypress.env.json (and add that file to .gitignore right now if you haven't). In CI, pass them as environment variables prefixed with CYPRESS_.
One thing that bites people: failOnStatusCode: false. Without it, Cypress treats any non-2xx status as a test failure and throws. When you're intentionally testing a 401 or 404, you need this flag. I forget it about once a month.
Schema validation: the test that catches breaking changes
This is where API tests really earn their keep. Backend developers change response structures. They rename a field from created_at to createdAt. They drop a property. They add a nested object where there used to be a string.
Your UI might still work because JavaScript is forgiving. But your mobile client breaks. Or your integration partner's webhook stops parsing. Or the data is silently wrong.
it('user response has the required fields and types', () => {
cy.request('GET', '/api/users/1').then((response) => {
expect(response.body).to.have.all.keys(
'id', 'name', 'email', 'created_at', 'role'
);
expect(response.body.id).to.be.a('number');
expect(response.body.name).to.be.a('string');
expect(response.body.email).to.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
expect(response.body.role).to.be.oneOf(['admin', 'user', 'editor']);
});
});
This test takes about 50 milliseconds to run. It will catch a breaking API change before your users do. That's a trade-off I will take every single time.
For bigger projects, look into chai-json-schema for full JSON Schema validation. But honestly, the simple assertions above cover 80% of what I need.
Testing the unhappy paths
Every junior tester writes tests for when things go right. The tests that matter are the ones for when things go wrong.
describe('Error handling', () => {
it('returns 404 for a user that does not exist', () => {
cy.request({
method: 'GET',
url: '/api/users/999999',
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(404);
expect(response.body).to.have.property('error');
});
});
it('returns 400 when required fields are missing', () => {
cy.request({
method: 'POST',
url: '/api/users',
body: { name: '' },
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(400);
expect(response.body.errors).to.be.an('array');
});
});
});
I check three things on every error response: correct status code, an error message that makes sense, and that the body does not leak internal details (stack traces, database errors, file paths). You'd be surprised how many APIs return a full Node.js stack trace on a 500.
Combining API and UI tests
This is where Cypress really shines compared to standalone API tools. You can set up data through the API and then verify it in the UI:
it('shows a newly created task on the dashboard', () => {
// Create data via API (fast, reliable)
cy.request({
method: 'POST',
url: '/api/tasks',
headers: { Authorization: `Bearer ${authToken}` },
body: { title: 'Fix login bug', priority: 'high' },
}).then((response) => {
const taskId = response.body.id;
// Verify it shows up in the UI
cy.visit('/dashboard');
cy.contains('Fix login bug').should('be.visible');
cy.get(`[data-task-id="${taskId}"]`).should('exist');
});
});
This pattern is faster and more reliable than creating data through the UI. Click-based setup is fragile. API-based setup gives you a known state in milliseconds.
Mocking APIs with cy.intercept()
Sometimes you need to test how the frontend handles a broken backend. That's where cy.intercept() comes in:
it('shows an error message when the API is down', () => {
cy.intercept('GET', '/api/users', {
statusCode: 500,
body: { error: 'Internal Server Error' },
}).as('getUsers');
cy.visit('/users');
cy.wait('@getUsers');
cy.contains('Something went wrong').should('be.visible');
});
it('shows empty state when there is no data', () => {
cy.intercept('GET', '/api/users', {
statusCode: 200,
body: [],
}).as('getUsers');
cy.visit('/users');
cy.wait('@getUsers');
cy.contains('No users found').should('be.visible');
});
I use this to test every error state the designer put in the mockups. If there's an empty state in the Figma file, there should be a test that forces that state through cy.intercept().
Organizing your tests so they don't become a mess
Once you have more than five or six API test files, structure matters.
cypress/
e2e/
api/
users.cy.js
auth.cy.js
orders.cy.js
payments.cy.js
ui/
login.cy.js
dashboard.cy.js
support/
commands.js
And pull repeated API calls into custom commands:
// cypress/support/commands.js
Cypress.Commands.add('apiLogin', (email, password) => {
return cy.request({
method: 'POST',
url: '/api/auth/login',
body: { email, password },
}).then((response) => {
window.localStorage.setItem('token', response.body.token);
return response.body.token;
});
});
// In your tests:
beforeEach(() => {
cy.apiLogin(Cypress.env('TEST_USER_EMAIL'), Cypress.env('TEST_USER_PASSWORD'));
});
I create a custom command for every API operation I call more than twice. Login, create user, create resource, cleanup. This keeps test files short and readable.
Running API tests in CI
API tests are fast because they skip the browser rendering. A suite of 50 API tests finishes in under 10 seconds. Add them to your pipeline:
# GitHub Actions
api-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm install
- run: npx cypress run --spec "cypress/e2e/api/**"
env:
CYPRESS_BASE_URL: ${{ secrets.API_BASE_URL }}
CYPRESS_TEST_USER_EMAIL: ${{ secrets.TEST_EMAIL }}
CYPRESS_TEST_USER_PASSWORD: ${{ secrets.TEST_PASSWORD }}
Run them on every PR. They're cheap and they catch real bugs.
When Cypress is not the right API testing tool
I'm not going to pretend Cypress is always the answer. Use Postman or a dedicated API framework when:
- You're testing APIs that have no frontend at all
- You need to generate load or stress test endpoints
- You want API documentation generated from your test definitions
- Your API tests need to run outside a Node.js environment
For everything else, especially when your team already has Cypress in the repo, just write the cy.request() tests. You already have the tool. Use it.
What I'd add to any test suite tomorrow
If I had to pick three API tests to add to a project that has zero, these are the ones:
-
Health check test - Hit
/api/healthor your main endpoint. Confirm it returns 200. This is your canary. If this fails, something is very wrong. - Auth rejection test - Hit a protected endpoint with no token. Confirm you get 401, not 200. You would not believe how many APIs return data to unauthenticated requests.
- Schema test on your most-used endpoint - Pick the endpoint the frontend calls most. Assert every field name and type. This catches breaking changes before they reach production.
Three tests. Maybe 15 minutes to write. They'll save you hours.
We write tests like these on client projects every week at BetterQA, usually alongside Postman collections and full E2E suites. If you want to read more about how we approach testing, check out betterqa.co/blog.
Top comments (0)