Cypress: API testing con cy.request — GET, POST, auth, chaining y schema validation

GET, POST, PUT y DELETE contra Restful-Booker. Auth con token, chaining, data-driven y validación de schema con AJV. 16 tests verdes.

VSCode con el bloque de chaining en restful-booker.cy.ts y la terminal a la derecha mostrando 16 tests passing en 8 segundos contra Restful-Booker
Bloque de chaining a la izquierda, resultado a la derecha. cy.apiLogin() + POST → GET → DELETE en una sola cadena.

En mi trabajo actual la mayoría del testing con Cypress se usa para API. Quería armar acá un lab completo — cubriendo todo lo que se puede hacer con cy.request() — para tener el músculo hecho y para dejar una referencia real.

Elegí atacar Restful-Booker (https://restful-booker.herokuapp.com), una API pública pensada para practicar automation. Tiene CRUD completo, auth con token, respuestas realistas y comportamientos raros que aparecen en APIs reales. Serenity.is no expone endpoints REST limpios, así que separé este módulo del resto de la serie.

Resultado final antes de arrancar: 16 tests verdes en 8 segundos, cubriendo GET / auth / POST / PUT / PATCH / DELETE / chaining / data-driven / errores / schema validation.


Por qué separé un módulo API en el repo

Hasta acá el repo tenía 5 carpetas de tests, todas contra Serenity.is:

cypress/e2e/
  ├── auth/
  ├── clientes/
  ├── dashboard/
  ├── intercept/
  └── ui-avanzada/

Todo con baseUrl apuntando a https://demo.serenity.is. Meter tests de otra API en el mismo módulo iba a ensuciar el baseUrl y los custom commands.

Decidí armar una carpeta nueva:

cypress/e2e/
  └── api/
      └── restful-booker.cy.ts

Y no toqué el baseUrl. La URL de la API pública va en env.apiUrl — sección aparte, sin pisar nada.


Cambios en el repo, uno por uno

Antes de escribir los tests, tuve que preparar la base. Cuatro archivos existentes modificados y cuatro nuevos.

cypress.config.ts — agregar env.apiUrl

Podría hardcodear https://restful-booker.herokuapp.com en cada test. Mala idea: si mañana cambia la URL o quiero apuntar a un mock, tengo que buscar y reemplazar en todos lados.

export default defineConfig({
  e2e: {
    baseUrl: 'https://demo.serenity.is',  // Serenity para tests de UI
    // ...
    env: {
      apiUrl: 'https://restful-booker.herokuapp.com'
    },
    // ...
  },
})
cypress.config.ts con el bloque env definiendo apiUrl con la URL de Restful-Booker
La URL de la API va en env.apiUrl, no hardcodeada en cada test. Cambiar de ambiente pasa a ser un flag.

En los tests la leo con Cypress.env('apiUrl').

package.json — dos deps nuevas y un script

Para schema validation (bloque 8) uso AJV. Es el validador de JSON Schema más usado en JavaScript. ajv-formats agrega los formats comunes como date, email, uri, que no vienen por default.

"devDependencies": {
  "ajv": "^8.17.1",
  "ajv-formats": "^3.0.1",
  "cypress": "^15.17.0",
  ...
}

Y agregué un script para correr solo los tests de API:

"scripts": {
  "cy:run": "cypress run --browser edge",
  "cy:run:api": "cypress run --browser edge --spec \"cypress/e2e/api/**/*.cy.ts\""
}

Así puedo correr npm run cy:run:api sin traer todos los tests de UI cuando estoy iterando sobre API.

cypress/support/commands.ts — dos custom commands nuevos

Repetir el POST /auth y los headers de Cookie: token=... en cada test es ruido. Encapsulé eso en dos commands:

Definición en TypeScript de los custom commands apiLogin y authRequest en cypress/support/commands.ts
apiLogin devuelve el token. authRequest envuelve cy.request con los headers de auth ya inyectados y failOnStatusCode: false.

Decisión chica pero importante: authRequest va con failOnStatusCode: false. La idea es que el test decida qué status espera, no el helper. Si el helper corta ante un 500, no puedo escribir tests que aserteen "debe fallar con 500".

cypress/support/index.d.ts — tipar los commands

Cypress + TypeScript es una relación bonita hasta que agregás un command y no tipás la firma. Ahí perdés autocomplete y el compilador te grita.

declare namespace Cypress {
    interface Chainable {
        login(username?: string, password?: string): Chainable<void>
        apiLogin(username?: string, password?: string): Chainable<string>
        authRequest(
            method: Cypress.HttpMethod,
            url: string,
            token: string,
            body?: unknown
        ): Chainable<Cypress.Response<any>>
    }
}

apiLogin devuelve Chainable<string> porque el token es un string. authRequest devuelve Chainable<Cypress.Response<any>> porque el test necesita acceder al status, body, headers.

cypress/fixtures/booking.json — payload base

Un booking válido para reutilizar en POST y PUT:

{
  "firstname": "Cesar",
  "lastname": "Beas",
  "totalprice": 350,
  "depositpaid": true,
  "bookingdates": {
    "checkin": "2026-09-15",
    "checkout": "2026-09-20"
  },
  "additionalneeds": "Late checkout"
}

cypress/fixtures/bookings-bulk.json — para data-driven

Array de 3 bookings para iterar. Uno con desayuno, uno con cena, uno sin nada. Diferentes precios. La idea es tener variación real, no tres copias del mismo.

cypress/fixtures/schemas/booking-schema.json — el JSON Schema

Este es el contrato que la respuesta de GET /booking/:id debería cumplir. AJV lo compila y me devuelve una función validate(body) que responde true o me lista los errores.

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["firstname", "lastname", "totalprice", "depositpaid", "bookingdates"],
  "properties": {
    "firstname": { "type": "string" },
    "lastname": { "type": "string" },
    "totalprice": { "type": "number" },
    "depositpaid": { "type": "boolean" },
    "bookingdates": {
      "type": "object",
      "required": ["checkin", "checkout"],
      "properties": {
        "checkin": { "type": "string", "format": "date" },
        "checkout": { "type": "string", "format": "date" }
      }
    },
    "additionalneeds": { "type": "string" }
  },
  "additionalProperties": false
}
booking-schema.json en cypress/fixtures/schemas/ con el JSON Schema Draft 07 del booking: required, properties, additionalProperties false
El schema del booking, guardado en fixtures/schemas/. additionalProperties: false para detectar si el server agrega campos no anunciados.

additionalProperties: false es estricto — si la API agrega un campo nuevo, el test falla. En algunos contextos esto molesta, pero como ejercicio de contract testing es lo que se busca: detectar cambios no anunciados.

Lo guardé en fixtures/schemas/ para que quede prolijo. Se lee con cy.fixture('schemas/booking-schema').


Los 8 bloques de tests, uno por uno

Cada bloque es un context() dentro del describe. Los agrupé por lo que están probando.

1. GET — leer datos

Tres tests: array de bookings, detalle por id, filtrado con query params.

it('GET /booking devuelve un array de bookingid', () => {
    cy.request('GET', `${apiUrl}/booking`).then((response) => {
        expect(response.status).to.eq(200)
        expect(response.headers['content-type']).to.include('application/json')
        expect(response.body).to.be.an('array')
        expect(response.body.length).to.be.greaterThan(0)
        expect(response.body[0]).to.have.property('bookingid')
    })
})

Assertions sobre status, headers, body, tipo, longitud, propiedad. Todo eso es lo que un test de API tiene que hacer.

El segundo test usa chaining: no hardcodeo un id. Primero traigo la lista, agarro el primero, y con eso pido el detalle:

cy.request('GET', `${apiUrl}/booking`).then((list) => {
    const firstId = list.body[0].bookingid

    cy.request('GET', `${apiUrl}/booking/${firstId}`).then((detail) => {
        expect(detail.status).to.eq(200)
        expect(detail.body).to.have.all.keys(
            'firstname', 'lastname', 'totalprice',
            'depositpaid', 'bookingdates', 'additionalneeds'
        )
    })
})

.to.have.all.keys(...) es una linda assertion de Chai: falla si al body le sobra o le falta una key.

2. Auth — POST /auth

Restful-Booker devuelve un token que después se manda como Cookie: token=xxx en los PUT/PATCH/DELETE.

Credenciales válidas:

cy.request('POST', `${apiUrl}/auth`, {
    username: 'admin',
    password: 'password123'
}).then((response) => {
    expect(response.status).to.eq(200)
    expect(response.body).to.have.property('token')
    expect(response.body.token).to.be.a('string').and.have.length.above(10)
})

Trampa real con credenciales inválidas: el server NO devuelve 401. Devuelve 200 con { reason: 'Bad credentials' }. Si el test aserteara .eq(401) fallaría no por un bug de la API sino porque asumí una convención que este server no sigue:

cy.request({
    method: 'POST',
    url: `${apiUrl}/auth`,
    body: { username: 'admin', password: 'wrong' },
    failOnStatusCode: false
}).then((response) => {
    expect(response.status).to.eq(200)  // sí, 200
    expect(response.body).to.have.property('reason', 'Bad credentials')
})

Esto es cosa que solo se aprende probando la API. La doc a veces miente o está desactualizada.

3. POST — crear booking

Dos tests. El happy path carga el payload desde fixture y valida que el server responda con bookingid y el objeto creado:

cy.fixture('booking').then((payload) => {
    cy.request('POST', `${apiUrl}/booking`, payload).then((response) => {
        expect(response.status).to.eq(200)
        expect(response.body).to.have.property('bookingid').that.is.a('number')
        expect(response.body.booking).to.deep.include({
            firstname: payload.firstname,
            lastname: payload.lastname,
            totalprice: payload.totalprice
        })
    })
})

.deep.include({...}) — mucho más limpio que asertar propiedad por propiedad.

El segundo test fuerza un Content-Type incorrecto para ver cómo reacciona la API. Puse una assertion defensiva:

expect(response.status).to.not.eq(200)

En vez de un .eq(500) estricto. Distintos servers responden 400, 415 o 500. Lo que me importa es que no acepte el request malformado.

4. PUT / PATCH / DELETE — con auth

Estos verbos requieren el token. El bloque tiene un beforeEach que arma dos cosas antes de cada test: token fresco y booking recién creado. Uso beforeEach en vez de before para que cada test parta limpio (si uno rompe, no arrastra estado a los siguientes).

beforeEach(() => {
    cy.request('POST', `${apiUrl}/auth`, {
        username: 'admin',
        password: 'password123'
    }).its('body.token').then((t: string) => {
        token = t
    })

    cy.fixture('booking').then((payload) => {
        cy.request('POST', `${apiUrl}/booking`, payload)
            .its('body.bookingid')
            .then((id: number) => {
                bookingId = id
            })
    })
})

.its('body.token') es azúcar para .then(r => r.body.token). Más limpio cuando solo querés una propiedad.

PUT reemplaza el booking entero:

cy.request({
    method: 'PUT',
    url: `${apiUrl}/booking/${bookingId}`,
    headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
        Cookie: `token=${token}`
    },
    body: updated
}).then((response) => {
    expect(response.status).to.eq(200)
    expect(response.body.firstname).to.eq('Actualizado')
})

Accept: application/json no es adorno. Sin ese header, Restful-Booker a veces te contesta XML. Cypress no parsea XML, y tus assertions rompen sin decirte por qué. Aprendí esto rompiendo el test la primera vez que corrí un PUT sin ese header.

PATCH actualiza solo lo que le mandes:

body: { firstname: 'ParcheOK' }
// El resto de campos se mantiene

DELETE — otra trampa: devuelve 201, no 204. La convención REST dice 204 (No Content), pero este server responde 201 (Created), cosa que no tiene sentido semántico pero es lo que hay:

cy.request({ method: 'DELETE', ... })
    .its('status').should('eq', 201)  // sí, 201

Después verifico que efectivamente se borró:

cy.request({
    method: 'GET',
    url: `${apiUrl}/booking/${bookingId}`,
    failOnStatusCode: false
}).its('status').should('eq', 404)

Y por último, un DELETE sin token para asertar el 403:

cy.request({
    method: 'DELETE',
    url: `${apiUrl}/booking/${bookingId}`,
    failOnStatusCode: false
}).its('status').should('eq', 403)

5. Chaining con custom commands

Este es el bloque donde se ve por qué armé apiLogin y authRequest. Un flujo completo POST → GET → DELETE en un solo test, sin repetir headers ni URLs:

cy.apiLogin().then((token) => {
    cy.fixture('booking').then((payload) => {
        cy.request('POST', `${apiUrl}/booking`, payload)
            .its('body.bookingid')
            .then((id: number) => {
                cy.request('GET', `${apiUrl}/booking/${id}`)
                    .its('body.firstname')
                    .should('eq', payload.firstname)

                cy.authRequest('DELETE', `/booking/${id}`, token)
                    .its('status')
                    .should('eq', 201)
            })
    })
})

El test se lee a nivel de intención: login por API, crear reserva, verificar que existe, borrar. La plomería (headers, URLs, tokens) queda en support/commands.ts.

6. Data-driven con múltiples payloads

bookings-bulk.json tiene 3 bookings. Iterar con forEach y crear cada uno:

cy.fixture('bookings-bulk').then((bookings) => {
    bookings.forEach((booking) => {
        cy.request('POST', `${apiUrl}/booking`, booking).then((response) => {
            expect(response.status).to.eq(200)
            expect(response.body.booking).to.deep.include({
                firstname: booking.firstname,
                lastname: booking.lastname
            })
        })
    })
})

Mismo patrón que ya vimos con clientes-data.cy.ts (aquel post de data-driven contra Excel), pero acá el "data source" es un JSON en fixtures.

7. Errores — failOnStatusCode: false

Por default, cy.request() falla el test si el status es 4xx o 5xx. Eso es lo que querés en happy paths. Pero cuando estás probando errores, tenés que decirle explícitamente que no falle, para poder asertar sobre el status:

cy.request({
    method: 'GET',
    url: `${apiUrl}/booking/999999999`,
    failOnStatusCode: false
}).its('status').should('eq', 404)

Sin failOnStatusCode: false, Cypress tira CypressError: cy.request() failed on: <url>. The response we received from your web server was: 404. Y el test rompe antes de llegar al assert.

8. Schema validation con AJV

Este es el bloque que valida el contrato del response. No importa qué datos vengan — importa que la estructura sea la esperada. Este patrón es lo que se usa en contract testing serio.

import Ajv from 'ajv'
import addFormats from 'ajv-formats'

// ...

cy.fixture('schemas/booking-schema').then((schema) => {
    const ajv = new Ajv({ allErrors: true })
    addFormats(ajv)
    const validate = ajv.compile(schema)

    cy.request('GET', `${apiUrl}/booking`).then((list) => {
        const id = list.body[0].bookingid
        cy.request('GET', `${apiUrl}/booking/${id}`).then((response) => {
            const ok = validate(response.body)
            if (!ok) console.log('AJV errors:', validate.errors)
            expect(ok, JSON.stringify(validate.errors)).to.eq(true)
        })
    })
})
Código del bloque 8 en restful-booker.cy.ts usando AJV con addFormats y allErrors true para validar la respuesta de GET /booking/:id
allErrors: true lista todos los errores del schema, no solo el primero. Si el response no cumple, el mensaje del test incluye directo qué campo rompió.

allErrors: true hace que AJV liste todos los errores, no solo el primero. Cuando falla la validación, validate.errors te dice exactamente qué campo se rompió y por qué. Ese output es oro puro en un CI cuando el server cambió y todavía no sabés qué.

El expect(ok, JSON.stringify(validate.errors)) usa el segundo argumento de expect como mensaje de error cuando la assertion falla. Si ok es false, el error del test incluye directamente los errores de AJV. No hay que ir a buscar al console.log.


Resultado

16 tests verdes, 8 segundos, 0 rojos.

Cypress runner con 16 tests verdes y el primer test 'GET /booking devuelve un array de bookingid' expandido mostrando los 6 asserts pasados
Los 6 asserts del primer test uno por uno: status 200, content-type application/json, tipo array, longitud, propiedad bookingid.
Terminal de PowerShell con el resumen de npm run cy:run:api: 16 tests, 16 passing, 0 failing, 8 segundos, All specs passed
npm run cy:run:api — 16 verdes en 8 segundos, corrida limpia a la primera.

Corrida limpia a la primera. Restful-Booker aguantó el ritmo (a veces se cuelga por Heroku free tier, pero esta vez respondió rápido).


Archivos nuevos y modificados

Nuevos:

  • cypress/e2e/api/restful-booker.cy.ts — el spec con los 8 bloques
  • cypress/fixtures/booking.json — payload base
  • cypress/fixtures/bookings-bulk.json — 3 bookings para data-driven
  • cypress/fixtures/schemas/booking-schema.json — JSON Schema para AJV

Modificados:

  • cypress.config.tsenv.apiUrl con la URL de Restful-Booker
  • package.jsonajv, ajv-formats y script cy:run:api
  • cypress/support/commands.tsapiLogin() y authRequest()
  • cypress/support/index.d.ts — tipos de los dos commands nuevos

Takeaways

  • cy.request() es distinto a cy.intercept(). intercept observa/mockea requests que salen del browser. request hace requests directos desde Node, sin browser. Este post fue todo request.
  • La URL de la API va en env, no hardcodeada. Cambiar de ambiente es un flag.
  • Custom commands + tipos en TypeScript hacen que el código sea legible y el editor te ayude.
  • failOnStatusCode: false en tests de error, obligatorio. En happy paths, no.
  • Trampas reales del server (200 en credenciales inválidas, 201 al borrar): solo aparecen probando. La doc no siempre lo dice.
  • Schema validation con AJV convierte tus tests de API en contract tests. Si el server cambia el shape, te enterás en el CI, no en producción.
  • beforeEach en vez de before cuando los tests necesitan estado limpio. Si un test rompe, el siguiente no arrastra basura.

Todo el código de esta serie está en: github.com/cesarbeassuarez/cypress-typescript-framework