Na empresa onde eu trabalho, mantenho uma suíte grande de ag-grid que roda contra os dois row models, o client-side e o server-side. Me pergunte qual é a pior coisa de uma aplicação para escrever testes end-to-end e eu vou responder: o data grid. Com a complexidade eu convivo. O que me pega é que um grid virtualizado mente sobre o próprio DOM de maneiras que fazem as asserções passarem.
Um teste que falha é barato: você olha para ele e corrige alguma coisa. Um teste que passa porque o elemento que ele procurava nunca foi renderizado? Esse é o caro, porque te dá uma confiança que você não conquistou. Todas as armadilhas abaixo são do segundo tipo, e eu descobri cada uma delas do jeito difícil.
Os exemplos usam nomes de classes do ag-grid e Cypress, mas os modos de falha valem para qualquer grid virtualizado: TanStack Virtual, Handsontable, Kendo, uma tabela com windowing feita à mão.
Seis armadilhas, seis falsos verdes. Vamos começar por uma coluna que você já ocultou.
1. Oculta, mas ainda no DOM 👻
Você esperaria que um recurso de ocultar coluna removesse as células. A flag hide do próprio ag-grid faz isso. Muitos recursos de ocultação construídos em cima de um grid, não. Oculte uma coluna zerando a largura dela, ou por meio de um controle customizado de "recolher este grupo", e o cabeçalho mais todas as células do corpo podem continuar montados, com largura computada 0. Oculto não significa ausente.
Então a asserção óbvia falha, mesmo com a ocultação funcionando:
cy.get('.ag-header-cell').should('have.length', 5)
Você tinha seis colunas e ocultou uma. A asserção falha, você "corrige" esperando 6, e agora o teste não consegue detectar uma coluna que não foi ocultada. Parabéns: ele passa com a ocultação funcionando ou não.
A aritmética de índices é ainda pior. Qualquer coisa que busque "a terceira coluna" por posição conta o fantasma de largura zero e lê uma célula uma posição à esquerda da que você queria. Em silêncio. Para sempre.
Filtre pela geometria renderizada, não pela presença:
const visibleHeaders = () =>
cy
.get('.ag-header-cell')
.filter((_, el) => el.getBoundingClientRect().width > 0)
visibleHeaders().should('have.length', 5)
Se o seu grid marca colunas ocultas com uma classe, prefira isso. É mais rápido e se autodocumenta:
const VISIBLE_CELL = '.ag-cell:not(.hidden-column)'
Depois, faça todo helper que recebe um índice de coluna passar por esse mesmo filtro. Um utilitário cellAt(row, col) que ignora colunas de largura zero por padrão, com uma flag opcional includeHidden para os testes que verificam especificamente a ocultação, elimina essa classe inteira de bug na origem.
Mais uma coisa para conferir enquanto você está mexendo nisso: a ocultação muitas vezes se comporta de um jeito numa visão pivotada e de outro numa visão plana, então valide as duas. Já vi um grid respeitar colunas ocultas na visão plana e renderizá-las com largura total na pivotada (sim, o mesmo grid).
2. Ids de linha que mudam quando você ordena
Esta aqui mata migrações. No row model client-side, uma linha de grupo recebe um id estável, derivado do campo e da chave:
<div class="ag-row" row-id="row-group-region-EMEA">
Aponte o mesmo grid para o row model server-side sem um callback getRowId e o id vira um contador: distribuído na ordem de carregamento, prefixado com as chaves do grupo pai. O grupo EMEA fica com o número que calhou de ser o próximo, e os filhos dele são numerados logo abaixo:
<div class="ag-row" row-id="3">
<div class="ag-row" row-id="EMEA-0">
Esse id codifica posição. Ordene o grid → outro id. Aplique um filtro → outro id. Deixe outro bloco carregar primeiro → outro id. A mesma linha lógica, toda vez.
Seletores construídos em cima dele passam na sua máquina e falham no CI sem nenhuma mudança de código, que é exatamente o perfil de bug que acaba sendo jogado na conta do runner.
Então nunca localize uma linha de grupo por id ou por nth-child. Localize pelo caminho de rótulos, do mesmo jeito que uma pessoa faria:
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')
}
Expanda ao longo do caminho e depois localize pelo rótulo da folha. Se lê como a user story e sobrevive à ordenação. Melhor ainda: é idêntico nos dois row models, então o mesmo helper atende suas specs client-side e server-side sem nenhuma ramificação.
O que me leva a portar specs. Quando você porta uma spec de um row model para o outro, a única diferença legítima deveria ser que o modelo server-side pode expandir sozinho o que o client-side precisa que você mande expandir. Se você se pegar reescrevendo seletores, é porque você os construiu sobre a coisa errada.
3. Cabeçalhos fora da tela não existem (e o not.exist não sabe a diferença)
A virtualização de cabeçalhos renderiza uma janela de colunas. Role para a esquerda e os cabeçalhos da extrema direita são removidos do DOM por completo.
Agora pegue a asserção negativa mais comum de uma suíte de grid:
cy.get('[col-id="year"]').should('not.exist')
Coluna removida com sucesso? Verde. Coluna presente, correta e simplesmente 900px à direita do viewport? Também verde. Isso é um falso verde, e o teste vai continuar reportando isso enquanto o projeto existir.
Há duas saídas, e você quer as duas.
Role de forma determinística antes de qualquer asserção posicional. Não por pixels. Peça ao grid que traga a coluna para a área visível, pelo mesmo handle em window que eu descrevo logo depois disto:
cy.window().its('gridApi').invoke('ensureColumnVisible', 'year')
cy.get('[col-id="year"]').should('be.visible')
Para ausência, faça a asserção contra o modelo de colunas, não contra o DOM. Exponha a API do grid em window no seu build de testes e pergunte diretamente a ela:
cy.window().then((win) => {
const ids = win.gridApi.getAllDisplayedColumns().map((c) => c.getColId())
expect(ids).to.not.include('year')
})
Doze linhas de código de produção, atrás de uma flag de ambiente, para expor esse objeto. Essa é a mudança de maior alavancagem que você pode fazer numa suíte de grid. Ela transforma "isso está renderizado agora?", que é uma pergunta de renderização, em "o grid acredita que essa coluna existe?", que é a pergunta que o seu teste estava tentando fazer desde o começo.
4. A coluna de grupo some com a rolagem
A coluna de grupo gerada automaticamente costuma vir sem pin por padrão. Então qualquer asserção do tipo "a primeira célula da linha contém o nome da região" se sustenta até o momento em que um teste rola na horizontal. Aí a coluna de grupo some, e a primeira célula renderizada passa a ser outra coisa.
Fixe a coluna na configuração do grid de teste, se puder. Se não puder, nunca descreva uma célula como "a primeira". Descreva pelo col-id e deixe o grid dizer onde ela está.
5. "No Rows To Show" é definitivo
Um grid vazio normalmente significa que uma busca de dados falhou e o grid engoliu o erro no overlay de estado vazio. Isso não é um carregamento lento. Nada nesse estado se resolve com retry, porque a requisição não vai voltar, então um retry do Cypress só queima o timeout inteiro de novo e a sua execução no CI fica minutos mais longa em troca de zero informação.
Um timeout maior também não vai te salvar. O que ajuda é uma proteção 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)
}
Recarregue uma vez e depois faça a asserção de verdade. Se vier vazio duas vezes, é um defeito real e você quer essa falha. O bug que eu persegui aqui só acontecia em um ambiente, um arquivo de dados que falhava no download no CI e em nenhum outro lugar (claro), e foi essa proteção que transformou um vermelho inexplicável em uma entrada de log de uma linha só.
6. A seleção que se desfaz sozinha
Clique numa célula, faça a asserção sobre a seleção e, às vezes, a seleção sumiu. O próprio efeito colateral do clique dispara um refresh do grid: clique → refresh → a linha re-renderiza → o estado de seleção da célula é limpo. Condição de corrida clássica: sua asserção e esse refresh estão num cara ou coroa.
Não resolva isso com um wait. Resolva selecionando de novo quando a seleção não pegar:
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()
}
Um cy.wait fixo aqui é uma aposta num timing que você não controla. Uma nova seleção condicional é uma aposta em que o grid vai acabar chegando ao estado que você pediu, e essa é uma aposta muito melhor.
Basicamente, quando o refresh ganha o cara ou coroa, o helper clica mais uma vez, e a sua asserção roda contra a seleção que você realmente pediu, em vez do que sobrou depois do re-render. Nada de chutar milissegundos.
Bons testes!
