Cypress: cy.session() — login una vez, tests sin login
cy.session() cachea autenticación. De 2:49 a 2:10. Tres errores reales: about:blank, idioma en inglés, textos hardcodeados. Proceso completo.
El problema
Hasta ahora, cada test hace login vía UI. Los 32 tests que usan cy.login() ejecutan la misma secuencia: visitar /Account/Login, llenar usuario, llenar contraseña, clickear botón, esperar redirección. Eso son 5 acciones por test, 32 veces, contra el servidor, con tiempos de carga.
El login no es lo que estoy testeando. Es un prerrequisito. Y repetirlo en cada test es tiempo desperdiciado.
Pero el tiempo no es el único problema. El login vía UI es una de las partes más frágiles de la suite. Si la página de login falla, si un selector cambia, si Serenity tira un error JavaScript — fallan TODOS los tests, no solo los de login. Un bug en la pantalla de login me rompe la suite entera.
cy.session() resuelve esto: hace login una vez, cachea cookies y localStorage, y los siguientes tests restauran ese cache sin tocar la UI de login.
Baseline: 2:49 con login vía UI
Antes de cambiar nada, corrí la suite completa en headless para tener el número de referencia:
npx cypress run --browser edge

| Spec | Tiempo | Tests |
|---|---|---|
| auth/login.cy.ts | 0:19 | 8 |
| clientes/clientes-data.cy.ts | 0:29 | 2 |
| clientes/clientes-interacciones.cy.ts | 0:36 | 7 |
| clientes/clientes.cy.ts | 0:25 | 6 |
| dashboard/assertions.cy.ts | 0:27 | 10 |
| intercept/intercept-stubbing.cy.ts | 0:31 | 7 |
| Total | 2:49 | 40 |
Cómo funciona cy.session
El flujo:
1. Primer test llama cy.login()
2. cy.session() corre el setup: login vía UI, cachea cookies
3. Segundo test llama cy.login()
4. cy.session() detecta que ya tiene cache → restaura cookies sin login
5. Todos los tests siguientes: restauran cache
No hay archivo JSON intermedio como en Playwright (storageState). Cypress mantiene el cache en memoria durante la corrida. El cache vive mientras dure cypress open o cypress run.
La diferencia con Playwright: storageState guarda las cookies en un archivo .auth/user.json y cada test las carga desde ahí. Cypress no necesita archivo — cy.session() cachea internamente con un ID (en mi caso 'default-user').
El login anterior: commands.ts sin cy.session
Este era el cy.login() que usaba desde el Post 4 (Cypress: configuración profesional, custom commands y estructura por módulos):
Cypress.Commands.add('login', (username?: string, password?: string) => {
const doLogin = (user: string, pass: string) => {
cy.visit('/Account/Login')
cy.get('#LoginPanel0_Username').should('be.enabled').clear().type(user)
cy.get('#LoginPanel0_Password').should('be.enabled').clear().type(pass)
cy.get('#LoginPanel0_LoginButton').click()
cy.url().should('not.include', '/Account/Login')
}
if (username && password) {
doLogin(username, password)
} else {
cy.fixture('users').then((users) => {
doLogin(users.validUser.username, users.validUser.password)
})
}
})
Sin parámetros: lee credenciales del fixture y hace login completo. Con parámetros: usa las credenciales que le pases. En ambos casos, login vía UI en cada llamada.
Primera implementación: cy.session en commands.ts
Wrappear el login dentro de cy.session():
Cypress.Commands.add('login', (username?: string, password?: string) => {
const doLogin = (user: string, pass: string) => {
cy.visit('/Account/Login')
cy.get('#LoginPanel0_Username').should('be.enabled').clear().type(user)
cy.get('#LoginPanel0_Password').should('be.enabled').clear().type(pass)
cy.get('#LoginPanel0_LoginButton').click()
cy.url().should('not.include', '/Account/Login')
}
if (username && password) {
doLogin(username, password)
} else {
cy.fixture('users').then((users) => {
cy.session('default-user', () => {
doLogin(users.validUser.username, users.validUser.password)
}, {
validate() {
cy.visit('/')
cy.url().should('not.include', '/Account/Login')
}
})
})
}
})
La lógica: si se llama sin parámetros (el caso de los 32 tests), cy.session() cachea las cookies después del primer login. Los siguientes tests restauran el cache. Si se llama con parámetros (caso hipotético), hace login normal sin cachear.
El validate verifica que la sesión sigue viva — visita la raíz y chequea que no redirige a login.
Corrí la suite. Resultado: desastre.

19 tests fallando, 11 skipped. El tiempo subió de 2:49 a 11:44. Solo login.cy.ts pasó — tiene sentido porque no usa cy.login().
Error 1: about:blank — el browser queda en la nada
Abrí el runner para ver qué estaba pasando.

El error: Timed out retrying after 10000ms: Expected to find content: 'Clientes' within the element: <ul#s_sidebar_menu1.s-sidebar-menu>.
irAClientes() busca el texto "Clientes" en el sidebar, pero el sidebar dice "Customers". Toda la UI está en inglés.
Esto es un problema de cy.session(). Cuando restaura las cookies cacheadas, limpia la página y la deja en about:blank. El cy.login() viejo terminaba con el browser en el dashboard. Con cy.session(), después de restaurar, el browser está en una página en blanco. Los tests esperaban estar en el dashboard.
El validate con cy.visit('/') ayudaba a navegar, pero la validación no deja el browser en esa página — cy.session() vuelve a limpiar después de validar.
Primer fix: mover cy.visit('/') fuera del validate, y cambiar validate para que solo chequee una cookie sin navegar:
cy.fixture('users').then((users) => {
cy.session('default-user', () => {
doLogin(users.validUser.username, users.validUser.password)
}, {
validate() {
cy.getCookie('.AspNetAuth').should('exist')
}
})
cy.visit('/')
})
validate ahora chequea que la cookie .AspNetAuth existe — rápido, sin navegación. cy.visit('/') va DESPUÉS de cy.session(), así el browser queda en el dashboard como antes.
Esto resolvió el about:blank, pero no el idioma.
Error 2: la UI carga en inglés
Con el fix de about:blank, los tests que no dependían del idioma empezaron a pasar. Pero los que buscaban texto en español seguían fallando.

expected '<h1>' to have text 'Tablero', but the text was 'Dashboard'.
Antes de cy.session(), el login vía UI pasaba por cy.visit('/Account/Login') en cada test. Serenity detectaba la preferencia de idioma a través de cookies o del estado del browser que se mantenía entre tests. cy.session() limpia TODAS las cookies antes de correr el setup — la preferencia de idioma se pierde.
Probé agregar --lang=es en los argumentos de lanzamiento del browser en cypress.config.ts:
on('before:browser:launch', (browser, launchOptions) => {
if (browser.family === 'chromium') {
launchOptions.args.push('--lang=es')
}
return launchOptions
})
Funcionó parcialmente. La página de login cargaba en español, pero después de autenticarse, la UI volvía a inglés. Serenity.is usa una preferencia de idioma del usuario autenticado que no respeta el flag del browser.
El fix definitivo: forzar Accept-Language: es en cada request HTTP vía cy.intercept en e2e.ts:
import './commands'
beforeEach(() => {
cy.intercept('**', (req) => {
req.headers['accept-language'] = 'es-AR,es;q=0.9'
})
})
Cypress.on('uncaught:exception', (err) => {
if (err.message.includes('Bad Request')) {
return false
}
})
El beforeEach registra el intercept antes de que el spec llame cy.login(). Cuando cy.visit('/') se ejecuta después del session, todos los requests van con Accept-Language: es. No interfiere con los intercepts de intercept-stubbing.cy.ts porque no llama req.continue() ni req.reply() — solo modifica el header y deja pasar el request.

10/10 en dashboard. Todo en español.
Error 3: textos hardcodeados en inglés en login.cy.ts
Con el idioma resuelto, corrí la suite completa. 36 passing, 4 failing. Los 2 nuevos fallos venían de login.cy.ts — el context "cypress features":

Los tests buscaban cy.contains('Sign In') y cy.contains('Forgot password?'). Ahora la página dice "Iniciar sesión" y "¿Olvidaste tu contraseña?".
El fix: actualizar los textos hardcodeados:
it('should find the login button by text', () => {
loginPage.visit()
cy.contains('Iniciar sesión').should('be.visible')
cy.contains('¿Olvidaste tu contraseña?').should('be.visible')
cy.contains('Google').should('be.visible')
})
it('should find elements within the login form', () => {
loginPage.visit()
cy.get('#LoginPanel').within(() => {
cy.get('input[name="Username"]').should('exist')
cy.get('input[name="Password"]').should('exist')
cy.get('button[type="submit"]').should('be.visible')
})
cy.get('#LoginPanel').find('button[type="submit"]').should('contain', 'Iniciar sesión')
})
Efecto colateral real. Forzar el idioma vía header es la solución correcta, pero obliga a actualizar tests que hardcodeaban texto en el idioma anterior.

Resultado final: de 2:49 a 2:10
Corrí la suite completa en headless:
npx cypress run --browser edge

| Spec | Antes | Después | Diferencia |
|---|---|---|---|
| auth/login.cy.ts | 0:19 | 0:19 | — |
| clientes/clientes-data.cy.ts | 0:29 | 0:23 | -6s |
| clientes/clientes-interacciones.cy.ts | 0:36 | 0:28 | -8s |
| clientes/clientes.cy.ts | 0:25 | 0:21 | -4s |
| dashboard/assertions.cy.ts | 0:27 | 0:15 | -12s |
| intercept/intercept-stubbing.cy.ts | 0:31 | 0:22 | -9s |
| Total | 2:49 | 2:10 | -39s (23%) |
login.cy.ts no cambió — no usa cy.login(), hace login por su cuenta. dashboard/assertions.cy.ts fue el que más ganó: de 27s a 15s, casi la mitad. Tiene sentido — 10 tests que antes hacían login vía UI cada uno, ahora restauran cookies.
Con 40 tests el ahorro es 39 segundos. Con 100 tests la diferencia sería mucho más notoria. Y más importante que el tiempo: la suite ya no depende de que la UI de login funcione en cada test. Si login se rompe, solo los tests de login fallan.
Qué gana el framework con esto
Estabilidad. El login vía UI era el punto más frágil de la suite. Si la página de login tardaba, si un selector cambiaba, si Serenity tiraba un error JavaScript durante el login — fallaban los 32 tests que usan cy.login(). No porque los tests estuvieran mal, sino porque el prerequisito falló. Con cy.session(), el login vía UI corre una vez. Si esa vez falla, falla rápido y claro. Los 31 tests restantes restauran cookies — no tocan la UI de login.
Aislamiento. cy.session() limpia cookies, localStorage y el DOM entre tests, y después restaura solo la sesión cacheada. Cada test arranca desde un estado de auth conocido y limpio. No arrastra basura del test anterior.
Separación de concerns. Los tests de Clientes testean Clientes, no login. Los tests de dashboard testean dashboard, no login. Si el login se rompe, solo login.cy.ts debería fallar. Antes eso no pasaba — un bug en login rompía todo.
Escalabilidad. Con 40 tests el ahorro es de 39 segundos. En CI/CD, donde cada minuto de pipeline es costo, la diferencia escala. Y si la suite crece a 100+ tests, el ahorro crece proporcionalmente.
Múltiples roles. Si en el futuro necesito testear con distintos usuarios (admin, read-only, editor), cy.session() permite cachear una sesión por usuario con IDs distintos y switchear entre ellas sin repetir logins. Hoy uso un solo ID ('default-user'), pero la arquitectura ya soporta más.
Diferencia con Playwright y Selenium
En la serie de Playwright implementé lo mismo con storageState. La diferencia principal es el mecanismo:
| Concepto | Cypress | Playwright | Selenium |
|---|---|---|---|
| Mecanismo | cy.session() — cache en memoria |
storageState — archivo JSON |
No tiene equivalente nativo |
| Guardar sesión | Automático con ID | page.context().storageState({ path }) |
getCookies() + serialización manual |
| Restaurar sesión | Automático por ID | storageState en config |
Loop addCookie() + refresh |
| Archivo externo | No necesita | .auth/user.json |
Custom (Gson/Jackson) |
| Validación | validate() callback |
No tiene (confía en el archivo) | Custom |
| Scope | Por corrida (open/run) | Por proyecto en config | Custom con @BeforeSuite |
| Dependencias extra | Ninguna | Ninguna | Gson o Jackson |
Playwright delega en un archivo. Cypress delega en su cache interno. Selenium no tiene nada — tendrías que escribir todo el mecanismo de serialización y restauración de cookies a mano.
En Playwright tuve un problema diferente: los tests existentes hacían doble login — storageState ya inyectaba cookies, y después el beforeEach volvía a hacer login encima. Funcionaba pero era redundante. En Cypress el problema fue más severo — cy.session() limpia la página y las cookies de idioma, lo que rompió la suite completa.
commands.ts — versión final
/// <reference types="cypress" />
Cypress.Commands.add('login', (username?: string, password?: string) => {
const doLogin = (user: string, pass: string) => {
cy.visit('/Account/Login')
cy.get('#LoginPanel0_Username').should('be.enabled').clear().type(user)
cy.get('#LoginPanel0_Password').should('be.enabled').clear().type(pass)
cy.get('#LoginPanel0_LoginButton').click()
cy.url().should('not.include', '/Account/Login')
}
if (username && password) {
doLogin(username, password)
} else {
cy.fixture('users').then((users) => {
cy.session('default-user', () => {
doLogin(users.validUser.username, users.validUser.password)
}, {
validate() {
cy.getCookie('.AspNetAuth').should('exist')
}
})
cy.visit('/')
})
}
})
Archivos modificados
Tres archivos, ninguno nuevo:
| Archivo | Cambio |
|---|---|
cypress/support/commands.ts | cy.login() refactorizado con cy.session() |
cypress/support/e2e.ts | beforeEach con cy.intercept para Accept-Language: es |
cypress/e2e/auth/login.cy.ts | Textos "Sign In" → "Iniciar sesión" en 2 tests |
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
Escenarios complejos de UI: iframes, Shadow DOM, file upload, drag and drop.
🔗 Todo el código de esta serie está en: github.com/cesarbeassuarez/cypress-typescript-framework