Cypress: cy.intercept — stubbing, mocking y simulación de errores de red

Respuestas fake con fixtures, modificación en tránsito, error 500, corte de red y validación de requests. cy.on vs Cypress.on para excepciones.

Grilla mostrando Empresa Modificada por Cypress en la primera fila ALFKI resaltado con flecha donde debería decir Alfreds Futterkiste
El request viajó al servidor real. Cypress modificó CompanyName antes de que la respuesta llegue a la grilla. Los otros 90 clientes están intactos

Contexto

El post anterior usó cy.intercept para espiar. Este lo usa para mentir: respuestas fake, errores inventados y una red que no existe.

En el post anterior usé cy.intercept en modo spy: capturar la respuesta real del servidor sin modificarla. El request viajaba al backend, volvía con los 91 clientes, y yo comparaba esos datos contra el Excel.

Este post va al otro lado. Acá cy.intercept no observa — interviene. Reemplaza respuestas completas, modifica datos en tránsito, simula errores 500 y corta la red a propósito. Todo contra el mismo endpoint: POST /Services/Northwind/Customer/List.

La idea es simple: si puedo controlar lo que el servidor "devuelve", puedo probar cómo reacciona la app a cualquier escenario. Incluidos los que el servidor real nunca produce.


Spy vs stub: dos formas de usar cy.intercept

En el Post 9 (Cypress: validación de grilla contra Excel — cy.task, SlickGrid API y cy.intercept) la sintaxis era:

cy.intercept('POST', '**/Services/Northwind/Customer/List').as('customerList')

Sin segundo argumento más allá del alias. Cypress deja pasar el request al servidor real y captura la respuesta cuando vuelve. Eso es spy.

Para stubear, el segundo argumento cambia todo:

// Stub estático — reemplaza la respuesta completa
cy.intercept('POST', url, { fixture: 'clientes-stub.json' })

// Stub con body directo
cy.intercept('POST', url, { body: { Entities: [], TotalCount: 0 } })

// Callback — el request viaja al servidor, pero modifico la respuesta antes de que llegue a la app
cy.intercept('POST', url, (req) => {
    req.continue((res) => {
        // modificar res.body acá
        res.send()
    })
})

La diferencia entre stub estático y callback es importante. En el estático, el request nunca sale del browser — Cypress lo intercepta y devuelve la respuesta fake directo. En el callback con req.continue(), el request sí viaja al servidor, pero Cypress modifica la respuesta antes de entregarla a la app.

En Selenium no existe nada parecido. En Playwright el equivalente es page.route(), pero la API es distinta.


El fixture: clientes-stub.json

Para el stub estático necesitaba un JSON con la estructura que Serenity espera. La estructura la descubrí en el Post 9 cuando capturé la respuesta real: un objeto con Entities (array de clientes) y TotalCount.

Creé cypress/fixtures/clientes-stub.json con 3 clientes inventados:

{
  "Entities": [
    {
      "CustomerID": "FAKE1",
      "CompanyName": "Empresa Stub Uno",
      "ContactName": "Juan Stub",
      "ContactTitle": "Manager",
      "Region": "Sur",
      "PostalCode": "5000",
      "Country": "Argentina",
      "City": "Córdoba",
      "Phone": "(0351) 555-0001",
      "Fax": "(0351) 555-0002"
    },
    {
      "CustomerID": "FAKE2",
      "CompanyName": "Empresa Stub Dos",
      "ContactName": "María Stub",
      "ContactTitle": "Owner",
      "Region": "Norte",
      "PostalCode": "4000",
      "Country": "Argentina",
      "City": "Tucumán",
      "Phone": "(0381) 555-0003",
      "Fax": "(0381) 555-0004"
    },
    {
      "CustomerID": "FAKE3",
      "CompanyName": "Empresa Stub Tres",
      "ContactName": "Pedro Stub",
      "ContactTitle": "Sales Representative",
      "Region": "Centro",
      "PostalCode": "1000",
      "Country": "Argentina",
      "City": "Buenos Aires",
      "Phone": "(011) 555-0005",
      "Fax": "(011) 555-0006"
    }
  ],
  "TotalCount": 3
}

Tres clientes argentinos con datos inventados. El TotalCount tiene que coincidir con la cantidad de Entities — si no, el paginador de la grilla se confunde.


Los 7 tests

El spec completo: cypress/e2e/intercept/intercept-stubbing.cy.ts. Cuatro context blocks, 7 tests.

context('Stubs estáticos — respuestas fake')

Test 1 — stub con fixture:

it('stub con fixture — la grilla muestra solo los 3 clientes fake', () => {
    cy.intercept('POST', customerListUrl, { fixture: 'clientes-stub.json' }).as('stubCustomers')

    cy.login()
    dashboardPage.irAClientes()
    clientesPage.verificarVisible()

    cy.wait('@stubCustomers')

    cy.get('.slick-row').should('have.length', 3)
    cy.get('.slick-row').eq(0).should('contain.text', 'Empresa Stub Uno')
    cy.get('.slick-row').eq(1).should('contain.text', 'Empresa Stub Dos')
    cy.get('.slick-row').eq(2).should('contain.text', 'Empresa Stub Tres')
})

cy.intercept se registra ANTES de navegar. Cuando Serenity hace el POST para cargar los clientes, Cypress lo intercepta y devuelve el fixture en vez de dejar que el request llegue al servidor. La grilla renderiza los 3 clientes fake como si fueran reales.

Cypress pausado mostrando grilla con 3 clientes fake FAKE1 FAKE2 FAKE3 de Argentina y contador Mostrando desde 1 hasta 3 de 3 registros totales
3 clientes inventados renderizados en la grilla real. El servidor nunca los devolvió — Cypress los inyectó con un fixture JSON

El contador dice "Mostrando desde 1 hasta 3 de 3 registros totales". La grilla no sabe que los datos son inventados.

Test 2 — respuesta vacía:

it('stub con respuesta vacía — la grilla no muestra registros', () => {
    cy.intercept('POST', customerListUrl, {
        body: { Entities: [], TotalCount: 0 }
    }).as('stubEmpty')

    cy.login()
    dashboardPage.irAClientes()
    clientesPage.verificarVisible()

    cy.wait('@stubEmpty')

    cy.get('.slick-row').should('have.length', 0)
})

Acá no usé fixture — pasé el body directo en el intercept. Array vacío, cero registros. La grilla queda sin filas.

Cypress pausado mostrando grilla de Clientes vacía con headers visibles y texto No hay registros después de stub con Entities vacío
Respuesta vacía stubeada. La grilla muestra headers pero sin filas, el contador dice No hay registros

context('Modificación de respuesta real')

Test 3 — modificar un campo antes de que llegue a la grilla:

it('modifica CompanyName del primer cliente antes de que llegue a la grilla', () => {
    cy.intercept('POST', customerListUrl, (req) => {
        req.continue((res) => {
            if (res.body?.Entities?.length > 0) {
                res.body.Entities[0].CompanyName = 'Empresa Modificada por Cypress'
            }
            res.send()
        })
    }).as('modifiedResponse')

    cy.login()
    dashboardPage.irAClientes()
    clientesPage.verificarVisible()

    cy.wait('@modifiedResponse')

    cy.get('.slick-row').eq(0).should('contain.text', 'Empresa Modificada por Cypress')
})

La diferencia con el stub estático: acá el request SÍ viaja al servidor. Serenity pide los 91 clientes reales, el servidor responde con datos reales, pero antes de que la respuesta llegue a la app, req.continue() me da acceso al body y cambio el CompanyName del primer cliente. La grilla muestra "Empresa Modificada por Cypress" donde debería decir "Alfreds Futterkiste".

Grilla mostrando Empresa Modificada por Cypress en la primera fila ALFKI resaltado con flecha donde debería decir Alfreds Futterkiste
El request viajó al servidor real. Cypress modificó CompanyName antes de que la respuesta llegue a la grilla. Los otros 90 clientes están intactos

Esto es útil para probar cómo reacciona la UI a datos que el servidor normalmente no devuelve. Nombres con caracteres especiales, campos vacíos, valores extremos — sin necesidad de modificar la base de datos.

context('Simulación de errores y red')

Test 4 — error 500:

it('error 500 del servidor', () => {
    cy.on('uncaught:exception', () => false)

    cy.intercept('POST', customerListUrl, {
        statusCode: 500,
        body: { Error: 'Internal Server Error' }
    }).as('error500')

    cy.login()
    dashboardPage.irAClientes()
    clientesPage.verificarVisible()

    cy.wait('@error500')

    cy.get('.slick-row').should('have.length', 0)
})

El cy.on('uncaught:exception', () => false) no estaba en la primera versión. Lo tuve que agregar después del primer error — más detalles en la sección de errores.

Cuando Serenity recibe un 500, la grilla queda vacía y aparece un banner rojo en la parte superior: 'An error occurred while processing your request'. Esto no lo vi en la primera corrida — recién lo descubrí cuando usé cy.pause() para capturar screenshots. Sin la pausa, el test avanza tan rápido que el banner no se llega a ver.

Serenity.is mostrando banner An error occurred while processing your request grilla vacía y excepción en command log tras error 500 stubeado
Con cy.pause se ve la UI real: Serenity muestra un banner de error en la parte superior. Sin cy.pause esto no se veía

Test 5 — delay de 3 segundos:

it('respuesta lenta — delay de 3 segundos', () => {
    cy.intercept('POST', customerListUrl, (req) => {
        req.continue((res) => {
            res.setDelay(3000)
            res.send()
        })
    }).as('slowResponse')

    cy.login()
    dashboardPage.irAClientes()
    clientesPage.verificarVisible()

    cy.wait('@slowResponse')

    cy.get('.slick-row').should('have.length.greaterThan', 0)
})

El request viaja al servidor, el servidor responde, pero Cypress retiene la respuesta 3 segundos antes de entregarla a la app. Serenity no mostró ningún indicador de carga durante la espera — simplemente tardó más en mostrar la grilla. Después del delay, los 91 clientes aparecieron normal.

Test 6 — corte de red:

it('error de red — conexión cortada', () => {
    cy.intercept('POST', customerListUrl, { forceNetworkError: true }).as('networkError')

    cy.login()
    dashboardPage.irAClientes()
    clientesPage.verificarVisible()

    cy.wait('@networkError')

    cy.get('.slick-row').should('have.length', 0)
})

forceNetworkError: true simula que la red se cortó. No hay respuesta HTTP — el request directamente falla. La grilla queda vacía, igual que con el 500.

context('Validación del request')

Test 7 — verificar qué envía Serenity:

it('verifica headers y body del POST que Serenity envía', () => {
    cy.intercept('POST', customerListUrl).as('customerRequest')

    cy.login()
    dashboardPage.irAClientes()
    clientesPage.verificarVisible()

    cy.wait('@customerRequest').then((interception) => {
        expect(interception.request.headers).to.have.property('content-type')
        expect(interception.request.headers['content-type']).to.include('application/json')

        cy.log('Request body: ' + JSON.stringify(interception.request.body))

        expect(interception.response?.statusCode).to.equal(200)
        expect(interception.response?.body).to.have.property('Entities')
        expect(interception.response?.body).to.have.property('TotalCount')
        expect(interception.response?.body.TotalCount).to.equal(91)
    })
})

Este test vuelve al modo spy — no modifica nada. Pero en vez de validar solo la respuesta (como en el Post 9), valida el request: qué headers manda Serenity, qué body tiene el POST, qué content-type usa. cy.log imprime el body del request en el command log para documentar la estructura.


Error real: uncaught:exception — cy.on vs Cypress.on

La primera corrida dio 6/7. El test del error 500 falló.

Cypress mostrando 6 passing 1 failing con uncaught exception Service fetch resulted in HTTP 500 error Internal Server Error
Primera corrida: 6/7. Serenity tira una excepción interna con el 500. El handler global solo atrapaba Bad Request

El mensaje: "Service fetch to '/Services/Northwind/Customer/List' resulted in HTTP 500 error: Internal Server Error!". No era un error de Cypress — era un error de JavaScript de Serenity. Cuando la app recibe un 500, tira una excepción interna. Cypress detecta esa excepción y la trata como un fallo del test.

El handler global en e2e.ts no lo atrapó:

Cypress.on('uncaught:exception', (err) => {
    if (err.message.includes('Bad Request')) {
        return false
    }
})

Solo atrapa errores que dicen 'Bad Request'. El 500 dice 'Internal Server Error' — no matchea.

Podría haber ampliado el handler global para atrapar ambos mensajes. Pero no quería. El handler global existe para suprimir errores específicos y conocidos de Serenity. Suprimir todo con return false a nivel global oculta errores legítimos de la app.

La solución: cy.on (con minúscula) dentro del test:

it('error 500 del servidor', () => {
    cy.on('uncaught:exception', () => false)
    // ...
})

cy.on aplica solo al test donde se declara. Cypress.on en e2e.ts es global. En este caso, solo el test de error 500 necesita suprimir excepciones — los demás tests deberían seguir fallando si la app tira errores inesperados.

Scope Sintaxis Dónde va Aplica a
Global Cypress.on('uncaught:exception', ...) e2e.ts Todos los tests
Local cy.on('uncaught:exception', ...) Dentro del it() Solo ese test

Con el cy.on agregado, los 7 tests pasaron.

Cypress Test Runner mostrando 7 tests passing en intercept-stubbing.cy.ts con 4 context blocks todos verdes y grilla con 91 registros
7 de 7 después de agregar cy.on. El último test (validación del request) deja la grilla con los 91 clientes reales

cy.pause() — debug visual

Un detalle técnico del build. Para capturar screenshots de la grilla en cada estado (datos fake, vacía, modificada, error 500), necesitaba pausar la ejecución en el momento exacto.

Cypress tiene cy.pause(). Pausa el test y deja la app en su estado real — con CSS completo, renderizada como la vería un usuario. El botón ▶ en la barra de Cypress reanuda la ejecución.

cy.wait('@stubCustomers')
cy.pause()  // ← pausa acá, screenshot manual, después play
cy.get('.slick-row').should('have.length', 3)

Esto es distinto de hacer click en los pasos del command log. Cuando clickeás un paso pasado, Cypress muestra un "time travel" — una snapshot del DOM que no siempre preserva el CSS. Con cy.pause() la app está viva, el CSS es real.


Comparación con Playwright y Selenium

Concepto Cypress Playwright Selenium
Interceptar requests cy.intercept() page.route() No tiene equivalente nativo
Stub con fixture cy.intercept(url, { fixture: ... }) route.fulfill({ path: ... })
Modificar respuesta req.continue((res) => { ... }) route.fulfill({ response, body })
Simular error HTTP { statusCode: 500, body: ... } route.fulfill({ status: 500 })
Simular corte de red { forceNetworkError: true } route.abort()
Delay en respuesta res.setDelay(ms) Delay manual con setTimeout
Validar request enviado interception.request.body route.request().postData()

Selenium no tiene mecanismo para interceptar tráfico HTTP entre la app y el servidor. Para simular errores de red en Selenium necesitás un proxy externo como BrowserMob Proxy. Cypress y Playwright lo hacen de forma nativa.


Estructura actual del proyecto

cypress-typescript-framework/
├── cypress/
│   ├── e2e/
│   │   ├── auth/
│   │   │   └── login.cy.ts              ← 8 tests
│   │   ├── clientes/
│   │   │   ├── clientes.cy.ts            ← 6 tests
│   │   │   ├── clientes-interacciones.cy.ts ← 7 tests
│   │   │   └── clientes-data.cy.ts       ← 2 tests
│   │   ├── dashboard/
│   │   │   └── assertions.cy.ts          ← 10 tests
│   │   └── intercept/
│   │       └── intercept-stubbing.cy.ts  ← NUEVO (7 tests)
│   ├── fixtures/
│   │   ├── clientes-stub.json            ← NUEVO
│   │   └── users.json
│   ├── pages/
│   │   ├── ClientesPage.ts
│   │   ├── DashboardPage.ts
│   │   └── LoginPage.ts
│   ├── test-data/
│   │   └── clientes-data.xlsx
│   ├── types/
│   │   └── Cliente.ts
│   ├── screenshots/
│   └── support/
│       ├── commands.ts
│       ├── e2e.ts
│       └── index.d.ts
├── cypress.config.ts
├── package.json
├── tsconfig.json
├── .gitignore
└── README.md

Dos archivos nuevos: intercept-stubbing.cy.ts y clientes-stub.json. Ningún archivo existente fue modificado.


Estado actual

40 tests, 38 passing, 2 failing intencionales (del Post 9):

Spec Tests Passing Failing
auth/login.cy.ts 8 8 -
clientes/clientes-data.cy.ts 2 - 2 (intencional)
clientes/clientes-interacciones.cy.ts 7 7 -
clientes/clientes.cy.ts 6 6 -
dashboard/assertions.cy.ts 10 10 -
intercept/intercept-stubbing.cy.ts 7 7 -

Próximo post

cy.session() — cachear autenticación entre tests para no hacer login vía UI en cada uno.