Skip to content
Back to Published Articles
Cypress

The ag-grid virtualization traps that make your E2E assertions lie

Hidden columns can sit at width zero, server-side row ids are positional, off-screen headers do not exist. Six ways a virtualized grid returns false green.

The ag-grid virtualization traps that make your E2E assertions lie
100% Human-Authored: All engineering insights, production war stories, and code were written by Marcelo Costa with zero AI involvement in authoring. AI was used solely for multilingual translations.

At the company I work for, I maintain a large ag-grid suite that runs against both the client-side and the server-side row models. Ask me for the worst thing in an application to write end-to-end tests against, and I'll say the data grid. The complexity I can live with. What gets me is that a virtualized grid lies about its own DOM in ways that make assertions pass.

A failing test is cheap: you look at it, you fix something. A test that passes because the element it was looking for was never rendered? That's the expensive one, because it buys you confidence you haven't earned. Every trap below is of the second kind, and I found each of them the hard way.

The examples use ag-grid class names and Cypress, but the failure modes apply to any virtualized grid: TanStack Virtual, Handsontable, Kendo, a hand-rolled windowed table.

Six traps, six false greens. Let's start with a column you already hid.

1. Hidden, but still in the DOM 👻

You'd expect a hide-column feature to remove cells. ag-grid's own hide flag does. Many hide features built on top of a grid don't. Hide a column by zeroing its width, or through a custom "collapse this group" affordance, and the header plus every body cell can stay mounted with a computed width of 0. Hidden doesn't mean absent.

So the obvious assertion fails, even though hiding worked:

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

You had six columns and hid one. The assertion fails, you "fix" it by expecting 6, and now the test can't detect a column that failed to hide. Congrats: it passes whether hiding works or not.

Index arithmetic is even worse. Anything that reaches for "the third column" by position counts the zero-width ghost and reads a cell one place to the left of the one you meant. Silently. Forever.

Filter on rendered geometry, not on presence:

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

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

If your grid marks hidden columns with a class, prefer that. It's faster and it documents itself:

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

Then make every helper that takes a column index go through that same filter. A cellAt(row, col) utility that skips zero-width columns by default, with an opt-in includeHidden flag for the tests that specifically assert hiding, kills this whole class of bug at the source.

One more thing to check while you're in there: hiding often behaves differently in a pivoted view than in a flat one, so assert both. I've seen a grid honour hidden columns flat and render them full width pivoted (yes, the same grid).

2. Row ids that move when you sort

This one is a migration killer. Under the client-side row model, a group row gets a stable id derived from the field and the key:

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

Point the same grid at the server-side row model without a getRowId callback, and the id becomes a counter: handed out in load order, prefixed with the parent group's keys. The EMEA group gets whatever number came next, and its children get numbered beneath it:

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

That id encodes position. Sort the grid → different id. Apply a filter → different id. Let a different block load first → different id. Same logical row, every time.

Selectors built on it pass on your machine and fail in CI with no code change, which is exactly the profile of a bug that ends up blamed on the runner.

So never address a group row by id or by nth-child. Address it by its label path, the same way a human would:

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

Expand along the path, then locate by the leaf label. It reads like the user story and it survives sorting. Better yet, it's identical across both row models, so the same helper serves your client-side and server-side specs with no branching at all.

Which brings me to porting. When you move a spec from one row model to the other, the only legitimate difference should be that the server model may auto-expand what the client model needs to be told. If you find yourself rewriting selectors, you built them on the wrong thing.

3. Off-screen headers don't exist (and not.exist can't tell)

Header virtualization renders a window of columns. Scroll left and the far-right headers are removed from the DOM entirely.

Now take the most common negative assertion in a grid suite:

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

Column successfully removed? Green. Column present, correct, and simply 900px to the right of the viewport? Also green. That's a false green, and the test will keep reporting it for as long as the project lives.

There are two ways out, and you want both.

Scroll deterministically before any positional assertion. Not by pixels. Ask the grid to bring the column into view, through the same window handle I describe right after this:

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

For absence, assert against the column model, not the DOM. Expose the grid api on window in your test build and ask it directly:

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

Twelve lines of production code, behind an environment flag, to expose that object. That's the highest-leverage change you can make to a grid suite. It turns "is it rendered right now", which is a rendering question, into "does the grid believe this column exists", which is the question your test was trying to ask all along.

4. The group column scrolls away

The auto-generated group column often defaults to unpinned. So any assertion shaped like "the first cell in the row contains the region name" holds right up until a test scrolls horizontally. Then the group column is gone, and the first rendered cell is something else.

Pin it in your test grid config if you can. If you can't, never describe a cell as "first". Describe it by col-id and let the grid tell you where it is.

5. "No Rows To Show" is final

An empty grid usually means a data fetch failed and the grid swallowed the error into its empty-state overlay. That's not a slow load. Nothing about that state is retriable, because the request isn't coming back, so a Cypress retry just burns the full timeout again and your CI run gets minutes longer for zero information.

A longer timeout won't save you either. What helps is an explicit guard:

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

Reload once, then assert properly. If it's empty twice, that's a real defect and you want the failure. The bug I chased here was environment-only, a data file that failed to download in CI and nowhere else (of course), and this guard is what turned an unexplainable red into a one-line log entry.

6. The selection that undoes itself

Click a cell, assert on the selection, and sometimes the selection is gone. The click's own side effect triggers a grid refresh: click → refresh → row re-renders → cell selection state cleared. Classic race: your assertion and that refresh are a coin flip.

Don't solve this with a wait. Solve it by re-selecting when the selection didn't stick:

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

A fixed cy.wait here is a bet on a timing you don't control. A conditional re-select is a bet on the grid eventually reaching the state you asked for, and that's a much better bet.

Basically, when the refresh wins the coin flip, the helper clicks one more time, and your assertion runs against the selection you actually asked for instead of whatever survived the re-render. No guessing at milliseconds.

Happy testing!