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.
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.

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.

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".

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.

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ó.

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.

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.