Cypress: validación de grilla contra Excel — cy.task, SlickGrid API y cy.intercept

91 clientes × 10 columnas contra Excel. cy.task() para leer archivos desde Node, dos estrategias sin scroll. Errores reales y patrón sin expect.soft().

Cypress mostrando 3 diferencias encontradas entre Excel y grilla SlickGrid para ALFKI empresa ANATR contacto y AROUT pais
3 diferencias intencionales. El test recorrió los 91 clientes sin detenerse al primer fallo

Contexto

El post anterior (Cypress: interacciones con la grilla) cubre ordenamiento, filtros Select2 y búsqueda. Todo interacción: click en headers, seleccionar países, tipear texto. Pero ninguno valida los datos completos de la grilla.

Este post resuelve eso: comparar los 91 clientes × 10 columnas de la grilla contra un archivo Excel con los datos esperados. Y lo hace de dos formas distintas, validando capas diferentes del sistema.

En la serie de Playwright resolví esto con scroll programático del DOM: mover el viewport, leer las filas visibles, repetir. Funcionó, pero en Cypress hay formas mejores.


El problema: SlickGrid no renderiza todo

SlickGrid virtualiza el DOM. De 91 registros, solo las filas visibles en el viewport existen en el DOM en cada momento. Si scrolleás, las de arriba desaparecen y aparecen las nuevas.

En Playwright usé page.evaluate() dentro de un loop con await para scrollear y extraer datos en cada paso. En Cypress eso se complica: los comandos son asincrónicos y encadenados, no hay await directo. Un loop con scroll requiere recursión o .then() anidados, con timings frágiles.

Pero los datos están en otro lado. Dos opciones:

SlickGrid API: SlickGrid guarda los 91 registros en memoria. Solo renderiza los visibles, pero los tiene todos. Con cy.window() accedo al objeto grid y leo getData().getItems() directo. Sin scroll, sin DOM, sin timings.

cy.intercept: Cuando la grilla carga, Serenity hace un POST a /Services/Northwind/Customer/List. Con cy.intercept() capturo la respuesta del servidor antes de que llegue a la grilla. Valido los datos crudos de la API.

Decidí implementar las dos. Cada una valida una capa distinta: intercept valida que el servidor devuelve datos correctos, SlickGrid API valida que la grilla los tiene en memoria. Y las dos usan la misma fuente de verdad: el Excel.


cy.task() — el puente Node ↔ browser

En Playwright, leer un Excel es un import directo: el test corre en Node, el archivo se lee en Node. En Cypress, los tests corren en el browser. No puedo hacer import ExcelJS dentro de un spec — ExcelJS necesita fs, path, cosas de Node que no existen en el browser.

cy.task() resuelve eso. Es el mecanismo de Cypress para ejecutar código en Node desde un test que corre en el browser. El spec envía un mensaje: "leeme este archivo". El task corre en Node, lee el Excel, y devuelve los datos al browser como JSON.

Lo registré en cypress.config.ts:

import { defineConfig } from 'cypress'
import ExcelJS from 'exceljs'
import path from 'path'

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',
    setupNodeEvents(on, config) {
      on('task', {
        async leerClientesDesdeExcel(rutaRelativa: string) {
          const rutaArchivo = path.resolve(rutaRelativa)
          const workbook = new ExcelJS.Workbook()
          await workbook.xlsx.readFile(rutaArchivo)

          const hoja = workbook.getWorksheet('Clientes')
          if (!hoja) throw new Error(`No se encontró la hoja "Clientes" en ${rutaArchivo}`)

          const clientes: Record<string, Record<string, string>> = {}

          hoja.eachRow({ includeEmpty: false }, (row, rowNumber) => {
            if (rowNumber === 1) return
            const id = row.getCell(1).text.trim()
            if (!id) return

            clientes[id] = {
              id,
              empresa: row.getCell(2).text.trim(),
              contacto: row.getCell(3).text.trim(),
              titulo: row.getCell(4).text.trim(),
              region: row.getCell(5).text.trim(),
              codigoPostal: row.getCell(6).text.trim(),
              pais: row.getCell(7).text.trim(),
              ciudad: row.getCell(8).text.trim(),
              telefono: row.getCell(9).text.trim(),
              fax: row.getCell(10).text.trim(),
              representantes: row.getCell(11).text.trim(),
            }
          })

          return clientes
        }
      })
    },
  },
})

Un detalle: cy.task() solo puede devolver JSON serializable. No puede devolver un Map. Devuelvo un objeto plano { ALFKI: {...}, ANATR: {...} } y el test lo usa directo.

En el spec, la llamada es una línea:

cy.task('leerClientesDesdeExcel', 'cypress/test-data/clientes-data.xlsx')

La ruta es relativa al root del proyecto — no path.join(), no __dirname. Esos son de Node. El spec corre en el browser. El task resuelve la ruta con path.resolve() en Node.


SlickGrid API — leer datos desde memoria

Para acceder al grid necesitaba encontrar la instancia de SlickGrid en el DOM. Esto tomó varias iteraciones.

Primero intenté con window.jQuery — no existe. Serenity no expone jQuery en window. Después probé window.$ — tampoco. El debug mostró que Serenity usa window.Q, pero no como función.

Escribí un test de debug temporal para inspeccionar qué había disponible:

it('DEBUG: inspeccionar acceso a SlickGrid', () => {
    cy.window().then((win: any) => {
        const el = win.document.querySelector('.s-CustomerGrid')
        cy.log('window.Serenity: ' + typeof win.Serenity)
        if (win.Serenity.tryGetWidget) {
            const widget = win.Serenity.tryGetWidget(el)
            cy.log('tryGetWidget result: ' + (widget ? 'found!' : 'null'))
            cy.log('widget.slickGrid: ' + typeof widget.slickGrid)
        }
    })
})

Los logs de Cypress mostraron: tryGetWidget result: found!, widget.slickGrid: object. El camino es Serenity.tryGetWidget(el).slickGrid.

El método final en ClientesPage.ts:

leerDatosDesdeSlickGrid(): Cypress.Chainable<Record<string, Record<string, string>>> {
    cy.get('.s-CustomerGrid').should('be.visible')
    cy.get('div.slick-row').should('exist')

    return cy.window().then((win: any) => {
        const el = win.document.querySelector('.s-CustomerGrid')
        if (!el) throw new Error('Container .s-CustomerGrid no encontrado')

        const widget = win.Serenity.tryGetWidget(el)
        if (!widget?.slickGrid) throw new Error('SlickGrid instance no encontrada')

        const grid = widget.slickGrid
        const items = grid.getData().getItems()
        const clientes: Record<string, Record<string, string>> = {}

        for (const item of items) {
            const id = (item.CustomerID || '').trim()
            if (!id) continue

            clientes[id] = {
                id,
                empresa: (item.CompanyName || '').trim(),
                contacto: (item.ContactName || '').trim(),
                titulo: (item.ContactTitle || '').trim(),
                region: (item.Region || '').trim(),
                codigoPostal: (item.PostalCode || '').trim(),
                pais: (item.Country || '').trim(),
                ciudad: (item.City || '').trim(),
                telefono: (item.Phone || '').trim(),
                fax: (item.Fax || '').trim(),
                representantes: Array.isArray(item.Representatives)
                    ? item.Representatives.join(', ')
                    : String(item.Representatives ?? '').trim(),
            }
        }

        return clientes
    })
}

Los cy.get().should() al principio fuerzan a Cypress a esperar hasta que la grilla exista. Sin eso, cy.window() se ejecutaba antes de que el DOM estuviera listo y querySelector devolvía null.

Tres líneas para llegar al grid: querySelectortryGetWidget.slickGrid. En comparación con Playwright donde usé page.evaluate() con loops de scroll, esto es mucho más directo.


cy.intercept — capturar la respuesta del servidor

La otra estrategia no toca la grilla. Intercepto el POST que Serenity hace al servidor cuando carga los datos:

cy.intercept('POST', '**/Services/Northwind/Customer/List').as('customerList')

cy.login()
dashboardPage.irAClientes()
clientesPage.verificarVisible()

cy.wait('@customerList').then((interception) => {
    const entities = interception.response?.body?.Entities || []
    // comparar entities contra Excel...
})

cy.intercept() se registra ANTES de navegar. Cuando Cypress ve el POST, captura la respuesta completa. cy.wait('@customerList') espera a que la request ocurra y me da el objeto con request y response.

Cada entity en la respuesta tiene las propiedades del cliente: CustomerID, CompanyName, ContactName, etc. Las comparo contra el Excel.


expect.soft() no existe en Cypress

En Playwright tenía expect.soft(): si una aserción falla, no corta el test — acumula el error y sigue. Perfecto para comparar 91 × 10 campos.

Cypress usa Chai, que no tiene ese concepto. Un expect() que falla corta el test al instante.

La solución: acumular los mismatches en un array y hacer un solo throw al final.

const errores: string[] = []

for (const id of idsExcel) {
    const esperado = excelData[id]
    const real = grillaData[id]

    if (!real) {
        errores.push(`${id} no existe en la grilla`)
        continue
    }

    const campos = [
        ['empresa', esperado.empresa, real.empresa],
        ['contacto', esperado.contacto, real.contacto],
        ['titulo', esperado.titulo, real.titulo],
        // ... resto de campos
    ]

    for (const [campo, valorExcel, valorGrilla] of campos) {
        if (valorExcel !== valorGrilla) {
            errores.push(`${id} - ${campo}: excel="${valorExcel}" | grilla="${valorGrilla}"`)
        }
    }
}

if (errores.length > 0) {
    throw new Error(
        `${errores.length} diferencias encontradas:\n\n${errores.join('\n')}`
    )
}

El patrón compara todo, acumula diferencias, y al final tira un error con el reporte completo. Es el equivalente manual de expect.soft().

Concepto Playwright Cypress
Aserción que no corta expect.soft() No existe — patrón manual
Reporte de errores Automático en HTML Reporter Manual con array + throw
Cuándo usar expect.soft(val).toBe(expected) if (val !== expected) errores.push(...)

clientes-data.cy.ts — los 2 tests

import { ClientesPage } from '../../pages/ClientesPage'
import { DashboardPage } from '../../pages/DashboardPage'

describe('Validación de datos contra Excel', () => {
    const clientesPage = new ClientesPage()
    const dashboardPage = new DashboardPage()

    const rutaExcel = 'cypress/test-data/clientes-data.xlsx'

    context('SlickGrid API — datos en memoria de la grilla', () => {
        beforeEach(() => {
            cy.login()
            dashboardPage.irAClientes()
            clientesPage.verificarVisible()
        })

        it('los 91 clientes coinciden con el Excel', () => {
            cy.task('leerClientesDesdeExcel', rutaExcel).then((excelData: any) => {
                const idsExcel = Object.keys(excelData)

                clientesPage.leerDatosDesdeSlickGrid().then((grillaData) => {
                    const idsGrilla = Object.keys(grillaData)
                    const errores: string[] = []

                    if (idsGrilla.length !== idsExcel.length) {
                        errores.push(
                            `Cantidad: grilla=${idsGrilla.length}, excel=${idsExcel.length}`
                        )
                    }

                    for (const id of idsExcel) {
                        const esperado = excelData[id]
                        const real = grillaData[id]

                        if (!real) {
                            errores.push(`${id} no existe en la grilla`)
                            continue
                        }

                        const campos = [
                            ['empresa', esperado.empresa, real.empresa],
                            ['contacto', esperado.contacto, real.contacto],
                            ['titulo', esperado.titulo, real.titulo],
                            ['region', esperado.region, real.region],
                            ['codigoPostal', esperado.codigoPostal, real.codigoPostal],
                            ['pais', esperado.pais, real.pais],
                            ['ciudad', esperado.ciudad, real.ciudad],
                            ['telefono', esperado.telefono, real.telefono],
                            ['fax', esperado.fax, real.fax],
                        ]

                        for (const [campo, valorExcel, valorGrilla] of campos) {
                            if (valorExcel !== valorGrilla) {
                                errores.push(
                                    `${id} - ${campo}: excel="${valorExcel}" | grilla="${valorGrilla}"`
                                )
                            }
                        }
                    }

                    for (const id of idsGrilla) {
                        if (!excelData[id]) {
                            errores.push(`${id} está en la grilla pero no en el Excel`)
                        }
                    }

                    if (errores.length > 0) {
                        throw new Error(
                            `${errores.length} diferencias encontradas:\n\n${errores.join('\n')}`
                        )
                    }
                })
            })
        })
    })

    context('cy.intercept — respuesta del servidor', () => {
        it('la respuesta del API coincide con el Excel', () => {
            cy.intercept('POST', '**/Services/Northwind/Customer/List').as('customerList')

            cy.login()
            dashboardPage.irAClientes()
            clientesPage.verificarVisible()

            cy.wait('@customerList').then((interception) => {
                const entities = interception.response?.body?.Entities
                    || interception.response?.body?.entities
                    || []

                cy.task('leerClientesDesdeExcel', rutaExcel).then((excelData: any) => {
                    const errores: string[] = []

                    if (entities.length !== Object.keys(excelData).length) {
                        errores.push(
                            `Cantidad: API=${entities.length}, excel=${Object.keys(excelData).length}`
                        )
                    }

                    for (const entity of entities) {
                        const id = entity.CustomerID || entity.customerId
                        const esperado = excelData[id]

                        if (!esperado) {
                            errores.push(`${id} está en el API pero no en el Excel`)
                            continue
                        }

                        const campos = [
                            ['empresa', esperado.empresa, entity.CompanyName || ''],
                            ['contacto', esperado.contacto, entity.ContactName || ''],
                            ['titulo', esperado.titulo, entity.ContactTitle || ''],
                            ['region', esperado.region, entity.Region || ''],
                            ['codigoPostal', esperado.codigoPostal, entity.PostalCode || ''],
                            ['pais', esperado.pais, entity.Country || ''],
                            ['ciudad', esperado.ciudad, entity.City || ''],
                            ['telefono', esperado.telefono, entity.Phone || ''],
                            ['fax', esperado.fax, entity.Fax || ''],
                        ]

                        for (const [campo, valorExcel, valorApi] of campos) {
                            if (valorExcel !== valorApi) {
                                errores.push(
                                    `${id} - ${campo}: excel="${valorExcel}" | api="${valorApi}"`
                                )
                            }
                        }
                    }

                    if (errores.length > 0) {
                        throw new Error(
                            `${errores.length} diferencias encontradas:\n\n${errores.join('\n')}`
                        )
                    }
                })
            })
        })
    })
})

Dos context blocks, un test cada uno. Los dos validan los mismos 9 campos por cliente (todos menos representantes, que en la grilla es un componente especial). La diferencia no está en qué validan, sino en de dónde sacan los datos: SlickGrid API los lee de la memoria del grid, intercept los captura de la respuesta HTTP del servidor.


Errores reales del build

Este fue el build con más iteraciones de la serie. Cada error está acá.

Error 1: path y __dirname no existen en el browser

La primera versión del spec tenía:

import path from 'path'
const rutaExcel = path.join(__dirname, '..', '..', 'test-data', 'clientes-data.xlsx')

TypeScript marcó error: Cannot find name '__dirname'. path y __dirname son de Node. El spec corre en el browser.

La solución: pasar una ruta relativa simple y resolver en el task.

// En el spec (browser):
const rutaExcel = 'cypress/test-data/clientes-data.xlsx'

// En el task (Node):
const rutaArchivo = path.resolve(rutaRelativa)

Error 2: File not found

File not found: cypress/test-data/clientes-data.xlsx. No había creado la carpeta test-data ni copiado el Excel. Error de setup, no de código.

Cypress Test Runner mostrando CypressError File not found cypress test-data clientes-data.xlsx al ejecutar cy.task leerClientesDesdeExcel
cy.task falló: el archivo Excel no existía en la carpeta test-data

Error 3: jQuery → Q → Serenity.tryGetWidget()

El primer intento para acceder al grid usó window.jQuery. Error: win.jQuery is not a function.

Serenity no expone jQuery como window.jQuery ni como window.$. Un debug mostró que window.Q existe como object (no función), y window.Slick también. Pero ninguno me daba el grid directamente.

La solución vino de inspeccionar window.Serenity:

Test de debug en Cypress mostrando logs tryGetWidget result found y widget.slickGrid object confirmando acceso al grid de Serenity
El test de debug confirmó que Serenity.tryGetWidget() devuelve el widget con slickGrid

Serenity.tryGetWidget(element) devuelve el widget asociado a un elemento del DOM. Y ese widget tiene .slickGrid con la instancia del grid. Tres líneas:

const el = win.document.querySelector('.s-CustomerGrid')
const widget = win.Serenity.tryGetWidget(el)
const grid = widget.slickGrid

Error 4: selector incorrecto

El primer selector fue .s-Northwind-CustomerGrid. El elemento real tiene la clase .s-Serenity-Demo-Northwind-CustomerGrid, .s-Demo-Northwind-CustomerGrid y .s-CustomerGrid. Pero no .s-Northwind-CustomerGrid sin el "Demo".

Error Container s-Northwind-CustomerGrid no encontrado con flechas señalando el querySelector y el throw en ClientesPage.ts
El selector .s-Northwind-CustomerGrid no existía. El elemento real usa .s-CustomerGrid

Cambié a .s-CustomerGrid — la más corta y genérica.

Error 5: Representatives no es string

(item.Representatives || '').trim is not a function
TypeError item.Representatives.trim is not a function en Cypress porque Representatives es un array no un string
item.Representatives no es un string — es un array. El || '' no atrapa valores truthy como arrays

item.Representatives no es un string — es un array o un objeto. El || '' no lo atrapa porque un array no es falsy.

// Antes:
representantes: (item.Representatives || '').trim(),

// Después:
representantes: Array.isArray(item.Representatives)
    ? item.Representatives.join(', ')
    : String(item.Representatives ?? '').trim(),

Error 6: expect.fail no es una función

En el test de intercept, usé expect.fail() para reportar IDs que están en la API pero no en el Excel. Cypress usa Chai, y Chai no tiene expect.fail().

// Antes (no existe en Chai):
expect.fail(`${id} está en el API pero no en el Excel`)

// Después:
throw new Error(`${id} está en el API pero no en el Excel`)

Error 7: emojis en el Excel — 94 diferencias

La primera corrida exitosa (después de resolver los errores anteriores) encontró 94 diferencias. Casi todas del mismo campo: país.

Reporte de 94 diferencias encontradas mostrando emojis de banderas en Excel versus texto plano en la grilla de SlickGrid
94 diferencias. Todas por emojis de banderas en el Excel que la grilla no tiene

El Excel tenía 🇩🇪 Germany, 🇫🇷 France, 🇦🇷 Argentina. La grilla devuelve Germany, France, Argentina sin emojis.

Además del tema de los emojis:

  • EASTCs en el Excel vs EASTC en la grilla (typo, una "s" de más)
  • Accounting Managers vs Accounting Manager en FISSA (plural incorrecto)
  • Mexicos vs Mexico en CENTC (typo)
  • Wolski Zajazd vs Wolski Zajazd en WOLZA (doble espacio en los datos reales — el mismo que descubrí en la serie de Playwright)

Corregí el Excel: saqué los emojis, arreglé los typos, puse el doble espacio de WOLZA. Los dos tests pasaron.


Errores intencionales — demo del reporte

Para demostrar el patrón de acumulación, introduje 3 diferencias a propósito en el Excel:

  • ALFKI — empresa: Alfred Futterkiste (falta la "s")
  • ANATR — contacto: Anna Trujillo (doble "n")
  • AROUT — país: United Kingdom (en vez de UK)

El reporte de SlickGrid API:

Cypress mostrando 3 diferencias encontradas entre Excel y grilla SlickGrid para ALFKI empresa ANATR contacto y AROUT pais
3 diferencias intencionales. El test recorrió los 91 clientes sin detenerse al primer fallo

El reporte de cy.intercept:

cy.intercept mostrando 3 diferencias encontradas entre Excel y respuesta del API para ALFKI empresa ANATR contacto y AROUT pais
Las mismas 3 diferencias desde la respuesta del servidor. Misma validación, distinta capa

Los dos tests reportan las mismas 3 diferencias. La fuente de los datos es distinta (memoria de la grilla vs respuesta HTTP), pero la validación es la misma. Si un error aparece en uno pero no en el otro, sabés en qué capa está el problema.


Comparación con Playwright

Concepto Playwright Cypress
Leer Excel import directo (Node) cy.task() — puente Node ↔ browser
Acceder a datos de la grilla page.evaluate() + scroll loop Serenity.tryGetWidget().slickGrid.getData().getItems()
Interceptar API No lo usé cy.intercept() + cy.wait()
Aserción que no corta expect.soft() Patrón manual con array + throw
Datos devueltos Map<string, Cliente> Record<string, Record<string, string>> (JSON serializable)
Serialización No necesaria (todo es Node) Obligatoria (cy.task cruza browser ↔ Node)

La diferencia fundamental: en Playwright todo corre en Node. En Cypress, los tests corren en el browser y Node es un mundo aparte al que accedés por cy.task(). Esa separación impacta en cómo leer archivos, qué tipos de datos podés devolver, y qué APIs están disponibles.


Estructura actual del proyecto

cypress-typescript-framework/
├── cypress/
│   ├── e2e/
│   │   ├── auth/
│   │   │   └── login.cy.ts              ← 8 tests (data-driven)
│   │   ├── clientes/
│   │   │   ├── clientes.cy.ts            ← 6 tests (navegación, grilla)
│   │   │   ├── clientes-interacciones.cy.ts ← 7 tests (ordenamiento, filtros, búsqueda)
│   │   │   └── clientes-data.cy.ts       ← NUEVO (2 tests: SlickGrid API + intercept)
│   │   └── dashboard/
│   │       └── assertions.cy.ts          ← 10 tests
│   ├── fixtures/
│   │   └── users.json
│   ├── pages/
│   │   ├── LoginPage.ts
│   │   ├── DashboardPage.ts
│   │   └── ClientesPage.ts              ← ACTUALIZADO (leerDatosDesdeSlickGrid)
│   ├── test-data/
│   │   └── clientes-data.xlsx            ← NUEVO
│   ├── types/
│   │   └── Cliente.ts                    ← NUEVO
│   ├── screenshots/
│   └── support/
│       ├── commands.ts
│       ├── e2e.ts
│       └── index.d.ts
├── cypress.config.ts                     ← ACTUALIZADO (setupNodeEvents + task)
├── package.json
├── tsconfig.json
├── .gitignore
└── README.md

Tres archivos nuevos (clientes-data.cy.ts, clientes-data.xlsx, Cliente.ts) y dos actualizados (ClientesPage.ts, cypress.config.ts).


Estado actual

33 tests, 31 passing, 2 failing intencionales:

Resumen de Cypress run mostrando 33 tests 31 passing 2 failing con clientes-data.cy.ts como unico spec con fallos intencionales
33 tests, 31 passing, 2 failing intencionales. Los otros 4 specs siguen verdes
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 -

Los 2 tests de clientes-data.cy.ts fallan a propósito. El Excel contiene 3 diferencias introducidas para demostrar cómo el patrón de acumulación reporta discrepancias sin detenerse al primer fallo. Para que pasen: corregir ALFKI empresa, ANATR contacto, AROUT país.