Validar 91 clientes contra Excel en Playwright: el bug que Selenium no había encontrado

exceljs, page.evaluate para grillas virtualizadas con SlickGrid, soft assertions para validar 910 comparaciones en un solo test.

Error del test WOLZA Empresa Expected Wolski Zajazd Received Wolski dos espacios Zajazd con el doble espacio resaltado en rojo por Playwright
El cuarto error. "Wolski Zajazd" con dos espacios en el Excel vs "Wolski Zajazd" en la grilla. Yo no lo puse. Mi suite de Selenium nunca lo había detectado. Playwright lo marcó la primera vez.

Nota para el lector

Este post documenta cómo armé en Playwright el equivalente a mi ClientesTests de la serie de Selenium: leer un Excel con 91 clientes, leer la grilla web, y comparar campo a campo.

Si querés ir al grano:


La pregunta que abre este post

Antes de escribir una sola línea de código me hice esta pregunta:

En un caso real, ¿cómo se validan los datos de esta grilla? Son bastantes datos. ¿Con JSON? ¿Con Excel? ¿Se valida la grilla completa o no? ¿Qué es lo más profesional, lo más senior?

Y la respuesta honesta es: depende del contexto, y la mayoría de los tutoriales no te lo dicen.

En un proyecto de producción real, contra una app con datos que cambian todos los días, validar 91 filas de UI contra un Excel mantenido a mano es una mala idea. El día que alguien actualice un teléfono en la base, el Excel queda desactualizado, el test falla, y vos no fallaste por un bug — fallaste por data drift. Mantener eso es trabajo administrativo, no testing.

Para volumen de datos, lo correcto en producción es validar contra la API directamente. La UI no es el lugar para chequear que los 91 registros existen y están bien.

Pero acá no estoy en producción. Estoy en demo.serenity.is, una app de demo estática. Los 91 clientes del dataset Northwind no van a cambiar. El Excel de hoy va a seguir siendo válido en dos años. El data drift no existe.

Y hay tres razones más para hacerlo igual:

  1. Es una skill listable. Saber leer Excel desde un framework de automation es algo que muchas ofertas piden explícitamente. No mostrarlo sería dejar fuera algo que suma en CV.
  2. Mantengo paralelismo con mi serie de Selenium. Mi ClientesTests.java ya hace exactamente esto. Replicarlo en Playwright cierra el círculo de la migración.
  3. Si más adelante voy a integrar API testing con REST Assured o con request de Playwright, este post deja claro por qué la API es mejor para volumen — lo demuestro haciéndolo "mal" primero.

Así que lo hago, pero con los ojos abiertos sobre cuándo aplica y cuándo no. Eso ya es más senior que automatizar sin pensar.


Estructura nueva del proyecto

Hasta ahora mi proyecto de Playwright tenía solo pages/, tests/ y fixtures/. Para este post necesitaba tres carpetas nuevas:

playwright-typescript-framework/
├── test-data/
│   └── clientes-data.xlsx        ← copiado desde el repo de Selenium
├── types/
│   └── Cliente.ts                ← interface tipada
├── utils/
│   └── excelReader.ts            ← función para leer el .xlsx
├── pages/
│   └── ClientesPage.ts           ← extendida con leerGrillaCompleta()
└── tests/
    └── serenity-clientes-excel.spec.ts

Tres decisiones:

test-data/ en la raíz, no adentro de tests/. Si mañana sumo más fixtures (JSON, CSV, archivos para upload), todo vive en un solo lugar predecible. Mezclar data con código de tests se vuelve un lío rápido.

types/ para una sola interface. Esto es ventaja directa de TypeScript sobre Java: declarás el contrato una vez (interface Cliente) y lo reusás en el reader del Excel, en el page de la grilla y en el test. El compilador te garantiza que las tres fuentes hablan el mismo idioma. En Java tendría que crear una clase con getters y setters y mantenerla sincronizada a mano.

No creé data/ClientesTestData.ts como hice en Java. En TypeScript con Playwright no existe el equivalente a @DataProvider con clase dedicada — el patrón idiomático es importar el reader directamente en el spec. Forzar la estructura Java acá sería ceremonia innecesaria.

Árbol de archivos del proyecto Playwright TypeScript mostrando las carpetas nuevas test-data types utils y el spec serenity-clientes-excel.spec.ts
Tres carpetas nuevas en la raíz: test-data para el .xlsx, types para la interface Cliente, utils para el reader. El spec vive en tests/ como el resto.

Por qué exceljs y no xlsx

Las dos librerías más usadas para leer Excel desde Node son xlsx (también conocida como SheetJS) y exceljs. Elegí exceljs por tres razones concretas:

  1. Mejor mantenida. xlsx arrastra issues de seguridad y tiene release lento. exceljs está activa y responde a issues.
  2. API async nativa. Encaja con el modelo async/await de Playwright sin envolturas.
  3. TypeScript sin instalar tipos aparte. xlsx requiere @types/xlsx. exceljs viene tipado de fábrica.

Instalación:

npm install --save-dev exceljs

El --save-dev lo guarda en devDependencies, no en dependencies. Razón: exceljs se usa solo para tests, no es código de producción. Misma lógica por la que @playwright/test está en devDependencies.

Terminal con npm install --save-dev exceljs mostrando added 98 packages y found 0 vulnerabilities junto al package.json con exceljs en devDependencies
exceljs queda en devDependencies al lado de @playwright/test. Los warnings deprecated son de paquetes transitivos — ruido conocido del ecosistema Node.

Cuando lo instalé apareció una catarata de npm warn deprecated de paquetes internos como inflight, glob@7, rimraf@2. Todos transitivos — son dependencias de exceljs, no las uso yo directamente. found 0 vulnerabilities confirma que no hay nada explotable. Son avisos de "esto va a dejar de mantenerse en el futuro", no "está roto ahora".

Esto es algo que en Java con Maven se siente menos: cada dependencia que declarás es la que usás, punto. Node tiene árboles de dependencias más grandes y más ruidosos. No es bueno ni malo, es distinto.


La interface Cliente

Antes del reader, el contrato. types/Cliente.ts:

export interface Cliente {
  id: string;
  empresa: string;
  contacto: string;
  titulo: string;
  region: string;
  codigoPostal: string;
  pais: string;
  ciudad: string;
  telefono: string;
  fax: string;
  representantes: string;
}

Once campos, todos string. En Java esto sería una clase con once getters y once setters. Acá son once líneas declarativas. El compilador me va a marcar en rojo cualquier lugar donde falte un campo o sobre uno.


El reader del Excel

utils/excelReader.ts:

import ExcelJS from 'exceljs';
import { Cliente } from '../types/Cliente';

/**
 * Lee un archivo Excel con datos de clientes y devuelve un Map indexado por ID.
 *
 * El Excel debe tener la hoja "Clientes" con las columnas en este orden:
 * A: ID, B: Empresa, C: Contacto, D: Titulo, E: Region,
 * F: CodigoPostal, G: Pais, H: Ciudad, I: Telefono, J: Fax, K: Representantes
 *
 * @param rutaArchivo - Ruta al archivo .xlsx (relativa al cwd o absoluta)
 * @returns Map<string, Cliente> donde la key es el ID del cliente
 */
export async function leerClientesDesdeExcel(
  rutaArchivo: string
): Promise<Map<string, Cliente>> {
  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 = new Map<string, Cliente>();

  // eachRow itera empezando en 1 (no en 0).
  // includeEmpty: false salta filas completamente vacías.
  hoja.eachRow({ includeEmpty: false }, (row, rowNumber) => {
    // Salteamos la fila 1 (headers)
    if (rowNumber === 1) return;

    // row.getCell(N) — las columnas también empiezan en 1, no en 0.
    // .text devuelve el valor como string (más seguro que .value, que puede ser
    // number, Date, formula object, etc. dependiendo del tipo de celda).
    const cliente: Cliente = {
      id: row.getCell(1).text.trim(),
      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(),
    };

    // Si la fila no tiene ID, la salteamos (fila basura)
    if (!cliente.id) return;

    clientes.set(cliente.id, cliente);
  });

  return clientes;

}

Devuelve un Map<string, Cliente> indexado por ID — el mismo patrón que ya usaba en Java con Map<String, String[]>. Buscar por ID es O(1) y el orden físico del Excel no importa.

Las dos mordidas de exceljs

Las filas y columnas empiezan en 1, no en 0. getCell(1) es la columna A. Si venís de Apache POI en Java es lo mismo. Si venís de pensar en arrays JS, te confunde la primera vez. Es la primera mordida clásica.

.text en vez de .value. Cada celda tiene dos propiedades: value devuelve el dato en su tipo nativo (number, Date, formula object, etc.) y text devuelve siempre el string como lo verías en Excel. Para validar contra una grilla web (donde todo es texto), .text es lo que querés. Te ahorra conversiones manuales y bugs sutiles donde una celda numérica no compara con su versión renderizada.

Test rápido del reader

Antes de seguir, un debug minimal para confirmar que lee bien:

const ruta = path.join(__dirname, '..', 'test-data', 'clientes-data.xlsx');
const clientes = await leerClientesDesdeExcel(ruta);

console.log(`Total clientes leídos: ${clientes.size}`);

Resultado:

Total clientes leídos: 91
Primeros 3:
  DRACD: Drachenblut Delikatessen - DE Germany
  DUMON: Du monde entier - FR France
  EASTCs: Eastern Connection - GB UK
Terminal con el debug del excelReader mostrando Total clientes leídos 91 y los primeros 3 DRACD Drachenblut DUMON Du monde entier EASTCs Eastern Connection
91 clientes leídos. El orden refleja el Excel físico, no alfabético. Tres detalles a anotar: prefijo de país "DE Germany", y el sospechoso "EASTCs" con s minúscula.

91 clientes, perfecto. Pero anoten los primeros 3 — tres detalles importantes que vamos a usar después:

_ El orden refleja el orden físico del Excel, no alfabético. El Excel está desordenado.

Hoja Clientes del archivo clientes-data.xlsx mostrando columnas ID Empresa Contacto Titulo Region CodigoPostal Pais Ciudad Telefono Fax Representantes
El mismo .xlsx que uso en mi serie de Selenium. Once columnas, 91 filas, orden no alfabético. Tres errores intencionales escondidos adentro — y uno que no era intencional.

_ El campo país viene con prefijo de dos letras: DE Germany, FR France, GB UK. Esto va a ser importante.

_ EASTCs tiene una s minúscula al final. Sospechoso. Los IDs del Northwind suelen ser de cinco letras mayúsculas.


El error que casi cometí: anticipar problemas que no existen

Cuando vi DE Germany, mi reflejo fue agregar una función para limpiar el prefijo:

function limpiarPrefijoPais(valorCelda: string): string {
  const texto = valorCelda.trim();
  const match = texto.match(/^[A-Z]{2}\s+(.+)$/);
  return match ? match[1] : texto;
}

Lo agregué, lo testée, funcionó. DE GermanyGermany. Listo.

Y casi rompo todo.

Más adelante, cuando leí la grilla web por primera vez, descubrí que la grilla también muestra DE Germany, no Germany. La banderita es una imagen aparte; el código de país está en el DOM como texto. Ambos lados (Excel y grilla) tienen el mismo formato.

Si hubiese dejado el limpiarPrefijoPais, mi test habría fallado en los 91 países como falsa diferencia: Excel diciendo Germany y grilla diciendo DE Germany. Habría perdido tiempo investigando un problema que yo mismo me creé.

Lección concreta: no resuelvas problemas que no aparecieron todavía. Saqué la función. El reader vuelve a leer el país tal cual está en el Excel. Y la moraleja queda anotada para cualquiera que lea esto: la voz de "anticipo todo" es la voz que rompe las cosas. Build first, fix what breaks.


Leer la grilla: el problema de SlickGrid

La grilla de Serenity usa SlickGrid, un componente que virtualiza el DOM. Esto significa que aunque la grilla diga "Mostrando 1 hasta 91 de 91 registros", solo las filas visibles en el viewport están renderizadas en el HTML. Si scrolleás, las filas de arriba se desmontan del DOM y se montan las nuevas.

Si hago page.locator('div.slick-row').all() esperando los 91 registros, recibo solo los ~20 que están visibles en pantalla. Las otras 71 no existen en el DOM hasta que scrolleás.

En Selenium ya había resuelto esto con JavascriptExecutor para scrollear y leer en bloque. La estrategia es la misma en Playwright, pero la ejecución cambia bastante.

Grilla de Clientes en demo.serenity.is con 91 clientes ordenados alfabéticamente por ID desde ALFKI hasta DUMON y contador Mostrando desde 1 hasta 91 de 91
La grilla usa SlickGrid: aunque el contador dice "91 de 91", solo las filas visibles en el viewport están renderizadas en el DOM. Las otras aparecen al scrollear.

Primer intento: usar Locators de Playwright (y por qué falla)

Mi primera versión de leerGrillaCompleta usaba la API "linda" de Playwright: scrollear, hacer grillaFilas.all() para tomar las filas visibles, y para cada fila recorrer las celdas con locator('div.slick-cell.l0'), l1, l2, etc., leyendo innerText().

Lo corrí. Timeout a los 30 segundos.

Test timeout of 30000ms exceeded.
Error: locator.count: Test timeout
Error de Playwright Test timeout of 30000ms exceeded con stack trace apuntando a celda.count() en el método leerFila de ClientesPage línea 158
El primer intento con Locators. Timeout en celda.count() porque las filas se despegan del DOM entre que las agarro y trato de leerlas. Staleness clásico.

Me llevó un rato entender por qué. Dos razones, ambas importantes:

1. Los Locators de Playwright son lazy. Cuando hacés grillaFilas.all(), no recibís un array de elementos congelados — recibís un array de "consultas pendientes". Cada vez que usás un Locator, Playwright vuelve a buscar el elemento en el DOM. El problema con SlickGrid: entre que tomás las filas y empezás a leer las celdas, el DOM ya cambió. Las filas que habías "agarrado" están desmontadas. El Locator queda esperando a un elemento que nunca va a aparecer.

2. Es lentísimo aunque funcionara. Cada lectura de celda son dos viajes al browser (uno para count(), otro para innerText()). 11 columnas × 20 filas × varios scrolls = cientos de round-trips Node↔browser, cada uno costando algunos milisegundos. Suma rápido.


Segundo intento: bajar a page.evaluate

La solución correcta: hacer toda la lectura de las filas visibles en una sola llamada a page.evaluate() por cada posición de scroll. En lugar de iterar con Locators, ejecuto JavaScript directo en el browser que recorre el DOM completo y devuelve los datos como un array. Una sola ida y vuelta por scroll, sin staleness, mucho más rápido.

El método final, en pages/ClientesPage.ts:

async leerGrillaCompleta(): Promise<Map<string, Cliente>> {
  await this.esperarGrillaCargada();
  const clientes = new Map<string, Cliente>();

  // Dimensiones del viewport
  const dimensiones = await this.grillaViewport.evaluate((viewport) => ({
    alturaTotal: viewport.scrollHeight,
    alturaVisible: viewport.clientHeight,
  }));

  // Reset al tope
  await this.grillaViewport.evaluate((vp) => { vp.scrollTop = 0; });
  await this.page.waitForTimeout(300);

  // Scroll de a medio viewport para solapar y no perder filas
  const paso = Math.floor(dimensiones.alturaVisible / 2);

  for (let pos = 0; pos <= dimensiones.alturaTotal; pos += paso) {
    await this.grillaViewport.evaluate(
      (vp, scrollPos) => { vp.scrollTop = scrollPos; },
      pos
    );

    await this.page.waitForTimeout(200);

    // UNA sola llamada al browser que extrae todas las filas visibles
    const filasData = await this.page.evaluate(() => {
      const filas = document.querySelectorAll('div.slick-row');
      const resultado: string[][] = [];
      for (const fila of filas) {
        const valores: string[] = [];
        for (let col = 0; col < 11; col++) {
          const celda = fila.querySelector(`div.slick-cell.l${col}`);
          valores.push(celda ? (celda.textContent ?? '').trim() : '');
        }
        resultado.push(valores);
      }
      return resultado;
    });

    // Procesamos en Node, sin tocar más el browser
    for (const valores of filasData) {
      const id = valores[0];
      if (!id || clientes.has(id)) continue;
      clientes.set(id, {
        id: valores[0],
        empresa: valores[1],
        contacto: valores[2],
        titulo: valores[3],
        region: valores[4],
        codigoPostal: valores[5],
        pais: valores[6],
        ciudad: valores[7],
        telefono: valores[8],
        fax: valores[9],
        representantes: valores[10],
      });
    }
  }

  return clientes;
}

Tres puntos que vale la pena explicar:

1. page.evaluate corre en el browser, no en Node. La función arrow se serializa, se envía al browser, se ejecuta en el contexto del DOM real, y el resultado vuelve a Node como JSON. El parámetro viewport adentro del callback es el HTMLElement real, no un Locator. No podés usar variables de Node ahí adentro. Es el equivalente a executeScript de Selenium, pero tipado y formateado por el editor.

2. waitForTimeout(200) es justificado acá. En Playwright los waitForTimeout se consideran code smell y casi siempre hay que reemplazarlos por waits inteligentes. Pero acá es uno de los pocos casos donde es lo correcto: no estoy esperando que aparezca un elemento, estoy esperando que SlickGrid termine de renderizar las filas para la nueva posición de scroll. No hay un evento DOM ni un atributo que cambie y pueda esperar. 200ms es empírico — lo mínimo que funciona consistentemente.

3. El Map deduplica por ID automáticamente. Como solapo el scroll de a medio viewport, una misma fila puede aparecer en dos pasos seguidos. El clientes.has(id) evita reescribirla.

Corrida:

Total clientes leídos de la grilla: 91
Primeros 3:
  ALFKI: Alfreds Futterkiste | DE Germany | Berlin
  ANATR: Ana Trujillo Emparedados y helados | MX Mexico | México D.F.
  ANTON: Antonio Moreno Taquería | MX Mexico | México D.F.
Últimos 3 IDs leídos:
  WHITC, WILMK, WOLZA
Terminal con corrida exitosa mostrando Total clientes leídos de la grilla 91 primeros ALFKI ANATR ANTON y últimos 3 WHITC WILMK WOLZA en 9.5 segundos
Segunda versión con page.evaluate. 91 clientes en 9.5 segundos. ALFKI primero, WOLZA último — confirma que la grilla ordena por ID ascendente por defecto.

91 filas. La grilla por defecto está ordenada alfabéticamente por ID, ALFKI primero, WOLZA último. La lectura completa tarda ~9 segundos, razonable considerando que hace múltiples scrolls y lee 91 × 11 = 1001 celdas.


Bonus: cuando Chromium se cuelga con poca RAM

Antes de las dos corridas exitosas, las dos primeras me dieron errores raros:

GPU process launch failed: error_code=63
The GPU process has crashed 6 time(s)
Worker teardown timeout of 30000ms exceeded

Y después:

FATAL ERROR: Allocation failed - JavaScript heap out of memory
worker process exited unexpectedly (code=134)
Error de Chromium con GPU process has crashed 6 times y FATAL ERROR JavaScript heap out of memory con stack trace nativo durante la corrida de tests
Procesos zombies de Chromium + VS Code + IntelliJ + 8 GB de RAM = crash. No es bug del test, es realidad de correr browsers en local con memoria justa. Cerrar todo y reabrir.

Esto no era un bug del test. Eran procesos zombies de Chromium de corridas anteriores que quedaron colgados consumiendo memoria, sumado a que tenía VS Code, IntelliJ y varios chromiums abiertos en una notebook con 8 GB de RAM.

Solución: cerrar VS Code, cerrar IntelliJ, abrir VS Code de nuevo, correr el test. Funcionó al primer intento.

Lo dejo anotado porque a cualquiera con un setup parecido le va a pasar tarde o temprano. No es culpa de Playwright — es la realidad de correr browsers reales en local con memoria justa.


El spec de comparación

Ya tengo las dos mitades funcionando: el reader del Excel y el reader de la grilla, ambos devolviendo Map<string, Cliente>. El test ahora es la parte fácil: iterar el Excel, buscar cada cliente en el Map de la grilla, comparar campo a campo.

tests/serenity-clientes-excel.spec.ts:

import { test, expect } from '@playwright/test';
import { ClientesPage } from '../pages/ClientesPage';
import { DashboardPage } from '../pages/DashboardPage';
import { leerClientesDesdeExcel } from '../utils/excelReader';
import path from 'path';

test('los 91 clientes de la grilla coinciden con el Excel', async ({ page }) => {
  // 1. Leer Excel
  const rutaExcel = path.join(__dirname, '..', 'test-data', 'clientes-data.xlsx');
  const clientesExcel = await leerClientesDesdeExcel(rutaExcel);

  // 2. Navegar a la grilla
  const dashboard = new DashboardPage(page);
  const clientesPage = new ClientesPage(page);
  await page.goto('https://demo.serenity.is/');
  await dashboard.verificarVisible();
  await dashboard.irAClientes();
  await clientesPage.verificarVisible();

  // 3. Leer grilla
  const clientesGrilla = await clientesPage.leerGrillaCompleta();

  // 4. Validar conteo
  expect.soft(
    clientesGrilla.size,
    'Cantidad total de clientes (grilla vs Excel)'
  ).toBe(clientesExcel.size);

  // 5. Validar campo a campo
  for (const [id, esperado] of clientesExcel) {
    const real = clientesGrilla.get(id);
    if (!real) {
      expect.soft(real, `Cliente ${id} debería existir en la grilla`).toBeDefined();
      continue;
    }
    expect.soft(real.empresa, `${id} - Empresa`).toBe(esperado.empresa);
    expect.soft(real.contacto, `${id} - Contacto`).toBe(esperado.contacto);
    expect.soft(real.titulo, `${id} - Título`).toBe(esperado.titulo);
    expect.soft(real.region, `${id} - Región`).toBe(esperado.region);
    expect.soft(real.codigoPostal, `${id} - Código Postal`).toBe(esperado.codigoPostal);
    expect.soft(real.pais, `${id} - País`).toBe(esperado.pais);
    expect.soft(real.ciudad, `${id} - Ciudad`).toBe(esperado.ciudad);
    expect.soft(real.telefono, `${id} - Teléfono`).toBe(esperado.telefono);
    expect.soft(real.fax, `${id} - Fax`).toBe(esperado.fax);
    expect.soft(real.representantes, `${id} - Representantes`).toBe(esperado.representantes);
  }
});

Tres decisiones del código:

1. Un solo test, no 91 tests data-driven. Podría haber usado el patrón for...of con test() adentro como en el Post 8, generando un test por cada cliente. No tiene sentido acá: 91 tests significan 91 setups, 91 navegaciones, 91 lecturas con scroll. Tardaría minutos. Con un solo test, leo la grilla una sola vez y comparo contra el Map ya cargado. Además, 91 tests verdes con 4 rojos en el reporte es ruido. Un solo test con 4 fallas claras es más legible.

2. expect.soft en lugar de expect. Las soft assertions acumulan fallas en lugar de cortar al primer error. Al final del test, si hubo al menos una falla, el test falla — pero entre medio ejecuta todo. Para validación de volumen, esta es la única estrategia útil: quiero ver todas las diferencias en una sola corrida, no ir descubriendo errores de a uno.

Esto contradice algo que dije en el post (Data-driven testing en Playwright): "soft assertions no deberían ser globales". Y sigue siendo cierto en aquel contexto. Acá el contexto es distinto — un test único validando 910 cosas. Misma herramienta, distinto criterio según contexto. Eso es lo que termina siendo "senior": no es saber la respuesta correcta, es saber qué pregunta hacerse antes.

3. Iterar el Excel, no la grilla. El Excel es la fuente de verdad — define qué clientes deberían estar y con qué datos. Si iterara la grilla, no detectaría clientes que están en el Excel pero no en la grilla (registros faltantes). Iterando el Excel sí los detecto, con el if (!real). Cuando comparás dos fuentes, siempre iterás la "esperada" contra la "real", no al revés.

4. El segundo argumento de expect.soft. Ese mensaje custom (${id} - Empresa) es lo más importante del test. Sin él, cuando una assertion falle, el reporte dice algo como "Expected: 'X', Received: 'Y'" sin contexto. Con él, vas a ver "FOLKO - País: Expected 'Sweden', Received 'Norway'" y sabés instantáneamente cuál cliente y cuál campo. En un test que hace 910 comparaciones, esto es la diferencia entre debuggear 5 minutos o 2 horas.


Los errores que aparecieron

Corrí el test esperando que fallara — el Excel que uso es el mismo que tenía en mi serie de Selenium, y le había metido 3 errores intencionales para mostrar cómo se reportaban en Allure. Esperaba ver esos 3.

Aparecieron 4.

Reporte de Playwright mostrando los primeros errores del test Cliente EASTCs no encontrado FISSA Título Accounting Managers vs Manager y CENTC País Mexicos vs Mexico
Tres de los cuatro errores que yo mismo había puesto en el Excel: EASTCs con s de más, FISSA con "Managers" en plural, CENTC con "Mexicos". Los esperaba.

Error 1: Cliente EASTCs debería existir en la grilla pero no se encontró

El Excel tiene el ID escrito como EASTCs (con s minúscula al final). En la grilla está como EASTC. El Map no encontró match porque las keys son distintas: "EASTCs" !== "EASTC".

Este es un typo del Excel, no un bug de la app. Caso clásico: la fuente de verdad miente.

Error 2: FISSA - Título

Expected: "Accounting Managers"   ← Excel
Received: "Accounting Manager"    ← Grilla

Excel tiene "Managers" en plural, grilla "Manager" en singular. Otro typo intencional.

Error 3: CENTC - País

Expected: "MX Mexicos"   ← Excel
Received: "MX Mexico"    ← Grilla

"Mexicos" en el Excel, "Mexico" en la grilla. Tercer typo intencional.

Hasta acá, todo lo que esperaba. Tres errores que yo mismo había metido en el dataset de Selenium para demostrar cómo se reportan las fallas. Pero apareció uno más.


El cuarto error: WOLZA — el bug que Selenium nunca había encontrado

Expected: "Wolski Zajazd"      ← Grilla
Received: "Wolski  Zajazd"     ← Excel (doble espacio)

El Excel tiene Wolski Zajazd con dos espacios entre las palabras. La grilla tiene un solo espacio. Yo no introduje este error. El Excel ya estaba así. Yo lo había usado durante toda mi serie de Selenium con ClientesTests.java y nunca había notado nada.

Mi suite de Selenium pasaba en verde (excepto los 3 errores intencionales), y este bug estaba sentado ahí, escondido entre dos espacios. Imposible verlo a simple vista en una hoja de cálculo. Imposible verlo en una grilla web donde el navegador colapsa los espacios al renderizar.

Error del test WOLZA Empresa Expected Wolski Zajazd Received Wolski dos espacios Zajazd con el doble espacio resaltado en rojo por Playwright
El cuarto error. "Wolski Zajazd" con dos espacios en el Excel vs "Wolski Zajazd" en la grilla. Yo no lo puse. Mi suite de Selenium nunca lo había detectado. Playwright lo marcó la primera vez.

Lo que más me llama la atención es cómo lo muestra Playwright: en la línea Received resalta exactamente el carácter de más en rojo. Si no fuera por ese resaltado, ver el doble espacio entre Wolski y Zajazd sería casi imposible — son dos espacios pegados, no se nota.

¿Por qué Selenium con TestNG no lo detectó? Honestamente, no lo sé. Mi Assert.assertEquals debería haberlo agarrado igual. Puede ser que tuviera algún .trim() de más en algún lado, o que el assertion message no fuera lo suficientemente granular como para que yo lo viera. Voy a tener que volver a la suite de Java a chequear. Pero el hecho concreto es: durante toda la serie de Selenium, este bug estaba ahí. Playwright lo encontró la primera vez que corrí el test.

Esto es lo que más me hace pensar de todo el ejercicio. Más que las decisiones de arquitectura, más que el pattern de leer la grilla, más que la elección de exceljs. La automatización de QA no se trata solo de "automatizar lo que ya hago manual". Se trata de detectar lo que el ojo humano se pierde. Un doble espacio en una celda de Excel no se ve. Una assertion automatizada con un buen reporte sí.

Y para ser totalmente honesto: este post iba a ser sobre validar 91 clientes con Excel. Terminó siendo sobre encontrar un bug que llevaba meses escondido en mi propio dataset.


Comparación rápida: Selenium + TestNG vs Playwright + TS

Aspecto Selenium + Java Playwright + TS
Lectura de Excel Apache POI (sync) exceljs (async)
Tipado de los datos Clase con getters/setters Interface (11 líneas)
Lectura masiva del DOM JavascriptExecutor.executeScript con string de JS page.evaluate con función TS tipada
Soft assertions SoftAssert de TestNG expect.soft nativo
Mensaje custom en assertion Tercer argumento de assertEquals Segundo argumento de expect.soft
Reporte de diferencias Texto plano, requiere leer con cuidado Diff visual con resaltado de chars

Las dos suites hacen lo mismo. Pero la experiencia de leer un fallo es bastante distinta. El resaltado visual del doble espacio en WOLZA no es solo una mejora estética — es lo que hace que un bug invisible pase a ser obvio.


Próximo post

Este post cubrió la validación de datos de la grilla. La grilla tiene mucho más para testear: filtros (País, Ciudad, Representantes), ordenamiento por columna, búsqueda por texto, paginación. Todas esas son interacciones de UI que no necesitan Excel — son tests más cortos, más rápidos, más estables, y cubren las funcionalidades reales que un usuario usa todos los días.

En el próximo post: testing de grilla — filtros, ordenamiento y búsqueda en SlickGrid con Playwright. Sin Excel, sin scroll programático, sin volumen. Solo interacciones reales.

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