Testing de grilla en Playwright: ordenamiento, filtros Select2 y búsqueda en SlickGrid

6 tests para ordenar, filtrar con Select2 y buscar en SlickGrid. pressSequentially vs fill, codegen para debuggear Select2, cross-browser timing.

Reporte Playwright 19 de 19 passed 0 failed en Chromium Firefox y WebKit con 6 tests de interacciones de grilla y auth setup en 1.8 minutos
19/19. 6 tests × 3 browsers + 1 setup. Todo verde. 1.8 minutos.

Nota para el lector

Este es el Post 10 de la serie Playwright + TypeScript. Viene directo del Post 9 (Validar 91 clientes contra Excel en Playwright) donde validé 91 clientes contra un Excel.

Si querés ir al grano:


El contexto: qué faltaba después del Post 9: Validar 91 clientes contra Excel en Playwright

El Post 9 cubrió la validación de datos de la grilla: leer un Excel, leer la grilla scrolleando, comparar campo a campo. Encontré 4 errores, incluido un doble espacio invisible que Selenium nunca había detectado.

Pero una grilla no es solo datos. Un usuario real no abre la grilla para verificar que los 91 clientes están bien escritos. Un usuario real:

  • Ordena por columna para encontrar algo rápido.
  • Filtra por país o ciudad para acotar.
  • Busca por nombre o ID.

Esas son las interacciones que faltan. Y son tests más cortos, más rápidos y más estables que la validación con Excel — porque no necesitan scroll programático ni archivos externos. Solo interacción con la UI.


Lo que necesité antes de escribir código: inspeccionar el HTML

Antes de escribir un solo locator, abrí DevTools y fui a buscar la estructura de cada control. No usé codegen para esto — codegen graba acciones y genera locators, pero no muestra la estructura del HTML. Para entender qué tipo de componente es cada filtro, necesitaba el panel de Elements.

Los filtros: Select2

Serenity usa Select2 para los dropdowns de País, Ciudad y Representantes. No son <select> nativos del HTML. Son componentes JavaScript custom con esta estructura:

  • Cada filtro vive en un div.quick-filter-item con un atributo data-qffield que identifica el campo ("Country", "City", etc.).
  • Al abrir el dropdown, Select2 crea un contenedor global con un input de búsqueda y una lista de opciones como <li> con role="option".
  • Al seleccionar, el valor aparece en un <span class="select2-chosen"> con un botón <abbr class="select2-search-choice-close"> para limpiar (la ×).
DevTools mostrando estructura HTML del dropdown País con Select2 abierto y Argentina resaltada como li con div select2-result-label role option
Select2 no es un select nativo. Es un div con ul, li y role="option". Saber eso antes de escribir locators ahorra tiempo.

El contador

El texto "Mostrando desde 1 hasta 91 de 91 registros totales" vive en un <span class="slick-pg-stat">. Cuando no hay resultados, cambia a "No hay registros" — esto me mordió después.

DevTools mostrando span slick-pg-stat con texto Mostrando desde 1 hasta 91 de 91 registros totales resaltado en la página de Clientes
El contador. Un span con clase slick-pg-stat. Cuando no hay resultados cambia a "No hay registros" — sin números. Eso me mordió después.

Los headers de columna

Cada header es un div.slick-header-column con un atributo data-id que identifica la columna ("CustomerID", "CompanyName", etc.). Cuando una columna está ordenada, agrega la clase slick-header-column-sorted.

DevTools mostrando div slick-header-column con data-id CustomerID e id _sleekgrid_1_CustomerID y span slick-column-name con texto ID
El header. data-id="CustomerID", no "_sleekgrid_1_CustomerID". Confundir id con data-id me costó un timeout de 30 segundos.

Dato importante: en el HTML, el atributo id del header es _sleekgrid_1_CustomerID, pero el atributo data-id es solo CustomerID. Usé el id por error al principio — el locator no encontraba nada. Más sobre esto después.


Los locators nuevos en ClientesPage.ts

Con la estructura clara, agregué los locators al Page Object. Todo en el constructor, como los anteriores:

// Filtros
this.filtroPais = page.locator('div.quick-filter-item[data-qffield="Country"]');
this.filtroCiudad = page.locator('div.quick-filter-item[data-qffield="City"]');
this.contadorRegistros = page.locator('span.slick-pg-stat');

// Headers
this.headerID = page.locator('div.slick-header-column[data-id="CustomerID"]');
this.headerEmpresa = page.locator('div.slick-header-column[data-id="CompanyName"]');
Código del constructor de ClientesPage mostrando los locators nuevos de filtros con data-qffield Country y City y headers con data-id CustomerID y CompanyName
Los locators nuevos. Filtros por data-qffield, headers por data-id. Semánticos, no posicionales.

Dos decisiones:

data-qffield para los filtros, no CSS genérico. Podría haber usado .quick-filter-item:nth-child(1) para el primer filtro. Pero si Serenity reordena los filtros o agrega uno nuevo, el test se rompe. data-qffield="Country" es semántico — dice qué es, no dónde está.

data-id para los headers, no id. El id del header tiene un prefijo generado (_sleekgrid_1_). El data-id es limpio: CustomerID, CompanyName. Menos frágil.


Los métodos nuevos

Después de los locators, los métodos. Separé por responsabilidad: ordenamiento, filtros, contador, búsqueda.

Ordenamiento

Código del método ordenarPorID con click en headerID y waitForTimeout 500ms para esperar re-renderizado de SlickGrid
Un click y 500ms de espera. Empezó siendo 300ms — Firefox necesitaba más.
Código del método obtenerPrimerIDVisible que lee la primera fila visible de la grilla usando grillaFilas first y locator de slick-cell con COL_ID
Leer la primera fila. Sin scroll, sin page.evaluate. Solo lo que está visible en el viewport.

Un click en el header, esperar 500ms a que SlickGrid re-renderice, y leer la primera fila. No necesita scroll — solo leo lo que está visible.

El waitForTimeout(500) empezó siendo 300ms. Chromium andaba bien con 300, pero Firefox necesitaba más. Subí a 500 para los 3 browsers. No es elegante, pero SlickGrid no emite un evento DOM observable cuando termina de reordenar — no hay un atributo que cambie ni un elemento que aparezca. El timeout es el costo de testear grillas virtualizadas.

Filtros con Select2

Código del método filtrarPorPais con click en select2-choice y getByRole option con name del país usando el patrón descubierto por codegen
Codegen resolvió Select2 en 2 líneas. Mi versión original tenía 4 y no funcionaba.

Este método tiene historia. La primera versión hacía click en el dropdown, buscaba el input.select2-input.select2-focused, tipeaba el nombre del país, y clickeaba la opción. No funcionó — el filtro no se aplicaba, el contador seguía en 91.

La solución vino de codegen. Corrí npx playwright codegen demo.serenity.is, seleccioné Argentina manualmente, y miré qué generaba. Codegen no usaba el input de búsqueda: hacía click en .select2-choice y después getByRole('option', { name: 'Argentina' }). Dos líneas, sin input intermedio.

Playwright codegen mostrando código generado para buscar alfki con getByRole textbox click y fill y la grilla filtrada mostrando solo ALFKI
Codegen para la búsqueda. Genera click + fill. Pero fill no dispara el debounce — tuve que usar pressSequentially.

Copié ese patrón. Funcionó al primer intento. Cuando un componente custom no responde a tu interacción programática, corré codegen y mirá cómo lo resuelve Playwright. A veces el approach más directo es el que funciona.

Limpiar filtros

Código del método limpiarFiltroPais con click en abbr select2-search-choice-close y waitForTimeout 500ms
Limpiar filtro. La × de Select2 es un abbr, no un button. Click directo.

La × de Select2 es un <abbr> — no un <button>. Lo encontré inspeccionando el HTML cuando tenía Argentina seleccionada. Click directo, esperar, listo.

DevTools mostrando grilla filtrada con Argentina y 3 registros con el abbr select2-search-choice-close resaltado en el panel Elements
Argentina aplicada, 3 de 3 registros. El abbr.select2-search-choice-close es la × para limpiar.

Contador: parsear el texto

Código del método obtenerTotalRegistros con check de No hay registros retornando 0 y regex para extraer el número del texto del contador
El regex parsea "de 91 registros totales" → 91. El if de "No hay registros" lo agregué después de que el test explotara buscando texto inexistente.

El regex extrae el número de "Mostrando desde 1 hasta 91 de 91 registros totales". Pero cuando busqué algo que no existe, el contador cambia a "No hay registros" — sin números. El regex no matcheaba y el test explotaba con un error poco claro.

Agregué el check de 'No hay registros' para retornar 0 directamente. Caso borde que solo descubrí cuando lo corrí.

Búsqueda: pressSequentially vs fill

Código del método buscar con click clear y pressSequentially con delay 50ms y waitForTimeout 1500ms con comentarios explicando la diferencia con fill
pressSequentially simula tecla por tecla. fill() setea el valor sin disparar keydown/keyup. Si tu app usa debounce, fill no alcanza.

Este método también tiene historia. La primera versión usaba fill('ALFKI'). Parecía razonable — es lo que Playwright recomienda. Pero no funcionó. La grilla no filtraba.

El problema: fill() setea el valor programáticamente, pero no dispara los eventos de teclado (keydown, keyup, input) que el buscador de Serenity necesita para activar el debounce del filtrado. Es como si escribieras el texto directamente en el atributo value del input — el valor está ahí, pero la app no se enteró.

La solución: pressSequentially(). Simula tecla por tecla, como un humano escribiendo. Cada tecla dispara los eventos. El buscador los detecta, activa el debounce, y filtra la grilla.

Codegen me lo confirmó: cuando tipeé "alfki" en el buscador, codegen generó dos líneas — un click() y un fill(). Pero fill() no funcionó en test. pressSequentially() sí.

El delay: 50 le da 50ms entre cada tecla. El waitForTimeout(1500) espera a que el debounce complete el filtrado. Empecé con 1000ms y Firefox no alcanzaba. 1500 funciona en los 3 browsers.

En Selenium no existía esta distinción. sendKeys() siempre simula tecla por tecla. En Playwright hay que elegir entre fill() (rápido, programático) y pressSequentially() (lento, realista). La diferencia importa cuando la app escucha eventos de teclado.


Los 6 tests

tests/serenity-grilla-interacciones.spec.ts:

Código del spec serenity-grilla-interacciones mostrando beforeEach con navegación y los 2 tests de ordenamiento por ID y por Empresa
Los tests de ordenamiento. Primer click asc, segundo click desc. beforeEach centraliza la navegación a Clientes.
Código del spec mostrando los 3 tests de filtros filtrar por Argentina con 3 clientes filtro combinado Argentina Buenos Aires y limpiar filtros vuelve a 91
Los tests de filtros. Filtrar, combinar, limpiar. El combinado terminó siendo 3 clientes, no 2 — los tres argentinos son de Buenos Aires.
Código del spec mostrando test de búsqueda con buscar ALFKI verificar una fila y buscar ZZZZZ_NO_EXISTE verificar 0 registros
El test de búsqueda. ALFKI devuelve 1 fila, texto inexistente devuelve 0. Dos escenarios en un solo test.

Tres cosas sobre la estructura:

beforeEach para la navegación. Cada test arranca en la grilla de Clientes. En vez de repetir goto + irAClientes en cada test, el beforeEach lo centraliza. Si la ruta de navegación cambia, corrijo en un solo lugar. Es el equivalente al @BeforeMethod de TestNG en Selenium.

Cada test es independiente. El test de filtros no depende de que el de ordenamiento haya corrido antes. Cada uno parte de la grilla limpia (91 registros, sin filtros, sin orden custom). Esta independencia es lo que permite correr tests en paralelo y en cualquier orden — Playwright lo hace por defecto.

El filtro combinado terminó siendo 3 clientes, no 2. Cuando armé los tests, asumí que filtrar Argentina + Buenos Aires daría 2. En realidad los 3 clientes argentinos (CACTU, OCEAN, RANCH) son todos de Buenos Aires. El dataset Northwind no tiene argentinos de otra ciudad. Lo descubrí cuando el test falló con "Expected 2, Received 3".


Primera corrida: 6 de 6 fallaron

Corrí todo por primera vez contra Chromium. Resultado:

6 failed, 1 passed (auth.setup)
Reporte Playwright con 6 tests fallados y solo auth setup passed mostrando errores de timeout en headers y expected 3 received 91 en filtros
Primera corrida. 6 de 6 fallaron. Tres problemas: locators de headers, interacción con Select2, y fill en vez de pressSequentially.

Ningún test del spec pasó. Tres problemas distintos.

Error 1: los headers no se encontraban (timeout 30s)

Error: locator.click: Test timeout of 30000ms exceeded.
waiting for locator('div.slick-header-column[data-id="_sleekgrid_1_CustomerID"]')

El locator buscaba data-id="_sleekgrid_1_CustomerID". El atributo real era data-id="CustomerID". Había confundido el id del elemento (que sí tiene el prefijo _sleekgrid_1_) con el data-id (que no lo tiene).

Fix: cambiar _sleekgrid_1_CustomerIDCustomerID y _sleekgrid_1_CompanyNameCompanyName.

Reporte detallado del test ordenar por ID mostrando timeout de 30 segundos esperando locator div slick-header-column data-id _sleekgrid_1_CustomerID
30 segundos esperando un locator que no existe. data-id no tiene el prefijo sleekgrid_1 — eso es el id del elemento, no el data-id.

Error 2: los filtros no filtraban (contador seguía en 91)

Expected: 3
Received: 91

El flujo de Select2 que había armado (click → buscar en input → click opción) no funcionaba. El filtro parecía abrirse pero no se aplicaba.

Corrí codegen (npx playwright codegen demo.serenity.is), seleccioné Argentina manualmente, y vi que codegen hacía algo más simple: .select2-choice click → getByRole('option', { name: 'Argentina' }) click. Sin input de búsqueda intermedio.

Fix: reemplazar el flujo de 4 pasos por 2 pasos, copiando el patrón de codegen.

Error 3: la búsqueda no buscaba (todas las filas seguían visibles)

Expected: ["ALFKI"]
Received: ["ALFKI", "ANATR", "ANTON", ...]

fill() seteaba el valor en el input pero no disparaba los eventos de teclado que el buscador necesita. Fix: reemplazar fill() por pressSequentially().


Segunda corrida:

Después de los 3 fixes, corrí de nuevo:

Reporte Playwright con 1 passed y 2 failed mostrando los tests de ordenamiento fallados con Expected WOLZA Received ALFKI después del fix de headers
Después del fix de headers: los dos tests de ordenamiento fallan. Tenía el orden invertido — primer click es asc, no desc.

Los dos tests de ordenamiento fallaban. Había asumido que el primer click en un header ordenaba descendente (WOLZA primero) y el segundo ascendente. Era al revés: primer click = ascendente, segundo = descendente.

Lo confirmé con los screenshots de la exploración manual. La grilla carga con ID ascendente por defecto. El primer click en ID refuerza ese orden (ALFKI primero). El segundo click lo invierte (WOLZA primero).

Fix: invertir las expectativas en los dos tests de ordenamiento.


Tercera corrida:

Corrí de nuevo:

Reporte detallado del test filtro combinado Argentina Buenos Aires mostrando Expected 2 Received 3 con los pasos de click en Select2 visibles
Esperaba 2 argentinos en Buenos Aires. Son 3. Los tres argentinos del dataset Northwind viven en Buenos Aires. Error mío en los datos esperados.

El filtro combinado Argentina + Buenos Aires esperaba 2 clientes pero recibía 3. Fui a la app, filtré manualmente: CACTU, OCEAN y RANCH. Los tres son de Buenos Aires. No hay argentinos de otra ciudad en el dataset Northwind.

Fix: cambiar expect(total).toBe(2)expect(total).toBe(3).


Cuarta corrida: 7/7 en Chromium

7 passed (46.0s)
Reporte Playwright 7 de 7 passed 0 failed en Chromium con los 6 tests de ordenamiento filtros y búsqueda verdes y auth setup en 46 segundos
7/7 en Chromium. Cuarta corrida, después de arreglar headers, Select2, ordenamiento invertido y datos esperados. Hora de probar Firefox y WebKit.

Todo verde en Chromium. Hora de correr en los 3 browsers.


Cross-browser: Firefox y WebKit

15 passed, 4 failed (3.2m)

Cuatro fallas, todas de timing:

Firefox — ordenar por Empresa: 300ms de espera no alcanzaban para que Firefox re-renderice la grilla. Subí a 500ms.

Firefox — limpiar filtros: mismo problema, 300ms insuficientes. Subí a 500ms.

WebKit — dos tests: la página tardaba más en cargar, timeouts en la navegación. Agregué { timeout: 15000 } al page.goto().

Una ronda más con Firefox y búsqueda: 1000ms de debounce no alcanzaba. Subí a 1500ms.

Reporte Playwright 15 passed 4 failed mostrando fallas de timing en Firefox para ordenar Empresa y limpiar filtros y en WebKit para ordenar Empresa y filtro combinado
Cross-browser. Firefox y WebKit necesitan más tiempo para re-renderizar. Los 4 fallos son de timing, no de lógica. Mismos tests, misma lógica, distinta paciencia.

Todos estos fixes son ajustes de timing, no de lógica. Los tests hacen lo correcto — solo necesitan más paciencia en browsers más lentos. En un framework de producción, esto se resolvería con waits inteligentes (esperar a que un atributo cambie, o a que el contador muestre un valor distinto). Pero SlickGrid no ofrece esos ganchos, así que los timeouts son el costo pragmático.


Corrida final: 19/19

19 passed (1.8m)

6 tests × 3 browsers + 1 setup = 19.

Reporte Playwright 19 de 19 passed 0 failed mostrando los 6 tests verdes en Chromium Firefox y WebKit con auth setup y tiempos entre 6.9 y 25.4 segundos
19/19. La corrida final. WebKit tarda el doble que Chromium en algunos tests — pero pasa. 1.8 minutos en total.

WANDK: el bug que no automaticé

Durante la exploración manual, antes de escribir los tests, descubrí algo raro. Hice click en el header ID para ordenar descendente. WOLZA apareció primero, como esperaba. Hice click de nuevo para volver a ascendente. ALFKI quedó primero. Pero WANDK apareció entre CONSH y DRACD — completamente fuera de orden alfabético.

Playwright UI Mode mostrando test ordenar por ID passed con pasos de click y expect visibles y la grilla a la derecha con WANDK apareciendo fuera de orden entre CONSH y DRACD
UI Mode. El test de ordenamiento pasa — verifica ALFKI y WOLZA. Pero mirá la grilla: WANDK aparece entre CONSH y DRACD, fuera de orden. Bug de renderizado de SlickGrid que el test no detecta porque solo lee la primera fila.

Al hacer un tercer click (otro ciclo de orden), WANDK se corrigía y aparecía donde debía.

Es un bug de renderizado de SlickGrid. La fila queda "pegada" en su posición anterior después del toggle de orden. Es intermitente — depende del timing del browser, la carga de la máquina, el viewport.

No hice un test para esto. Tres razones:

Es intermitente. Un test que pasa a veces y falla a veces genera desconfianza en la suite entera. Es peor que no tener test.

No es un bug que yo pueda arreglar. Es de SlickGrid o de cómo Serenity lo integra. Un test que detecta un bug sin fix posible se convierte en mantenimiento sin retorno — tenés que mantenerlo fallando "a propósito" o marcarlo como skip.

Lo que sí tiene valor es documentarlo. Que un QA detecte un bug de renderizado durante la exploración manual, entienda por qué no conviene automatizarlo, y lo documente como hallazgo — eso es criterio. No todo lo que se encuentra se automatiza. Saber qué automatizar y qué no es parte del trabajo.


Comparación con Selenium

Aspecto Selenium + Java Playwright + TS
Filtros Select2 JavascriptExecutor para abrir y seleccionar getByRole('option') directo
Ordenamiento Click en header con WebElement.click() Click en header con locator.click()
Búsqueda sendKeys() (siempre simula teclado) pressSequentially() (hay que elegirlo sobre fill())
Cross-browser Configurar drivers separados --project=chromium/firefox/webkit
Timing entre browsers Waits explícitos manuales Mismo problema, misma solución
Independencia de tests @BeforeMethod + lógica manual beforeEach nativo + aislamiento por defecto

La diferencia más importante no está en la tabla: es que en Selenium nunca hice estos tests de interacción de grilla. Mi ClientesTests.java solo validaba datos contra Excel. No testeaba ordenamiento, filtros ni búsqueda. Este post cubre funcionalidad que en la serie de Selenium no existe.


Estado actual del proyecto

tests/
├── serenity-grilla-interacciones.spec.ts  ← NUEVO (6 tests)
├── serenity-clientes-excel.spec.ts        ← Post 9 (1 test, 91 clientes)
├── serenity-data-driven.spec.ts           ← Post 8
├── serenity-storagestate.spec.ts          ← Post 7
├── serenity-fixtures.spec.ts              ← Post 5
├── serenity-assertions.spec.ts            ← Post 4
├── serenity-locators.spec.ts              ← Post 2
└── serenity/
    └── auth.setup.ts                      ← Post 7

Con 10 posts, la carpeta tests/ ya tiene 8 spec files. Cada uno es didáctico — muestra un concepto. Pero ninguno combina todo como lo haría un framework real de producción. Eso viene en el próximo post.


Próximo post

Reorganización del framework. Separar tests/learning/ (los specs didácticos de cada post) de tests/e2e/ (specs definitivos que combinan POM + fixtures + storageState + assertions + data-driven). El refactor que convierte esto de un laboratorio de aprendizaje en un framework con estructura de producción.

🔗 Todo el código de esta serie está en: github.com/cesarbeassuarez/playwright-typescript-framework