Cypress: configuración profesional, custom commands y estructura por módulos
cypress.config.ts a fondo, cy.login() como custom command, fixtures, specs por módulo. Error real: Electron muestra el dashboard en otro idioma.
Contexto
Este post documenta la configuración profesional del proyecto Cypress + TypeScript.
El post anterior Cypress: assertions, hooks y organización de tests terminó con 10 tests pasando contra el dashboard de Serenity.is. Assertions con should() y expect(), hooks con beforeEach, tests organizados en context() blocks.
Pero la estructura del proyecto era la default de Cypress:
cypress/
├── e2e/
│ ├── assertions.cy.ts
│ └── login.cy.ts
├── fixtures/
│ └── example.json ← el archivo default, sin usar
├── support/
│ ├── commands.ts ← lleno de comentarios de ejemplo
│ └── e2e.ts
Dos specs sueltas en e2e/. El commands.ts tenía 37 líneas de comentarios y cero código real. El login estaba repetido inline en el beforeEach de assertions.cy.ts. Las fixtures eran el example.json que Cypress genera por default.
Funciona, pero no escala. En el momento que agregue specs para Northwind, para la sección de usuarios, para formularios — tener todo suelto en e2e/ se vuelve un desorden.
Este post es llevar eso a una estructura profesional.
Reorganización de specs por módulo
Creé carpetas dentro de e2e/ agrupando por funcionalidad:
cypress/e2e/
├── auth/
│ └── login.cy.ts
├── dashboard/
│ └── assertions.cy.ts
auth/ tiene todo lo relacionado al login. dashboard/ tiene las validaciones del dashboard. Cuando agregue tests para el módulo de Northwind, va a ser e2e/northwind/. Para administración de usuarios, e2e/admin/.
Cypress busca specs recursivamente por default — no hay que configurar nada extra para que las encuentre en subcarpetas. Moví los archivos y corrí los tests. Pasaron sin tocar una línea de código.
En Selenium tenía la misma idea con paquetes Java:
src/test/java/
├── prjs/
│ ├── ventas/
│ ├── clientes/
│ └── presupuestos/
En Playwright lo organicé igual:
tests/
├── auth/
├── dashboard/
├── northwind/
El patrón es el mismo en los tres frameworks: agrupar por módulo de la aplicación, no por tipo de test.
Custom commands — cy.login()
El beforeEach de assertions.cy.ts hacía el login completo inline:
beforeEach(() => {
cy.visit('/Account/Login')
cy.get('#LoginPanel0_Username').should('be.enabled').clear().type('admin')
cy.get('#LoginPanel0_Password').should('be.enabled').clear().type('serenity')
cy.get('#LoginPanel0_LoginButton').click()
cy.url().should('not.include', '/Account/Login')
})
Cinco líneas repetidas en cada spec que necesite estar logueado. Si mañana Serenity.is cambia un selector del formulario de login, tengo que ir archivo por archivo actualizando.
Los custom commands de Cypress resuelven esto. Extraje el login a cypress/support/commands.ts:
/// <reference types="cypress" />
Cypress.Commands.add('login', (username: string, password: string) => {
cy.visit('/Account/Login')
cy.get('#LoginPanel0_Username').should('be.enabled').clear().type(username)
cy.get('#LoginPanel0_Password').should('be.enabled').clear().type(password)
cy.get('#LoginPanel0_LoginButton').click()
cy.url().should('not.include', '/Account/Login')
})

Borré todos los comentarios de ejemplo que venían por default. Un archivo limpio con un comando real.
Ahora el beforeEach en assertions.cy.ts es una línea:
beforeEach(() => {
cy.login('admin', 'serenity')
})

TypeScript necesita saber que cy.login() existe
Sin un paso extra, TypeScript marca cy.login() con error: la propiedad login no existe en Chainable. Cypress está escrito en TypeScript, pero los custom commands son dinámicos — hay que declararlos.
Creé cypress/support/index.d.ts:
declare namespace Cypress {
interface Chainable {
login(username: string, password: string): Chainable<void>
}
}

Chainable<void> significa que el comando se puede encadenar (como cualquier comando de Cypress) pero no devuelve un valor — solo ejecuta el login.
Con esto TypeScript reconoce cy.login() y el autocompletado en Cursor funciona.
Equivalentes en otros frameworks
En Selenium no hay custom commands. Lo que hacía era crear métodos en las clases del Page Object Model — un LoginPage.login(user, pass). La lógica es parecida: centralizar el login en un solo lugar.
En Playwright usaba fixtures personalizadas con test.extend() para inyectar el login. Distinto mecanismo, mismo objetivo.
Cypress tiene esta capa intermedia de custom commands que no es POM pero cumple la función de reutilizar acciones. POM viene después (próximo post).
Qué pasa con login.cy.ts
login.cy.ts no usa cy.login(). Los tests de ese archivo están probando la página de login en sí — que el formulario carga, que los elementos existen, que el login funciona con credenciales válidas. Necesitan el control completo del flujo, no un atajo.

El custom command es para specs que necesitan estar logueadas como precondición. No para testear el login.
Fixtures — datos de test centralizados
Cypress genera un example.json en fixtures/ que no sirve para nada. Lo borré y creé users.json:
{
"admin": {
"username": "admin",
"password": "serenity"
}
}
Las fixtures en Cypress son archivos JSON que se cargan con cy.fixture(). La idea es no tener datos hardcodeados en los tests.
Todavía no las estoy usando en el beforeEach porque el custom command cy.login('admin', 'serenity') es suficiente por ahora. Cuando tenga tests con múltiples usuarios, con datos de formularios, con escenarios de datos variados — ahí las fixtures van a tener más peso.
Lo que hice ahora fue preparar la estructura. Tener un users.json real en vez del example.json default.
En Selenium usaba DataProviders de TestNG con Excel para alimentar datos a los tests. En Playwright usaba archivos JSON directamente o test.describe con arrays de datos. Cypress con fixtures es más parecido al enfoque de Playwright.
cypress.config.ts a fondo
En el Post anterior el config tenía solo baseUrl y viewport. Ahora tiene todo lo que un proyecto profesional necesita:
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'https://demo.serenity.is',
viewportWidth: 1280,
viewportHeight: 720,
defaultCommandTimeout: 10000,
retries: {
runMode: 2,
openMode: 0
},
video: false,
screenshotOnRunFailure: true,
specPattern: 'cypress/e2e/**/*.cy.ts',
},
})

Cada opción:
baseUrl — La URL base de la aplicación. Todos los cy.visit('/Account/Login') se resuelven contra esta URL. Si cambio de ambiente (staging, producción), cambio solo esta línea.
viewportWidth / viewportHeight — 1280×720, resolución estándar desktop. Lo expliqué en el Post 3: el dashboard de Serenity.is se rompía con el default de 1000×660.
defaultCommandTimeout — 10000ms (10 segundos). El default de Cypress es 4000ms. Serenity.is a veces tarda en cargar elementos. Con 4 segundos, algunos tests fallaban intermitentemente. Con 10, tengo margen sin afectar la velocidad de ejecución cuando todo anda bien. Cypress no espera los 10 segundos completos — si el elemento aparece en el segundo 2, continúa.
retries — runMode: 2 significa que en ejecución headless (cypress run), si un test falla, Cypress lo reintenta hasta 2 veces más antes de marcarlo como fallido. openMode: 0 significa que en modo interactivo (cypress open) no reintenta — quiero ver el fallo inmediatamente para debuggear. Esto es útil para tests que fallan por timing, no por bugs reales. En Selenium no existe esto nativo — hay que configurarlo con TestNG listeners o plugins de retry.
video — false. Cypress graba video de cada ejecución por default. No lo necesito ahora y genera archivos pesados. Si en el futuro necesito debugging visual en CI/CD, lo activo.
screenshotOnRunFailure — true. Cuando un test falla en headless, Cypress captura un screenshot automático. Esto fue lo que me permitió ver que Electron mostraba "Dashboard" en vez de "Tablero" (más sobre esto abajo).
specPattern — 'cypress/e2e/**/*.cy.ts'. Le dice a Cypress dónde buscar specs. El ** es recursivo — encuentra specs en subcarpetas (auth/, dashboard/, lo que venga). Este es el default, pero lo declaro explícito para que quede claro en el config.
El error: Electron vs Edge y el idioma del dashboard
Después de reorganizar todo, corrí npx cypress run. Los 4 tests de login pasaron. Pero 3 de los 10 tests de dashboard fallaron:
should validate the dashboard titleshould validate the title using expect()should validate partial text with contain

Los tres validan el texto del h1. El test esperaba "Tablero". Cypress reportó:
expected <h1> to have text Tablero, but the text was Dashboard

expected <h1> to have text Tablero, but the text was Dashboard — el dashboard completo renderizado en inglés
El sidebar también estaba en inglés: "Basic Samples", "Advanced Samples", "Dashboard" en vez de "Tablero", "Muestras básicas", "Muestras avanzadas".
Pero en cypress open con Edge, todo pasaba. "Tablero" aparecía correctamente.
Por qué pasa
cypress run usa Electron por default — un browser embebido sin configuración de idioma del sistema. Serenity.is detecta el idioma del browser a través de las cabeceras HTTP (Accept-Language). Electron no envía español, entonces Serenity.is renderiza en inglés.
Edge sí toma el idioma del sistema operativo (mi Windows está en español), entonces manda es en las cabeceras y Serenity.is muestra "Tablero".
La solución
Dos opciones:
- Hacer los tests independientes del idioma — cambiar
should('have.text', 'Tablero')por algo comoshould('be.visible'). Pero pierdo el valor de validar el contenido real. - Correr siempre con un browser real.
Elegí la opción 2. Agregué un script en package.json:
"scripts": {
"cy:run": "cypress run --browser edge"
}

Corrí npm run cy:run. Los 14 tests pasaron.
Los tests validan contenido real del dashboard — eso tiene valor. Que Electron no maneje idioma es un detalle de infraestructura, no un problema de los tests.
Lo que me enseñó este error
Un test puede pasar en open y fallar en run. Son contextos diferentes: browser real vs Electron, con cabeceras distintas, rendering distinto. screenshotOnRunFailure: true en el config fue lo que me permitió ver el problema — sin ese screenshot, habría estado adivinando por qué fallaba.
.gitignore — lo específico de Cypress
El .gitignore del proyecto venía con el template estándar de Node.js. Agregué las líneas específicas de Cypress:
cypress/videos/
cypress/screenshots/
cypress/downloads/
videos/ — los recordings de ejecución (aunque los desactivé en el config, la carpeta puede crearse). screenshots/ — las capturas de fallos. No van al repo. downloads/ — archivos que Cypress descarga durante tests.
Son archivos generados, no código. No van al repositorio.
package.json — scripts de ejecución
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"cy:run": "cypress run --browser edge"
}
cy:run ejecuta todos los tests en Edge headless. Un comando, todo corre.
Más adelante puedo agregar más scripts: cy:open para modo interactivo, cy:run:auth para correr solo los tests de auth, cy:run:dashboard para solo dashboard. Pero por ahora uno alcanza.
Estructura final del proyecto
Antes (Post Cypress: assertions, hooks y organización de tests):
cypress-typescript-framework/
├── cypress/
│ ├── e2e/
│ │ ├── assertions.cy.ts
│ │ └── login.cy.ts
│ ├── fixtures/
│ │ └── example.json
│ └── support/
│ ├── commands.ts ← 37 líneas de comentarios
│ └── e2e.ts
├── cypress.config.ts ← solo baseUrl y viewport
├── package.json
└── tsconfig.json
Después (Post actual):
cypress-typescript-framework/
├── cypress/
│ ├── e2e/
│ │ ├── auth/
│ │ │ └── login.cy.ts
│ │ └── dashboard/
│ │ └── assertions.cy.ts
│ ├── fixtures/
│ │ └── users.json
│ └── support/
│ ├── commands.ts ← cy.login() real
│ ├── e2e.ts
│ └── index.d.ts ← tipos TypeScript
├── cypress.config.ts ← config profesional completa
├── package.json ← script cy:run con --browser edge
├── .gitignore ← con líneas de Cypress
└── tsconfig.json
Cada archivo tiene un propósito claro. No hay código muerto, no hay defaults sin tocar.
Estado actual
14 tests pasando con la estructura profesional:

14 tests, 14 passing — auth/login.cy.ts (4) + dashboard/assertions.cy.ts (10)
Resumen de lo que cambió:
| Qué | Antes | Después |
|---|---|---|
| Specs | sueltas en e2e/ |
organizadas en e2e/auth/, e2e/dashboard/ |
| Login | 5 líneas inline en beforeEach |
cy.login('admin', 'serenity') — una línea |
| Fixtures | example.json default |
users.json con datos reales |
| Config | baseUrl + viewport |
timeout, retries, video, screenshots, specPattern |
| Commands | 37 líneas de comentarios | comando real cy.login() |
| TypeScript | sin tipos custom | index.d.ts con declaración de login |
| Ejecución | npx cypress run (Electron) |
npm run cy:run (Edge) |
| .gitignore | genérico Node.js | + videos, screenshots, downloads de Cypress |
No agregué tests nuevos. Solo configuré bien lo que ya existía para que escale.
Próximo post:
Page Object Model. Crear clases de página, mover selectores y acciones fuera de los specs, y refactorear los tests para que usen las pages.