Cypress: data-driven testing, fixtures y dos formas de cargar datos

Data-driven en login con forEach. import JSON para tests dinámicos, cy.fixture() para datos en runtime. Refactoring de login.cy.ts y commands.ts.

Split view en Cursor con users.json a la izquierda mostrando invalidCredentials y login.cy.ts a la derecha con forEach data-driven generando tests desde el fixture
users.json + login.cy.ts — los datos del fixture alimentan el forEach que genera tests dinámicos

Contexto

Este post documenta la implementación de data-driven testing en el proyecto Cypress + TypeScript.

El post anterior Cypress: Page Object Model, 3 pages y 2 errores reales contra Serenity.is terminó con 18 tests pasando, 3 pages y selectores centralizados. Pero los datos de test siguen hardcodeados:

it('should login with valid credentials', () => {
    loginPage.visit()
    loginPage.login('admin', 'serenity')
    cy.url().should('not.include', '/Account/Login')
})

it('should show error with wrong password', () => {
    loginPage.visit()
    loginPage.login('admin', 'wrongpassword')
    loginPage.getErrorMessage().should('contain', 'Nombre de usuario o contraseña inválidos')
})

'admin', 'serenity', 'wrongpassword' repetidos inline en cada test. Y cy.login('admin', 'serenity') hardcodeado en el beforeEach de assertions.cy.ts.

El archivo users.json existe en fixtures/ desde el Post 4, pero no se usa en ningún test. Hora de usarlo.


El plan: dos refactorings, dos mecanismos

Cypress tiene dos formas de cargar datos externos. No son intercambiables — cada una sirve para algo distinto:

Concepto import JSON cy.fixture()
Cuándo carga Compilación (build time) Ejecución (runtime)
Genera it() dinámicos Sí (forEach) No
Async No Sí (.then())
Requiere tsconfig resolveJsonModule: true No
Uso ideal Tests data-driven (forEach) Datos dentro de tests/hooks existentes

import — los datos están disponibles antes de que Cypress registre los tests. Puedo usarlos en un forEach para generar it() dinámicos. Es lo que necesito para login negativo.

cy.fixture() — carga datos dentro de un it() o beforeEach() que ya existe. No puedo usarlo para generar tests porque es async — Cypress necesita conocer todos los it() antes de ejecutar.

Este post usa los dos: import en login.cy.ts para data-driven, cy.fixture() en commands.ts para el custom command.


users.json — estructura centralizada

Reestructuré users.json para cubrir los dos casos:

{
  "validUser": {
    "username": "admin",
    "password": "serenity"
  },
  "invalidCredentials": [
    {
      "caso": "Password incorrecta",
      "username": "admin",
      "password": "wrongpassword",
      "expectedError": "Nombre de usuario o contraseña inválidos"
    },
    {
      "caso": "Usuario incorrecto",
      "username": "wronguser",
      "password": "serenity",
      "expectedError": "Nombre de usuario o contraseña inválidos"
    }
  ]
}
Cursor IDE mostrando users.json con objeto validUser con username y password, y array invalidCredentials con dos objetos con caso, username, password y expectedError
users.json — un objeto para login válido, un array para casos inválidos. Cada entrada con su error esperado

users.json — un objeto para login válido, un array para casos inválidos. Cada entrada con su error esperado

validUser es un objeto simple — lo usa cy.login() cuando no recibe argumentos. invalidCredentials es un array — lo recorre forEach para generar un it() por cada caso. Si mañana necesito agregar "password vacía" o "usuario con caracteres especiales", agrego una entrada al array y el test se genera solo.


Refactoring de login.cy.ts — import + forEach

Antes: 8 tests planos, datos hardcodeados, sin organización.

Después:

import { LoginPage } from '../../pages/LoginPage'
import users from '../../fixtures/users.json'

describe('Serenity.is Login Page', () => {
    const loginPage = new LoginPage()

    // ===== LOGIN POSITIVO =====
    context('login positivo', () => {

        it('should load the login page', () => {
            loginPage.visit()
            cy.get('#LoginPanel').should('be.visible')
        })

        it('should login with valid credentials', () => {
            loginPage.visit()
            loginPage.login(users.validUser.username, users.validUser.password)
            cy.url().should('not.include', '/Account/Login')
        })

        it('should login with pre-loaded credentials', () => {
            loginPage.visit()
            loginPage.clickLogin()
            cy.url().should('not.include', '/Account/Login')
        })

    })

    // ===== LOGIN NEGATIVO — DATA-DRIVEN =====
    context('login negativo — data-driven', () => {

        users.invalidCredentials.forEach((datos) => {
            it(`login inválido: ${datos.caso}`, () => {
                loginPage.visit()
                loginPage.login(datos.username, datos.password)
                loginPage.getErrorMessage().should('contain', datos.expectedError)
            })
        })

    })

    // ===== EMPTY FIELDS (caso especial) =====
    context('empty fields', () => {

        it('should show error with empty fields', () => {
            loginPage.visit()
            cy.get('#LoginPanel0_Username').invoke('removeAttr', 'required')
            cy.get('#LoginPanel0_Password').invoke('removeAttr', 'required')
            cy.get('#LoginPanel0_Username').invoke('val', '').trigger('input').trigger('change')
            cy.get('#LoginPanel0_Password').invoke('val', '').trigger('input').trigger('change')
            loginPage.clickLogin()
            loginPage.getErrorMessage().should('be.visible')
        })

    })

    // ===== CYPRESS FEATURES (didácticos) =====
    context('cypress features', () => {

        it('should find the login button by text', () => {
            loginPage.visit()
            cy.contains('Sign In').should('be.visible')
            cy.contains('Forgot password?').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', 'Sign In')
        })

    })

})
Cursor IDE mostrando login.cy.ts con import de users.json, context login positivo con 3 tests, context login negativo data-driven con forEach sobre invalidCredentials
login.cy.ts — import del fixture arriba, forEach generando tests dinámicos en el bloque data-driven

login.cy.ts — 4 context blocks. El bloque data-driven genera tests desde el fixture con forEach

Tres cambios principales:

1. import users from '../../fixtures/users.json' — carga el JSON en compilación. Los datos están disponibles inmediatamente para generar tests.

2. users.invalidCredentials.forEach() — recorre el array y genera un it() por cada entrada. Si agrego un tercer caso al array, aparece un tercer test automáticamente. No toco el spec.

3. context() blocks — organizan los 8 tests en 4 grupos: login positivo (3), login negativo data-driven (2), empty fields (1), cypress features (2). En el Test Runner se ven agrupados, más fácil de leer.

tsconfig.json — resolveJsonModule

Para que import users from '../../fixtures/users.json' funcione, TypeScript necesita permiso para importar archivos JSON. En tsconfig.json:

{
  "compilerOptions": {
    "resolveJsonModule": true
  }
}

Sin esto, TypeScript tira error de compilación. Es un cambio de una línea pero si no lo sabés, te frena.

Por qué empty fields queda separado

El test de campos vacíos no entra en el array data-driven por dos razones:

cy.type('') falla con string vacío. Cypress lanza error si intentás tipear un string vacío. El test necesita el hack de invoke('removeAttr', 'required') + invoke('val', '') para vaciar los campos — un flujo diferente al de loginPage.login().

El error esperado depende del idioma del browser. "Por favor, valide los campos vacíos..." en Edge, "Please validate empty or invalid inputs..." en Electron. Por eso valido con should('be.visible') en vez de validar texto. No encaja en un campo expectedError del fixture.

No fuerzo un caso especial en un patrón donde no entra. Lo dejo separado.


Refactoring de commands.ts — cy.fixture() para datos en runtime

El otro refactoring: cy.login() en commands.ts.

Antes:

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')
})

Requería siempre cy.login('admin', 'serenity'). Datos hardcodeados en cada beforeEach que lo usa.

Después:

/// <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) => {
            doLogin(users.validUser.username, users.validUser.password)
        })
    }
})
Cursor IDE mostrando commands.ts con función doLogin interna, condicional if/else y cy.fixture users como fallback cuando no recibe argumentos
commands.ts — con argumentos usa los valores recibidos, sin argumentos lee de users.json con cy.fixture()

commands.ts — cy.login() con fallback a fixture. Con argumentos usa los valores recibidos, sin argumentos lee de users.json

Dos paths:

Con argumentoscy.login('admin', 'serenity') — usa los valores recibidos directamente. Útil si algún test necesita credenciales específicas.

Sin argumentoscy.login() — carga users.json con cy.fixture() y usa validUser. No necesita import porque cy.fixture() funciona dentro de la ejecución del command.

Acá uso cy.fixture() y no import porque el custom command no necesita generar tests dinámicos. Solo necesita datos en runtime para hacer login. cy.fixture() es el mecanismo correcto para este caso.

index.d.ts — parámetros opcionales

Para que TypeScript acepte cy.login() sin argumentos, los tipos necesitan el ?:

declare namespace Cypress {
    interface Chainable {
        login(username?: string, password?: string): Chainable<void>
    }
}
Cursor IDE mostrando index.d.ts con declare namespace Cypress, interface Chainable y método login con username y password opcionales
index.d.ts — parámetros opcionales con ?. TypeScript acepta cy.login() y cy.login('admin', 'serenity')

index.d.ts — username? y password? opcionales. TypeScript acepta cy.login() y cy.login('admin', 'serenity')

assertions.cy.ts — cy.login() sin argumentos

Con el refactoring, assertions.cy.ts queda más limpio:

beforeEach(() => {
    cy.login()
})
Cursor IDE mostrando assertions.cy.ts con beforeEach usando cy.login() sin argumentos, DashboardPage importado y context page structure
assertions.cy.ts — cy.login() sin argumentos. El custom command lee las credenciales del fixture

Sin datos hardcodeados. El custom command lee del fixture. Si mañana cambian las credenciales de Serenity.is, actualizo users.json y todos los tests que usan cy.login() se actualizan automáticamente.


Comparación con Playwright

En mi serie de Playwright, el data-driven de login se veía así:

const credencialesInvalidas = [
  {
    caso: 'Password incorrecta',
    usuario: 'admin',
    password: 'mal',
    mensajeEsperado: 'Error de validación: ¡Nombre de usuario o contraseña inválidos!',
  },
  // ...
];

for (const datos of credencialesInvalidas) {
    test(`login inválido: ${datos.caso}`, async ({ loginPage }) => {
        await loginPage.goto();
        await loginPage.login(datos.usuario, datos.password);
        const mensajeReal = await loginPage.obtenerMensajeError();
        expect(mensajeReal).toContain(datos.mensajeEsperado);
    });
}
Código Playwright mostrando array credencialesInvalidas con 3 casos inline y bloque for of generando tests dinámicos con test.describe login negativo
Data-driven en Playwright — datos inline en el spec con for...of. En Cypress están externalizados en users.json con forEach

La diferencia principal: en Playwright definí el array inline en el spec. En Cypress lo externalicé a un archivo fixture.

Las dos formas son válidas en ambos frameworks. Pero Cypress tiene la convención de fixtures/ como carpeta dedicada para datos de test, y la resolución de JSON con import es directa. Separar los datos del spec es más escalable — si el mismo users.json lo necesitan varios specs, está en un solo lugar.

El patrón es el mismo: un array de casos, una iteración, un it()/test() por caso. La implementación cambia, el concepto no.


Estructura actual del proyecto

cypress-typescript-framework/
├── cypress/
│   ├── e2e/
│   │   ├── auth/
│   │   │   └── login.cy.ts          ← import users.json + forEach (8 tests)
│   │   └── dashboard/
│   │       └── assertions.cy.ts     ← cy.login() sin argumentos (10 tests)
│   ├── fixtures/
│   │   └── users.json               ← ACTUALIZADO (validUser + invalidCredentials)
│   ├── pages/
│   │   ├── LoginPage.ts
│   │   ├── DashboardPage.ts
│   │   └── ClientesPage.ts
│   ├── screenshots/
│   └── support/
│       ├── commands.ts               ← ACTUALIZADO (cy.fixture() fallback)
│       ├── e2e.ts
│       └── index.d.ts               ← ACTUALIZADO (params opcionales)
├── cypress.config.ts
├── package.json
├── tsconfig.json                     ← resolveJsonModule: true
├── .gitignore
└── README.md

Tres archivos cambiaron, uno se agregó a la configuración:

  1. users.json — de placeholder a estructura real con datos de test.
  2. login.cy.ts — de datos hardcodeados a import + forEach + context blocks.
  3. commands.ts — de argumentos obligatorios a cy.fixture() fallback.
  4. index.d.ts — parámetros opcionales.
  5. tsconfig.json — resolveJsonModule habilitado.

Estado actual

18 tests pasando con datos externalizados:

Terminal mostrando All specs passed con 18 tests verdes, dashboard assertions.cy.ts 10 passing y auth login.cy.ts 8 passing en 56 segundos
18 tests, 18 passing — assertions con cy.login() sin argumentos, login con datos del fixture

18 tests, 18 passing — login con datos del fixture, assertions con cy.login() sin argumentos

Cypress Test Runner mostrando 8 tests verdes en 4 context blocks: login positivo, login negativo data-driven, empty fields y cypress features
8 tests organizados en 4 contexts — los nombres del bloque data-driven se generan desde el campo caso del fixture

8 tests organizados en 4 contexts — el bloque data-driven se genera desde users.json

Qué Antes (Post 5) Después (Post 6)
Datos de login hardcodeados en cada test centralizados en users.json
Tests negativos it() individuales manuales forEach data-driven desde fixture
cy.login() requiere argumentos siempre sin argumentos lee del fixture
Organización login.cy.ts 8 tests planos 4 context blocks
Agregar caso negativo crear nuevo it() + copiar código agregar entrada al array en JSON

Próximo post:

Módulo Clientes: navegación desde el dashboard, grilla SlickGrid y primeros tests con ClientesPage.