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.

Cursor IDE mostrando cypress.config.ts con configuración profesional y estructura de carpetas por módulo auth y dashboard en el sidebar
cypress.config.ts + estructura por módulos — de proyecto default a configuración profesional

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')
})
Cursor IDE mostrando commands.ts con el custom command cy.login() y la estructura de carpetas auth y dashboard en el sidebar
cy.login() — el comando completo. Una función, un lugar para mantener

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')
})
Cursor IDE mostrando assertions.cy.ts con beforeEach usando cy.login('admin', 'serenity') y la estructura de specs por módulo
beforeEach reducido a una línea: 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>
    }
}
Cursor IDE mostrando index.d.ts con la declaración TypeScript del custom command login en la interfaz Chainable de Cypress
La declaración de tipos para que TypeScript reconozca cy.login()

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.

Cursor IDE mostrando login.cy.ts con el login inline en cada test, sin usar el custom command, testeando la página de login directamente
login.cy.ts no usa cy.login() — estos tests prueban el formulario de login en sí

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',
    },
})
Cursor IDE mostrando cypress.config.ts con configuración profesional: baseUrl, viewport, timeout, retries, video y specPattern
La configuración completa — cada opción tiene un por qué

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.

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

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

screenshotOnRunFailuretrue. 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 title
  • should validate the title using expect()
  • should validate partial text with contain
Terminal mostrando 3 tests fallidos en dashboard/assertions.cy.ts al correr con Electron: 14 tests, 11 passing, 3 failing
3 tests fallaron en headless. Login pasó completo, dashboard no. Los tres validan el texto del h1

Los tres validan el texto del h1. El test esperaba "Tablero". Cypress reportó:

expected <h1> to have text Tablero, but the text was Dashboard
Screenshot de Cypress mostrando error: expected h1 to have text Tablero but the text was Dashboard, con el dashboard renderizado en inglés
expected "Tablero", but the text was "Dashboard" — Electron no envía Accept-Language y Serenity.is renderiza en inglés

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:

  1. Hacer los tests independientes del idioma — cambiar should('have.text', 'Tablero') por algo como should('be.visible'). Pero pierdo el valor de validar el contenido real.
  2. Correr siempre con un browser real.

Elegí la opción 2. Agregué un script en package.json:

"scripts": {
    "cy:run": "cypress run --browser edge"
}
Cursor IDE mostrando package.json con el script cy:run cypress run --browser edge resaltado con flecha verde
La solución: correr siempre con Edge vía script en package.json

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:

Terminal mostrando All specs passed con 14 tests verdes: auth/login.cy.ts 4 passing y dashboard/assertions.cy.ts 10 passing
14 tests, 14 passing — estructura nueva, mismos tests, todo verde

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.