QA Interview Hub · Knowledge base · Quizzes
Cypress
API testing
cy.request from simple to Senior
What is cy.request and how does it differ from cy.intercept?
cy.request sends an HTTP request itself, from the Cypress Node process, not from the page. So CORS doesn't block it, and cookies are sent and stored automatically. It is a tool for API tests and data preparation.
cy.intercept sends nothing: it intercepts the requests the application makes.
| cy.request | cy.intercept |
| What it does | Sends a request | Intercepts a page's request |
| From where | The Node process | The Node process's proxy |
| What for | API tests, login, data preparation | Mocks, checking the front end's requests |
In an interview: cy.request sends an HTTP request itself, and does it from the Node process, so CORS doesn't get in its way. I use it for API tests, login and data preparation. cy.intercept sends nothing: it intercepts the requests the application makes, to replace or check them.
And in Playwright:The equivalent of cy.request is the request fixture (APIRequestContext). The main difference: the browser doesn't start, so API tests are much faster and are parallelised by workers. page.request shares cookies with the page.
How do you check the status, body and headers of a response?
cy.request yields a response object with status, body, headers and duration. Individual fields are conveniently obtained with .its(), and several checks in .then().
cy.request('GET', '/api/products/42').then((res) => {
expect(res.status).to.eq(200);
expect(res.headers['content-type']).to.include('application/json');
expect(res.body).to.have.property('id', 42);
expect(res.body.price).to.be.a('number').and.be.greaterThan(0);
expect(res.duration).to.be.lessThan(1000);
});
cy.request('/api/products').its('body').should('have.length.greaterThan', 0);
In an interview: cy.request returns a response object with the status, body, headers and duration. One field I get with its, and several checks I do in then: the status, content-type, the presence and type of fields in the body, and if needed, that the response came faster than a certain threshold.
And in Playwright:const res = await request.get('/api/products/42');
await expect(res).toBeOK();
expect(res.headers()['content-type']).toContain('application/json');
const body = await res.json();
expect(body).toMatchObject({ id: 42 });
How do you test negative scenarios: 400, 401, 404?
By default cy.request fails on any 4xx or 5xx status. For negative tests you need the option failOnStatusCode: false, otherwise the test will fail before your check. This is one of the most frequent questions.
cy.request({
method: 'POST',
url: '/api/orders',
body: { items: [] },
failOnStatusCode: false,
}).then((res) => {
expect(res.status).to.eq(400);
expect(res.body.message).to.contain('items');
});
cy.request({ url: '/api/admin', failOnStatusCode: false })
.its('status').should('eq', 401);
In an interview: By default cy.request fails on any four hundred or five hundred status. So for negative cases I add failOnStatusCode false and then check myself that the expected code came: four hundred for invalid data, four hundred and one without a token, four hundred and four for a nonexistent resource, and that the error message is meaningful.
And in Playwright:The opposite behaviour: Playwright doesn't fail on 4xx and 5xx by default, and you check the status yourself. To make it fail, as in Cypress, there is the option failOnStatusCode: true.
How do you build a CRUD chain in one test?
You create an entity, take the id from the response, read, update and delete it, and at the end check that it really disappeared. Each step passes data to the next one through .then().
it('product CRUD', () => {
cy.request('POST', '/api/products', { name: 'Thermos', price: 500 })
.then(({ status, body }) => {
expect(status).to.eq(201);
const id = body.id;
cy.request('GET', '/api/products/' + id)
.its('body.name').should('eq', 'Thermos');
cy.request('PUT', '/api/products/' + id, { name: 'Thermos', price: 450 })
.its('body.price').should('eq', 450);
cy.request('DELETE', '/api/products/' + id)
.its('status').should('eq', 204);
cy.request({ url: '/api/products/' + id, failOnStatusCode: false })
.its('status').should('eq', 404);
});
});
In an interview: I create the entity with POST, take the id from the response and pass it on through then. Then I read with GET and check the data, update with PUT, delete with DELETE, and at the end make sure that GET returns four hundred and four. That way one test covers the resource's whole lifecycle and cleans up after itself.
And in Playwright:The logic is the same, but with await and without nested callbacks: the chain reads from top to bottom, and the id goes into an ordinary variable.
How do you organise authorisation in API tests?
You log in with a request, save the token and pass it in the Authorization header. To avoid repeating code, move it into a custom command. The login and password come from environment variables, not from the code.
// cypress/support/commands.ts
Cypress.Commands.add('apiLogin', () => {
return cy.request('POST', '/api/auth/login', {
email: Cypress.env('USER_EMAIL'),
password: Cypress.env('USER_PASSWORD'),
}).its('body.token');
});
// the test
cy.apiLogin().then((token) => {
cy.request({
url: '/api/orders',
headers: { Authorization: 'Bearer ' + token },
}).its('status').should('eq', 200);
});
In an interview: I log in with a request, get the token and pass it in the Authorization header. The logic is moved into the custom command apiLogin, to avoid duplication. I take the credentials from environment variables, so they don't end up in the repository.
And in Playwright:It is convenient to create a separate context with a header and hand it out through a fixture.
const api = await playwright.request.newContext({
baseURL: process.env.API_URL,
extraHTTPHeaders: { Authorization: 'Bearer ' + token },
});
How do you validate a response schema?
Checking individual fields doesn't catch the situation where the backend changed a type or removed a field you didn't check. JSON schema validation checks the whole structure: required fields, types, formats.
The simplest is to add the chai-json-schema plugin and keep the schemas in fixtures. An alternative is ajv through cy.task or your own command.
// cypress/support/e2e.ts
import chaiJsonSchema from 'chai-json-schema';
chai.use(chaiJsonSchema);
// the test
cy.fixture('schemas/product.json').then((schema) => {
cy.request('/api/products/42').its('body')
.should('be.jsonSchema', schema);
});
// fixtures/schemas/product.json
// { "type": "object",
// "required": ["id", "name", "price"],
// "properties": {
// "id": { "type": "integer" },
// "name": { "type": "string" },
// "price": { "type": "number", "minimum": 0 } } }
In an interview: Checking individual fields misses changes you didn't foresee, for example when the backend changed a field's type. So I validate the whole response against a JSON schema: I add chai-json-schema, and I keep the schemas themselves in fixtures. That way the test fails immediately if the contract changed.
And in Playwright:Chai plugins aren't needed: the test is in Node, so ajv is plugged in directly, and the result is checked with expect(validate(body)).toBe(true).
How do you combine API and UI in one test?
Prepare data through the API, check through the UI, clean up through the API again. This is many times faster and more stable than creating everything with clicks, and it tests exactly what is needed.
The second technique is to check that the front end sent the right request: an action in the UI, an intercept with an alias, a check of the request body.
let productId;
beforeEach(() => {
cy.request('POST', '/api/products', { name: 'Test product', price: 99 })
.its('body.id').then((id) => { productId = id; });
});
afterEach(() => {
cy.request('DELETE', '/api/products/' + productId);
});
it('the product is visible in the catalogue', () => {
cy.visit('/catalog');
cy.contains('[data-cy=product]', 'Test product').should('be.visible');
});
In an interview: I create the needed data through the API in beforeEach, the test itself checks only the UI, and in afterEach I clean the data up through the API again. This is much faster and more stable than creating everything with clicks. The same approach works in Playwright: test objects can be deleted through the API after every test.
And in Playwright:The same, but through a fixture: creation before await use(), deletion after. Cleanup is guaranteed even if the test fails, and you simply add this fixture to the signature of the test that needs it.
What limits does Cypress have for API testing and when should you take another tool?
- There is no built-in parallelism without Cypress Cloud or plugins.
- WebSocket can't be tested directly.
- It isn't suitable for load testing: for that use k6 or JMeter.
- For API tests a browser is still started, so it is slower than pure HTTP clients. More convenient output is given by the cypress-plugin-api plugin.
A large set of pure API tests is more convenient to keep in Postman/Newman, Playwright request or Supertest. Cypress is justified when API tests mostly serve UI tests and the team wants one stack.
In an interview: Cypress is suitable for API tests, but has limits: without Cloud there is no parallelism, WebSocket can't be tested directly, load can't be checked with it, and even for API tests a browser is started. If there are many API tests and they are standalone, I would take Postman with Newman or Playwright request. Cypress is justified when the API mostly helps the UI tests and you want to keep one stack.
And in Playwright:For APIs Playwright is more convenient: without a browser, in parallel through workers, with an HTML report. WebSocket can be listened to through page.on('websocket') or mocked through routeWebSocket. But load testing is likewise not its job.