API Testing with Cypress
cy.request() for sending HTTP requests directly from the Node.js server, and cy.intercept() for controlling network traffic during end-to-end tests. Together, they let you validate backend behavior, test error handling, and build a layered testing strategy that covers both your API contracts and your frontend's response to different API states.
While Cypress is primarily a browser testing framework, its API testing capabilities are robust enough to replace lightweight API testing tools for many teams. The advantage of running API tests within Cypress is that you use the same test runner, the same assertion library, the same CI configuration, and the same reporting infrastructure. Teams avoid maintaining a separate API testing tool and can share authentication helpers, fixtures, and test data across both UI and API tests.
Step 1: Make Direct API Requests with cy.request
The cy.request() command sends HTTP requests from Cypress's Node.js server process, completely bypassing the browser. This makes it fast and suitable for validating backend behavior without rendering any UI. The command accepts all standard HTTP options:
// Simple GET request
cy.request('GET', '/api/products').then((response) => {
expect(response.status).to.equal(200);
expect(response.body).to.be.an('array');
expect(response.body).to.have.length.greaterThan(0);
});
// POST with request body
cy.request({
method: 'POST',
url: '/api/products',
body: {
name: 'Test Widget',
price: 29.99,
category: 'Electronics'
},
headers: {
'Content-Type': 'application/json'
}
}).then((response) => {
expect(response.status).to.equal(201);
expect(response.body).to.have.property('id');
expect(response.body.name).to.equal('Test Widget');
});
By default, cy.request() uses the baseUrl from your Cypress configuration, so you only need to specify the path. It automatically includes cookies from the browser session, making it easy to test authenticated endpoints after logging in through the UI or via an API-based login command.
The response object provides access to the status code, response body (automatically parsed from JSON), headers, and duration. Cypress logs each request in the Command Log, showing the method, URL, status, and response for debugging.
Step 2: Test CRUD Operations
A typical API test suite validates each operation in a resource's lifecycle: create, read, update, and delete. Cypress handles this naturally by chaining requests:
describe('Products API', () => {
let productId;
it('creates a new product', () => {
cy.request('POST', '/api/products', {
name: 'Cypress Widget',
price: 19.99
}).then((response) => {
expect(response.status).to.equal(201);
expect(response.body).to.have.property('id');
productId = response.body.id;
});
});
it('reads the created product', () => {
cy.request('GET', `/api/products/${productId}`).then((response) => {
expect(response.status).to.equal(200);
expect(response.body.name).to.equal('Cypress Widget');
expect(response.body.price).to.equal(19.99);
});
});
it('updates the product price', () => {
cy.request('PATCH', `/api/products/${productId}`, {
price: 24.99
}).then((response) => {
expect(response.status).to.equal(200);
expect(response.body.price).to.equal(24.99);
});
});
it('deletes the product', () => {
cy.request('DELETE', `/api/products/${productId}`).then((response) => {
expect(response.status).to.equal(204);
});
});
it('confirms the product no longer exists', () => {
cy.request({
method: 'GET',
url: `/api/products/${productId}`,
failOnStatusCode: false
}).then((response) => {
expect(response.status).to.equal(404);
});
});
});
Note the failOnStatusCode: false option in the last test. By default, cy.request() fails when it receives a 4xx or 5xx status code. Setting this to false tells Cypress to treat error status codes as valid responses that you want to assert on rather than test failures.
Step 3: Handle Authentication in API Tests
Most APIs require authentication. The standard pattern in Cypress is to authenticate once and reuse the token across subsequent requests:
describe('Protected Endpoints', () => {
let authToken;
before(() => {
cy.request('POST', '/api/auth/login', {
email: 'admin@example.com',
password: 'adminPassword123'
}).then((response) => {
authToken = response.body.token;
});
});
it('accesses protected user data', () => {
cy.request({
method: 'GET',
url: '/api/admin/users',
headers: {
Authorization: `Bearer ${authToken}`
}
}).then((response) => {
expect(response.status).to.equal(200);
expect(response.body).to.be.an('array');
});
});
it('rejects requests without authentication', () => {
cy.request({
method: 'GET',
url: '/api/admin/users',
failOnStatusCode: false
}).then((response) => {
expect(response.status).to.equal(401);
});
});
});
For cookie-based authentication, cy.request() automatically sends and stores cookies, so you can authenticate once and subsequent requests include the session cookie without any explicit header management.
Step 4: Stub API Responses with cy.intercept
While cy.request() tests the actual API, cy.intercept() controls what the browser receives during end-to-end tests. This separation lets you test UI behavior independently from backend behavior:
// Stub the products list with specific test data
cy.intercept('GET', '/api/products', {
statusCode: 200,
body: [
{ id: 1, name: 'Widget A', price: 10, inStock: true },
{ id: 2, name: 'Widget B', price: 20, inStock: false }
]
}).as('getProducts');
cy.visit('/products');
cy.wait('@getProducts');
// Test that the UI renders both products
cy.get('.product-card').should('have.length', 2);
// Test that out-of-stock items show the correct indicator
cy.contains('.product-card', 'Widget B')
.find('.stock-badge')
.should('contain', 'Out of Stock');
You can also use fixture files to keep stubbed responses organized and reusable:
// cypress/fixtures/products.json contains the response data
cy.intercept('GET', '/api/products', { fixture: 'products.json' }).as('getProducts');
Stubbed tests are faster than tests that hit real APIs, more deterministic because the data never changes unexpectedly, and easier to write because you control exactly what the application receives.
Step 5: Test Error Handling and Edge Cases
Stubbing error responses is one of the most valuable uses of cy.intercept(). It lets you verify that your application handles API failures gracefully, which is difficult to test against a live backend:
// Test 500 server error handling
it('shows error message on server failure', () => {
cy.intercept('GET', '/api/products', {
statusCode: 500,
body: { error: 'Internal server error' }
});
cy.visit('/products');
cy.get('.error-banner').should('contain', 'Something went wrong');
cy.get('.retry-button').should('be.visible');
});
// Test network timeout
it('handles slow responses with a loading state', () => {
cy.intercept('GET', '/api/products', (req) => {
req.reply({
statusCode: 200,
body: [],
delay: 5000
});
});
cy.visit('/products');
cy.get('.loading-spinner').should('be.visible');
});
// Test empty state
it('shows empty state when no data exists', () => {
cy.intercept('GET', '/api/products', { body: [] });
cy.visit('/products');
cy.get('.empty-state').should('contain', 'No products found');
});
// Test validation errors from the API
it('displays field-level validation errors', () => {
cy.intercept('POST', '/api/products', {
statusCode: 422,
body: {
errors: {
name: 'Name is required',
price: 'Price must be positive'
}
}
});
cy.visit('/products/new');
cy.get('[data-testid="submit"]').click();
cy.get('.field-error').should('have.length', 2);
});
Step 6: Combine API and UI Testing
The most effective Cypress testing strategy uses API calls for setup and teardown while focusing test assertions on UI behavior. This layered approach keeps tests fast and focused:
describe('Order Management', () => {
beforeEach(() => {
// API setup: create test data quickly
cy.loginByApi('admin@test.com', 'password');
cy.request('POST', '/api/test/seed-orders', {
count: 5,
status: 'pending'
});
cy.visit('/admin/orders');
});
it('displays pending orders in the dashboard', () => {
cy.get('.order-row').should('have.length', 5);
cy.get('.order-status').each(($el) => {
cy.wrap($el).should('contain', 'Pending');
});
});
it('marks an order as fulfilled', () => {
cy.intercept('PATCH', '/api/orders/*').as('updateOrder');
cy.get('.order-row').first()
.find('[data-testid="fulfill-btn"]').click();
cy.wait('@updateOrder')
.its('response.statusCode').should('equal', 200);
cy.get('.order-row').first()
.find('.order-status').should('contain', 'Fulfilled');
});
afterEach(() => {
// API teardown: clean up test data
cy.request('DELETE', '/api/test/cleanup-orders');
});
});
This pattern uses cy.request() in beforeEach to seed the database quickly, asserts on UI elements during the test, uses cy.intercept() to spy on the API call triggered by the UI action, and cleans up with cy.request() in afterEach. Each layer is used for what it does best: the API for data management and the browser for user experience validation.
Use cy.request() for direct API validation and test data management, and cy.intercept() for controlling what the UI receives during end-to-end tests. The combination gives you comprehensive coverage of both backend contracts and frontend error handling within a single, unified test suite.