Data-driven testing en Playwright: un test, N casos, sin DataProvider
Login positivo, click directo, 3 negativos parametrizados con array y for...of. Regex en el banner y fail intencional probando aislamiento.
El problema
En Selenium con TestNG, cuando quería correr el mismo test con varios sets de datos usaba @DataProvider:
@Test(dataProvider = "credencialesInvalidas", dataProviderClass = TestData.class)
public void loginInvalido_muestraError(String caseName, String usuario, String password, String mensajeEsperado) {
LoginPage loginPage = new LoginPage();
loginPage.loginComo(usuario, password);
Assert.assertEquals(loginPage.obtenerMensajeError(), mensajeEsperado, caseName);
}
Y la data en una clase aparte:
@DataProvider(name = "credencialesInvalidas")
public static Object[][] credencialesInvalidas() {
return new Object[][] {
{"Password incorrecta", "admin", "mal", "Error de validación..."},
{"Usuario incorrecto", "mal", "serenity", "Error de validación..."},
{"Espacios en blanco", " ", " ", "Por favor, valide los campos..."}
};
}
Funciona. Pero tiene cosas que no me gustan:
- La data está en una clase separada con un decorador (
@DataProvider) que solo TestNG entiende - Los parámetros del test son posicionales (
String caseName, String usuario, String password...) — si me equivoco en el orden, falla raro - Es opaco: tenés que conocer TestNG para entender cómo se conectan los puntos
En Playwright no existe @DataProvider. No hay decorador, no hay annotation mágica. Lo que hay es JavaScript puro: un array de objetos y un for...of que genera múltiples test() en tiempo de ejecución.
Y eso, en la práctica, es más limpio.
Setup: leyendo el mensaje de error
Antes de armar los tests parametrizados, necesitaba un método en LoginPage para leer el banner rojo de error. En Selenium ya lo tenía. En Playwright todavía no.
Abrí codegen apuntando a la página de login:
npx playwright codegen --lang=javascript --locale=es-AR https://demo.serenity.is/Account/Login
Probé login con credenciales mal, y después con campos vacíos. Codegen me generó esto para el banner:
await page.getByText('Error de validación: ¡Nombre').click();
await page.locator('div').filter({ hasText: 'Error de validación: ¡Nombre' }).nth(1).click();
Y para campos vacíos:
await page.getByText('Por favor, valide los campos').click();
await page.locator('div').filter({ hasText: 'Por favor, valide los campos' }).nth(1).click();
El segundo locator de cada par es feo: div + filter + nth(1) es frágil. El primero (getByText) está mejor pero matchea texto específico, y yo necesitaba un solo locator que sirviera para los dos mensajes — porque el método obtenerMensajeError() no debería saber qué error va a aparecer.
La solución: regex.
this.errorBanner = page.getByText(/Error de validación|Por favor, valide/);
Un solo locator que matchea cualquiera de los dos casos. Esto es algo que en Selenium también podés hacer, pero la API de Playwright lo hace más natural — getByText acepta string o RegExp directamente, sin tener que andar componiendo XPaths.
El método quedó así:
// Lee el mensaje del banner de error (sirve para credenciales inválidas y campos vacíos)
async obtenerMensajeError(): Promise<string> {
return (await this.errorBanner.textContent()) ?? '';
}
El ?? '' es por TypeScript: textContent() devuelve string | null, y yo declaré que el método devuelve string. El ?? (nullish coalescing) reemplaza null por string vacío. En Java con Selenium, getText() te devuelve String directo y vos te las arreglás con los nulls cuando explotan en runtime. Acá lo manejo en compile time.
Un método más: clickLogin()
Hay un detalle de demo.serenity.is: la página de login viene con admin y serenity precargados en los inputs. Si entrás y clickeás "Iniciar sesión" sin tipear nada, te loguea igual. Eso también es un caso de prueba positivo, y en mi serie de Selenium ya lo tenía.
Para cubrirlo necesitaba un método que solo clickea el botón, sin tocar los campos. No podía reusar login('', '') porque fill('') borra los placeholders precargados — eso ya sería otro caso (campos vacíos), no el que quiero.
Lo agregué a LoginPage:
// Click directo en el botón sin tipear nada (la app viene con credenciales precargadas)
async clickLogin() {
await this.loginButton.click();
}Trivial, pero importante: cada caso de prueba debería tener un método claro en el Page Object, no un workaround.
El array y el for...of
Acá viene la parte central. El archivo nuevo: tests/serenity-data-driven.spec.ts.
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
// ===== DATA-DRIVEN: credenciales inválidas =====
// Equivalente a @DataProvider de TestNG, pero acá es solo un array de objetos.
// El for...of de abajo genera un test() por cada caso.
const credencialesInvalidas = [
{
caso: 'Password incorrecta',
usuario: 'admin',
password: 'mal',
mensajeEsperado: 'Error de validación: ¡Nombre de usuario o contraseña inválidos!',
// mensajeEsperado: 'Mensaje que no existe', // ← roto a propósito
},
{
caso: 'Usuario incorrecto',
usuario: 'mal',
password: 'serenity',
mensajeEsperado: 'Error de validación: ¡Nombre de usuario o contraseña inválidos!',
},
{
caso: 'Campos vacíos',
usuario: '',
password: '',
mensajeEsperado: 'Por favor, valide los campos vacíos o inválidos (marcados en rojo) antes de enviar el formulario',
},
];
// ===== LOGIN POSITIVO =====
test.describe('Login positivo', () => {
// Sin storageState: queremos hacer el login real, no arrancar logueado
test.use({ storageState: { cookies: [], origins: [] } });
test('login válido lleva al dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
await loginPage.goto();
await loginPage.login('admin', 'serenity');
await dashboardPage.verificarVisible();
});
// demo.serenity.is viene con admin/serenity ya cargados en los inputs.
// Click directo, sin tipear nada, también debería loguear.
test('click directo en login (credenciales precargadas) lleva al dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
await loginPage.goto();
await loginPage.clickLogin();
await dashboardPage.verificarVisible();
});
});
// ===== LOGIN NEGATIVO =====
test.describe('Login negativo - data-driven', () => {
// Importante: estos tests NO usan storageState (no necesitamos estar logueados)
test.use({ storageState: { cookies: [], origins: [] } });
for (const datos of credencialesInvalidas) {
test(`login inválido: ${datos.caso}`, async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(datos.usuario, datos.password);
const mensajeReal = await loginPage.obtenerMensajeError();
expect(mensajeReal).toContain(datos.mensajeEsperado);
});
}
});Tres decisiones del código:
1. Por qué dos describe separados
El positivo y los negativos no comparten estructura. El positivo verifica que aparece el dashboard. Los negativos verifican un mensaje de error. Forzarlos en el mismo array sería ensuciar el código por simetría falsa. Cada uno en su describe, cada uno con su lógica.
2. Por qué el test.use({ storageState: { cookies: [], origins: [] } })
Detalle no obvio: En el Post 7 Autenticación con storageState en Playwright configuré storageState global, todos los tests arrancan logueados. Pero estos tests son de login — necesito que el browser arranque sin cookies, sino redirige al dashboard y nunca llego al form de login.
test.use({ storageState: { cookies: [], origins: [] } }) anula el storageState global para este describe específico. Es como decir "para este bloque, arrancá vacío".
En Selenium no tenía este problema porque cada test creaba su propio driver desde cero. En Playwright, donde optimicé las cookies para todo el resto, tengo que apagarlas explícitamente acá.
3. El for...of con test() adentro
Esto es lo más distinto de TestNG.
for (const datos of credencialesInvalidas) {
test(`login inválido: ${datos.caso}`, async ({ page }) => {
// ...
});
}
No hay decorador. No hay annotation. Es JavaScript: un loop que registra N tests en Playwright en tiempo de ejecución. Cuando el archivo se carga, el loop corre, y por cada elemento del array Playwright registra un test() con su propio nombre dinámico (login inválido: Password incorrecta, etc.).
Después, cuando el runner los ejecuta, son tests independientes. No es un solo test que itera adentro. Son tres tests reales, separados, con su propio reporte, su propio aislamiento, su propia trace.
Eso lo vamos a comprobar más abajo.
Primera corrida
npx playwright test tests/serenity-data-driven.spec.ts --project=chromium
(Uso --project=chromium mientras itero. Para la corrida final saco el flag y corre en los 3 browsers. Este es el flujo normal: un browser para iterar rápido, los 3 para validar el resultado final.)
Resultado: 6 passed.

Mirá los nombres de los tests en el reporte:
Login positivo › login válido lleva al dashboardLogin positivo › click directo en login (credenciales precargadas) lleva al dashboardLogin negativo - data-driven › login inválido: Password incorrectaLogin negativo - data-driven › login inválido: Usuario incorrectoLogin negativo - data-driven › login inválido: Campos vacíos
Cada uno aparece como test independiente, con el nombre dinámico que armé con el template string ${datos.caso}. Esto es importante: en el reporte, en CI, en logs, cada caso tiene su propia identidad. No es "test parametrizado [iteración 2]" como pasa en algunos frameworks viejos.
Si abro el detalle del test "Password incorrecta" en Test Steps, veo:
Navigate to "/Account/Login"
Fill "admin" ← el valor real, no un placeholder
Fill "mal" ← el valor real
Click "Iniciar sesión"
Expect "toContain"

Fill "admin", Fill "mal" — no un placeholder. Cada test parametrizado corre con su propia data.Y el de "Campos vacíos":
Fill "" ← string vacío, capturado tal cual
Fill ""

Fill "" capturado tal cual en ambos inputs. El reporte deja claro qué valor recibió cada acción, incluso cuando es vacío.Cada test corrió con su propia data. Eso es lo que querías.
Aislamiento real: el experimento del fail intencional
Antes de cerrar quería demostrar algo importante: que estos no son "un solo test con un loop adentro". Son tests reales, aislados.
Para probarlo, rompí a propósito el mensajeEsperado del primer caso:
{
caso: 'Password incorrecta',
usuario: 'admin',
password: 'mal',
mensajeEsperado: 'Mensaje que no existe', // ← roto a propósito
},
Si fuera un solo test con un loop adentro, el primer expect que falla cortaría todo. El segundo y tercer caso ni siquiera correrían.
Corrí. Resultado:

mensajeEsperado del primer caso. Si fuera un loop adentro de un único test, los otros dos no hubieran corrido. Acá Playwright los aisló.2 passed, 1 failed. Los otros dos casos siguieron corriendo a pesar de que el primero falló. Eso es aislamiento real entre tests parametrizados.
Y el panel de Errors muestra exactamente qué falló:
Error: expect(received).toContain(expected)
Expected substring: "Mensaje que no existe"
Received string: "Error de validación: ¡Nombre de usuario o contraseña inválidos!"

Sin tocar nada, ya sé qué caso falló, qué esperaba y qué recibí. En Selenium con TestNG el Assert.assertEquals te tira algo parecido pero más mezclado con stack trace de Java. Acá es directo.
(Después de la screenshot, revertí el cambio y volví al mensajeEsperado correcto.)
Corrida final en los 3 browsers
Revertido el fail intencional, corrí sin filtro de browser:
npx playwright test tests/serenity-data-driven.spec.ts
Resultado: 16 passed (1 setup + 5 tests × 3 browsers).

Total: 1.3 minutos. 15 ejecuciones de login (5 casos × 3 browsers) más el setup, en paralelo, contra una app real.
Comparación con Selenium + TestNG
| Aspecto | Selenium + TestNG | Playwright |
|---|---|---|
| Cómo se parametriza | @DataProvider (decorador) |
Array de objetos + for...of |
| Dónde vive la data | Clase aparte (TestData.java) |
Mismo archivo o módulo TS importado |
| Sintaxis de los params | Posicional (String, String..) |
Por nombre ({ usuario, password... }) |
| Anular login global | Cada test crea su driver | test.use({ storageState: { ... } }) |
| Reporte por caso | Sí | Sí, con nombre dinámico |
| Aislamiento entre casos | Sí | Sí |
TestNG funciona y va a seguir funcionando años. Pero si arrancás un proyecto nuevo hoy, el enfoque de Playwright es más limpio por dos razones concretas:
- Es JavaScript puro. No tenés que conocer una API específica del framework. Si sabés
for...of, sabés data-driven en Playwright. - Los parámetros son por nombre, no posicionales.
datos.usuarioes más legible queString usuarioen la posición 2 de 4 argumentos. Y si reordenás los campos del objeto, los tests siguen funcionando.
Próximo post:
Validación de la grilla de clientes contra Excel. 91 clientes, 11 columnas por cada uno, datos cargados desde un .xlsx externo. Otra forma de data-driven, pero con dataset grande y archivo externo. El equivalente a mi ClientesTests de Selenium.
—
🔗 Todo el código de esta serie está en: github.com/cesarbeassuarez/playwright-typescript-framework