Individual API requests are useful, but real API testing involves patterns: verifying complete CRUD lifecycles, chaining requests where one response feeds the next, testing error scenarios and validation rules, and combining API and UI assertions for end-to-end confidence. This lesson catalogues the patterns that professional teams use to build comprehensive, maintainable API test suites inside Cypress.
Production API Test Patterns
Each pattern addresses a specific testing need and can be combined with others for thorough API coverage.
// ── PATTERN 1: Full CRUD lifecycle test ──
describe('Products API — CRUD Lifecycle', () => {
let productId: number;
it('CREATE — should create a new product', () => {
cy.request('POST', '/api/products', {
name: 'Test Widget',
price: 24.99,
category: 'gadgets',
}).then((response) => {
expect(response.status).to.eq(201);
expect(response.body).to.have.property('id');
expect(response.body.name).to.eq('Test Widget');
productId = response.body.id;
});
});
it('READ — should retrieve the created product', () => {
cy.request(`/api/products/${productId}`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.name).to.eq('Test Widget');
expect(response.body.price).to.eq(24.99);
});
});
it('UPDATE — should modify the product price', () => {
cy.request('PATCH', `/api/products/${productId}`, {
price: 19.99,
}).then((response) => {
expect(response.status).to.eq(200);
expect(response.body.price).to.eq(19.99);
expect(response.body.name).to.eq('Test Widget'); // unchanged
});
});
it('DELETE — should remove the product', () => {
cy.request('DELETE', `/api/products/${productId}`)
.its('status').should('eq', 204);
cy.request({
url: `/api/products/${productId}`,
failOnStatusCode: false,
}).its('status').should('eq', 404);
});
});
// ── PATTERN 2: Request chaining (one response feeds the next) ──
it('should create user, add product to wishlist, then verify', () => {
// Step 1: Create a user
cy.request('POST', '/api/users', {
name: 'Chain Test User',
email: `chain_${Date.now()}@test.com`,
}).then((userResp) => {
const userId = userResp.body.id;
// Step 2: Add product to that user's wishlist
cy.request('POST', `/api/users/${userId}/wishlist`, {
productId: 42,
}).then((wishResp) => {
expect(wishResp.status).to.eq(201);
// Step 3: Verify the wishlist contains the product
cy.request(`/api/users/${userId}/wishlist`).then((listResp) => {
expect(listResp.body).to.have.length(1);
expect(listResp.body[0].productId).to.eq(42);
});
});
});
});
// ── PATTERN 3: Negative testing (error scenarios) ──
describe('Products API — Error Handling', () => {
it('should return 400 for missing required fields', () => {
cy.request({
method: 'POST',
url: '/api/products',
body: { price: 10.00 }, // Missing required 'name' field
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(400);
expect(response.body.errors).to.be.an('array');
expect(response.body.errors[0]).to.include('name');
});
});
it('should return 422 for invalid price', () => {
cy.request({
method: 'POST',
url: '/api/products',
body: { name: 'Widget', price: -5 },
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(422);
expect(response.body.message).to.include('price');
});
});
it('should return 401 without authentication', () => {
cy.clearCookies();
cy.request({
url: '/api/admin/settings',
failOnStatusCode: false,
}).its('status').should('eq', 401);
});
it('should return 403 for insufficient permissions', () => {
cy.apiLogin('viewer@test.com', 'pass');
cy.request({
method: 'DELETE',
url: '/api/products/1',
failOnStatusCode: false,
headers: { Authorization: `Bearer ${Cypress.env('authToken')}` },
}).its('status').should('eq', 403);
});
});
// ── PATTERN 4: API + UI combined verification ──
it('should verify product count matches between API and UI', () => {
cy.request('/api/products').then((apiResp) => {
const apiCount = apiResp.body.length;
cy.visit('/shop');
cy.get('.product-card').should('have.length', apiCount);
});
});
// ── PATTERN 5: Response schema validation ──
it('should return products matching the expected schema', () => {
cy.request('/api/products').then((response) => {
response.body.forEach((product: any) => {
expect(product).to.have.all.keys(
'id', 'name', 'price', 'description', 'imageUrl', 'inStock'
);
expect(product.id).to.be.a('number');
expect(product.name).to.be.a('string').with.length.greaterThan(0);
expect(product.price).to.be.a('number').and.be.greaterThan(0);
expect(product.inStock).to.be.a('boolean');
});
});
});
// ── Best practices summary ──
const API_BEST_PRACTICES = [
'Use cy.request() for setup, cy.intercept() for browser-level monitoring',
'Always assert on status code AND response body structure',
'Use failOnStatusCode: false for negative tests (4xx, 5xx)',
'Store auth tokens in Cypress.env(), not in variables',
'Generate unique test data with timestamps to avoid conflicts',
'Combine API + UI assertions for end-to-end contract verification',
'Test the full CRUD lifecycle: Create, Read, Update, Delete',
'Include negative tests: missing fields, invalid data, auth failures',
];
describe block. This is one of the few acceptable cases for test interdependence — each step verifies a different HTTP method on the same resource, and the lifecycle only makes sense as a sequence. However, the entire block should be independent of other describe blocks. If the CREATE step fails, all subsequent steps should be skipped (not fail with confusing errors). Cypress’s afterEach hook can clean up partial state if needed.imageUrl to image_url, the schema test fails immediately — before any UI test notices the broken images. Run schema validation tests against every API endpoint your frontend depends on.it() blocks (productId set in CREATE, used in READ/UPDATE/DELETE) require careful ordering. If you add retries to your Cypress config, a retried READ test might run before CREATE has completed on the retry. For critical CRUD flows, consider putting the entire lifecycle in a single it() block to guarantee execution order, or use explicit dependency management.Common Mistakes
Mistake 1 — Only testing happy-path API responses
❌ Wrong: Testing only that POST /api/products returns 201 with valid data — never checking what happens with missing fields, invalid values, or unauthorized requests.
✅ Correct: Testing the full matrix: valid request (201), missing required field (400), invalid data type (422), unauthenticated (401), unauthorized role (403), non-existent resource (404).
Mistake 2 — Not validating response body structure beyond status codes
❌ Wrong: cy.request('/api/products').its('status').should('eq', 200) — the API could return 200 with an empty body, wrong format, or missing fields.
✅ Correct: Asserting on status code, response body structure (keys), data types, value ranges, and array lengths. A 200 status with invalid data is a silent defect that only comprehensive body assertions catch.