A broken API contract often surfaces as a vague UI failure:
expected [data-cy="order-total"] to contain "$42.00"
Was the React component wrong? Did the API rename total to amount? Did GraphQL return 200 with an errors array? A focused API test answers those questions closer to the boundary that changed.
Cypress can run REST and GraphQL checks through cy.request() in the same specs, configuration, and CI job as the UI suite.
A REST contract should assert shape and meaning
A status assertion alone is not much of a contract. Check the fields the consumer actually relies on and a few domain constraints:
describe('GET /api/cart', { testIsolation: false }, () => {
it('returns the fields required by checkout', () => {
cy.request('/api/cart').then(({ status, body }) => {
expect(status).to.eq(200)
expect(body).to.have.all.keys(
'id',
'items',
'subtotal',
'tax',
'total',
'currency',
)
expect(body.total).to.be.a('number').and.be.at.least(0)
expect(body.currency).to.be.oneOf(['USD', 'EUR', 'GBP'])
body.items.forEach((item: unknown) => {
expect(item).to.include.all.keys('sku', 'quantity', 'unitPrice')
})
})
})
})
This is intentionally consumer-oriented. It is not a full OpenAPI schema validator, but it catches the contract changes most likely to break the checkout UI.
For expected error responses, disable Cypress's default status-code failure and assert the API's error contract:
cy.request({
method: 'POST',
url: '/api/orders',
body: { items: [] },
failOnStatusCode: false,
}).then(({ status, body }) => {
expect(status).to.eq(422)
expect(body.errors).to.deep.include({
field: 'items',
code: 'MIN_ITEMS',
})
})
GraphQL can fail with HTTP 200
GraphQL usually uses one endpoint. Transport success does not guarantee query success, so assert both data and the absence of errors.
const gql = (query: string, variables: Record<string, unknown>) =>
cy.request('POST', '/graphql', { query, variables })
it('returns the order fields used by the confirmation page', () => {
gql(
`query Order($id: ID!) {
order(id: $id) {
id
status
total
currency
}
}`,
{ id: 'order-17' },
).then(({ status, body }) => {
expect(status).to.eq(200)
expect(body).not.to.have.property('errors')
expect(body.data.order).to.deep.include({
id: 'order-17',
status: 'CONFIRMED',
currency: 'USD',
})
expect(body.data.order.total).to.be.a('number')
})
})
An alternative is to assert errors is either absent or empty, depending on your server's response shape. Be explicit; do not assume 200 means the resolver succeeded.
Control the data before asserting the contract
A contract check built on whatever data happens to exist is difficult to diagnose. Seed a synthetic resource with a stable identifier, verify it, and remove it through a supported test boundary:
beforeEach(() => {
cy.request('POST', '/test/fixtures/orders', {
id: 'contract-order-17',
status: 'CONFIRMED',
total: 42,
currency: 'USD',
})
})
afterEach(() => {
cy.request('DELETE', '/test/fixtures/orders/contract-order-17')
})
In a shared environment, generate a per-run identifier rather than reusing the literal value. Test-only fixture endpoints should be authenticated, narrowly scoped, unavailable from the public production surface, and prevented from returning unrelated records.
cy.request() and cy.intercept() solve different problems
| Question | Use |
|---|---|
| Can I call this endpoint directly and inspect the response? | cy.request() |
| Did the application make this request? | cy.intercept() |
| Should I stub or wait for browser traffic? | cy.intercept() |
| Do I need a database query or filesystem operation? | cy.task() |
cy.request() is handed to the Cypress Node process. The browser does not make the call, so it is not visible in DevTools, is not intercepted by cy.intercept(), and is not constrained by browser CORS.
That makes direct API tests convenient. It also means they cannot prove that browser code uses the endpoint correctly.
Keep API specs substantial
Cypress starts a browser for an end-to-end spec even when that spec only uses cy.request(). Avoid creating one spec per endpoint method. Group related API tests by resource so the startup cost is shared:
cypress/e2e/api/
├── auth.cy.ts
├── carts.cy.ts
├── orders.cy.ts
└── users.cy.ts
For API-only groups, testIsolation: false can avoid browser cleanup between tests, but then your own setup must ensure that one test cannot contaminate another.
Prefer a few meaningful assertions over a response snapshot
Large snapshots tend to fail on harmless fields such as timestamps while hiding why a consumer broke. Assert the required fields, enum values, nullability, and domain relationships instead. If the API has a formal schema, validate that schema in its own pipeline and keep the Cypress assertion focused on the React workflow's dependency.
That division gives two useful signals: the service owns its complete contract, while the UI repository documents the subset it actually consumes.
What these tests do not replace
These examples do not replace:
- provider/consumer contract tooling across independently deployed services;
- OpenAPI or GraphQL schema validation;
- load, security, or resilience testing;
- browser integration tests;
- service-level unit and integration tests.
They provide fast evidence in the repository where frontend engineers feel the breakage. That is often enough to turn “the page is wrong” into “the response contract changed.”
Which API dependency causes the most ambiguous UI failures in your suite today?
Top comments (0)