Saltar al contenido
Volver a Artículos Publicados
Cypress

Las trampas de virtualización de ag-grid que hacen mentir a tus aserciones E2E

Columnas ocultas que pueden quedar con ancho cero, ids de fila server-side posicionales, cabeceras fuera de pantalla que no existen. Seis falsos verdes de un grid virtualizado.

Las trampas de virtualización de ag-grid que hacen mentir a tus aserciones E2E
Contenido 100% autoral: Todos los artículos, desafíos técnicos y código fueron escritos por Marcelo Costa sin el uso de IA en la redacción. La inteligencia artificial se utilizó exclusivamente para ayudar con la traducción a múltiples idiomas.

En la empresa donde trabajo mantengo una suite grande de ag-grid que corre contra los dos row models, el client-side y el server-side. Pregúntame qué es lo peor de una aplicación para escribir tests end-to-end y te diré que el data grid. Con la complejidad puedo vivir. Lo que me mata es que un grid virtualizado miente sobre su propio DOM de maneras que hacen que las aserciones pasen.

Un test que falla es barato: lo miras y arreglas algo. ¿Un test que pasa porque el elemento que buscaba nunca llegó a renderizarse? Ese es el caro, porque te regala una confianza que no te has ganado. Todas las trampas de abajo son del segundo tipo, y las descubrí todas por las malas.

Los ejemplos usan nombres de clase de ag-grid y Cypress, pero los modos de fallo se aplican a cualquier grid virtualizado: TanStack Virtual, Handsontable, Kendo, una tabla con windowing hecha a mano.

Seis trampas, seis falsos verdes. Empecemos por una columna que ya ocultaste.

1. Oculta, pero todavía en el DOM 👻

Esperarías que una función de ocultar columnas eliminara las celdas. El flag hide del propio ag-grid lo hace. Muchas funciones de ocultación construidas encima de un grid, no. Oculta una columna poniendo su ancho a cero, o mediante un control personalizado de "contraer este grupo", y la cabecera más todas las celdas del cuerpo pueden seguir montadas, con un ancho calculado de 0. Oculto no significa ausente.

Así que la aserción obvia falla, aunque la ocultación haya funcionado:

cy.get('.ag-header-cell').should('have.length', 5)

Tenías seis columnas y ocultaste una. La aserción falla, la "arreglas" esperando 6, y ahora el test no puede detectar una columna que no se llegó a ocultar. Enhorabuena: pasa tanto si la ocultación funciona como si no.

La aritmética de índices es todavía peor. Cualquier cosa que busque "la tercera columna" por posición cuenta el fantasma de ancho cero y lee una celda una posición a la izquierda de la que querías. En silencio. Para siempre.

Filtra por la geometría renderizada, no por la presencia:

const visibleHeaders = () =>
  cy
    .get('.ag-header-cell')
    .filter((_, el) => el.getBoundingClientRect().width > 0)

visibleHeaders().should('have.length', 5)

Si tu grid marca las columnas ocultas con una clase, prefiere eso. Es más rápido y se documenta solo:

const VISIBLE_CELL = '.ag-cell:not(.hidden-column)'

Después, haz que todos los helpers que reciben un índice de columna pasen por ese mismo filtro. Una utilidad cellAt(row, col) que omite por defecto las columnas de ancho cero, con un flag opcional includeHidden para los tests que comprueban específicamente la ocultación, elimina de raíz toda esta clase de bugs.

Una cosa más que conviene revisar ya que estás en ello: la ocultación a menudo se comporta de forma distinta en una vista pivotada que en una plana, así que comprueba las dos. He visto un grid respetar las columnas ocultas en la vista plana y renderizarlas a ancho completo en la pivotada (sí, el mismo grid).

2. Ids de fila que cambian cuando ordenas

Esta se carga cualquier migración. Con el row model client-side, una fila de grupo recibe un id estable, derivado del campo y la clave:

<div class="ag-row" row-id="row-group-region-EMEA">

Apunta el mismo grid al row model server-side sin un callback getRowId y el id se convierte en un contador: asignado por orden de carga, prefijado con las claves del grupo padre. Al grupo EMEA le toca el número que viniera a continuación, sea cual sea, y sus hijos se numeran por debajo:

<div class="ag-row" row-id="3">
<div class="ag-row" row-id="EMEA-0">

Ese id codifica la posición. Ordena el grid → otro id. Aplica un filtro → otro id. Deja que otro bloque cargue antes → otro id. La misma fila lógica, cada vez.

Los selectores construidos sobre él pasan en tu máquina y fallan en CI sin ningún cambio de código, que es justo el perfil del bug del que se acaba culpando al runner.

Así que nunca localices una fila de grupo por id ni por nth-child. Localízala por su ruta de etiquetas, igual que lo haría una persona:

function expandTo(path: string[]) {
  path.forEach((label) => {
    cy.contains('.ag-row .ag-group-value', label)
      .parents('.ag-row')
      .find('.ag-group-contracted')
      .click()
  })
}

function rowByLabelPath(path: string[]) {
  const leaf = path[path.length - 1]
  return cy.contains('.ag-row .ag-group-value', leaf).parents('.ag-row')
}

Expande a lo largo de la ruta y luego localiza por la etiqueta de la hoja. Se lee como la historia de usuario y sobrevive a la ordenación. Mejor aún: es idéntico en ambos row models, así que el mismo helper sirve a tus specs client-side y server-side sin ninguna bifurcación.

Lo que me lleva a portar specs. Cuando portas un spec de un row model al otro, la única diferencia legítima debería ser que el modelo server-side puede expandir por su cuenta lo que al client-side hay que indicarle. Si te ves reescribiendo selectores, es que los construiste sobre lo que no debías.

3. Las cabeceras fuera de pantalla no existen (y not.exist no sabe distinguirlo)

La virtualización de cabeceras renderiza una ventana de columnas. Desplázate a la izquierda y las cabeceras del extremo derecho desaparecen por completo del DOM.

Ahora toma la aserción negativa más habitual de una suite de grid:

cy.get('[col-id="year"]').should('not.exist')

¿Columna eliminada correctamente? Verde. ¿Columna presente, correcta y simplemente 900px a la derecha del viewport? También verde. Eso es un falso verde, y el test lo seguirá dando por bueno mientras viva el proyecto.

Hay dos salidas, y quieres las dos.

Desplázate de forma determinista antes de cualquier aserción posicional. No por píxeles. Pídele al grid que haga visible la columna, a través del mismo handle en window que describo justo después de esto:

cy.window().its('gridApi').invoke('ensureColumnVisible', 'year')
cy.get('[col-id="year"]').should('be.visible')

Para la ausencia, haz la aserción contra el modelo de columnas, no contra el DOM. Expón la API del grid en window en tu build de test y pregúntale directamente:

cy.window().then((win) => {
  const ids = win.gridApi.getAllDisplayedColumns().map((c) => c.getColId())
  expect(ids).to.not.include('year')
})

Doce líneas de código de producción, detrás de un flag de entorno, para exponer ese objeto. Ese es el cambio con más impacto que puedes hacer en una suite de grid. Convierte "¿está renderizado ahora mismo?", que es una pregunta de renderizado, en "¿cree el grid que esta columna existe?", que es la pregunta que tu test intentaba hacer desde el principio.

4. La columna de grupo se va con el scroll

La columna de grupo autogenerada suele venir sin fijar por defecto. Así que cualquier aserción del tipo "la primera celda de la fila contiene el nombre de la región" se cumple justo hasta que un test hace scroll horizontal. Entonces la columna de grupo desaparece y la primera celda renderizada es otra cosa.

Fíjala en la configuración del grid de test si puedes. Si no puedes, nunca describas una celda como "la primera". Descríbela por su col-id y deja que el grid te diga dónde está.

5. "No Rows To Show" es definitivo

Un grid vacío suele significar que falló una carga de datos y el grid se tragó el error en su overlay de estado vacío. Eso no es una carga lenta. Nada en ese estado se arregla reintentando, porque la petición no va a volver, así que un reintento de Cypress solo vuelve a quemar el timeout entero y tu ejecución en CI se alarga varios minutos a cambio de cero información.

Un timeout más largo tampoco te va a salvar. Lo que ayuda es una protección explícita:

function ensureRowsRendered(minRows = 1) {
  const rows = '.ag-center-cols-container .ag-row'
  const noRows = '.ag-overlay-no-rows-center'

  cy.get(`${rows}, ${noRows}`).then(($settled) => {
    if ($settled.is(noRows)) {
      cy.reload()
    }
  })
  cy.get(rows).should('have.length.at.least', minRows)
}

Recarga una vez y luego haz la aserción como es debido. Si sale vacío dos veces, es un defecto real y quieres ese fallo. El bug que perseguí aquí solo ocurría en un entorno, un archivo de datos que fallaba al descargarse en CI y en ningún otro sitio (cómo no), y esta protección es lo que convirtió un rojo inexplicable en una entrada de log de una sola línea.

6. La selección que se deshace sola

Haz clic en una celda, haz una aserción sobre la selección y, a veces, la selección ya no está. El propio efecto secundario del clic dispara un refresh del grid: clic → refresh → la fila se vuelve a renderizar → se limpia el estado de selección de la celda. Condición de carrera clásica: tu aserción y ese refresh se lo juegan a cara o cruz.

No lo resuelvas con una espera. Resuélvelo volviendo a seleccionar cuando la selección no se haya quedado:

function selectCell(rowIndex: number, colId: string, reselect = true) {
  const cell = () =>
    cy.get(`.ag-row[row-index="${rowIndex}"] [col-id="${colId}"]`)

  cell().click()
  if (reselect) {
    cell().then(($el) => {
      if (!$el.hasClass('ag-cell-range-selected')) cell().click()
    })
  }
  return cell()
}

Un cy.wait fijo aquí es una apuesta por un timing que no controlas. Una reselección condicional es una apuesta por que el grid acabe llegando al estado que le pediste, y esa es una apuesta mucho mejor.

Básicamente, cuando el refresh gana el cara o cruz, el helper hace clic una vez más, y tu aserción se ejecuta contra la selección que de verdad pediste en lugar de lo que haya sobrevivido al re-render. Sin adivinar milisegundos.

¡Felices pruebas!