Assertions en Playwright: auto-retry, soft assertions y lo que en Selenium armé a mano
4 tests, 5 errores reales. Auto-retry, soft assertions, .not, y por qué en Selenium necesitaba esperas explícitas que acá no existen.
El problema con assertions clásicas
En mi framework de Selenium uso Assert.assertEquals de TestNG:
Assert.assertEquals(driver.getTitle(), "Google");
Si la página tarda en cargar y el título todavía no está, falla. Así nomás. Una oportunidad, un resultado. Para que funcione, armé esperas explícitas antes de cada assertion:
wait.until(ExpectedConditions.titleIs("Google"));
Assert.assertEquals(driver.getTitle(), "Google");
Dos líneas para verificar un título. Y si me olvido la espera, el test es flaky.
Playwright resuelve esto distinto.
Assertions web-first: qué son y por qué importan
En Playwright, las assertions reintentan automáticamente. No hace falta armar esperas.
await expect(page).toHaveTitle(/Iniciar sesión/);
Playwright hace esto internamente:
- Verifica la condición
- Si no se cumple, espera un poco
- Vuelve a verificar
- Repite hasta que pase o se agote el timeout (5 segundos por defecto)
No escribí una sola espera. El framework lo maneja.
Test 1: assertions de navegación (URL y título)
Empecé con lo básico: verificar URL y título de la página.

Error 1: el título no es lo que pensaba
Mi primera versión tenía:
await expect(page).toHaveTitle(/Serenity/);
Falló. El error de Playwright:
Expected pattern: /Serenity/
Received string: "Iniciar sesión en su cuenta"
Timeout: 5000ms

Asumí que el título tenía "Serenity" porque el sitio se llama así. No. El <title> del HTML dice "Iniciar sesión en su cuenta".
Y mirá lo que dice el log: 8 x unexpected value "Iniciar sesión en su cuenta". Reintentó 8 veces durante 5 segundos. Eso es el auto-retry en acción. En Selenium con TestNG, Assert.assertEquals falla una vez y listo, no reintenta.
Corregí a /Iniciar sesión/. Pasó.
Test 2: assertions de elementos (texto, visibilidad, atributos, conteo)

Error 2: CSS frágil vs roles
Mi primera versión del conteo usaba un selector CSS:
await expect(page.locator('.external-login-flow button')).toHaveCount(3);
Falló:
Expected: 3
Received: 0

La clase .external-login-flow no existe. Inventé el selector. La solución: usar getByRole con regex, que no depende de clases CSS:
await expect(page.getByRole('button', { name: /Iniciar sesión con/ })).toHaveCount(3);
Matchea los 3 botones (Google, GitHub, Microsoft) por su nombre accesible. Sin CSS frágil.
Error 3: el <title> del HTML no es lo que dice la UI
Después del login, la UI muestra "Tablero". Pero toHaveTitle(/Tablero/) falló:
Expected pattern: /Tablero/
Received string: "Dashboard - StartSharp"

La UI está en español porque tengo locale: 'es-AR' en playwright.config.ts. Pero el <title> del HTML quedó en inglés. toHaveTitle verifica el <title> del documento, no lo que se ve en pantalla.
Es una diferencia sutil pero importante: lo que ves no siempre es lo que el HTML dice.
Corregí a /Dashboard/. Pasó.
Test 3: soft assertions
Esto no existe en TestNG de forma nativa.
En Selenium, si la primera assertion falla, el test se frena:
Assert.assertEquals(title, "Dashboard"); // falla acá
Assert.assertTrue(url.contains("/")); // nunca corre
Assert.assertEquals(header, "Tablero"); // nunca corre
Si falla la primera, no sabés si las otras dos están bien o mal. Tenés que arreglar, correr de nuevo, y rezar.
En Playwright, expect.soft hace que el test siga corriendo aunque falle una assertion:

Para demostrar el comportamiento, agregué una assertion que falla a propósito:
// Esta va a fallar a propósito
await expect.soft(page.getByText('Texto que no existe')).toBeVisible();
// Esta se ejecuta AUNQUE la anterior falló
await expect.soft(page.getByText('Northwind')).toBeVisible();

La assertion de "Texto que no existe" falló. Pero la de "Northwind" se ejecutó igual y pasó. En TestNG, nunca hubiera corrido.
Error 4: getByText('Tablero') matcheó 2 elementos
Mi primera versión usaba:
await expect.soft(page.getByText('Tablero')).toBeVisible();
Falló con:
strict mode violation: getByText('Tablero') resolved to 2 elements:
1) <span class="s-sidebar-link-text">Tablero</span>
2) <h1>Tablero</h1>

Mismo problema que en el Post 3 con getByText('ANTON'). La solución: ser más específico con getByRole:
await expect.soft(page.getByRole('heading', { name: 'Tablero' })).toBeVisible();
Apunta directo al <h1>, sin ambigüedad.
Test 4: assertions negativas con .not
En Selenium, verificar que algo no está visible es un dolor:
// Opción 1: try-catch (feo)
try {
driver.findElement(By.id("dashboard"));
Assert.fail("El elemento no debería existir");
} catch (NoSuchElementException e) {
// OK, no existe
}
// Opción 2: ExpectedConditions (mejor pero verboso)
wait.until(ExpectedConditions.invisibilityOfElementLocated(By.id("login-btn")));
En Playwright: .not.

Una línea. .not.toBeVisible(). Sin try/catch, sin ExpectedConditions, sin verbosidad.
Error 5: una carrera de tiempos
Por accidente dejé dos assertions seguidas sobre el mismo botón:
await expect(page.getByRole('button', { name: 'Iniciar sesión', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Iniciar sesión', exact: true })).not.toBeVisible();Las dos pasaron. ¿Cómo?

Mirá los tiempos:
toBeVisible()→ 105msnot.toBeVisible()→ 764ms
El click en "Iniciar sesión" dispara la navegación, pero la página no cambia instantáneamente. Cuando toBeVisible() se ejecuta, el botón todavía está ahí porque la navegación no terminó. Después, not.toBeVisible() reintenta hasta que el botón desaparece.
No es un test confiable. Depende de qué tan rápido cargue la página. Si un día la navegación es más rápida, toBeVisible() fallaría. Eliminé la línea y dejé solo not.toBeVisible().
La comparación directa con Selenium + TestNG
| Concepto | Selenium + TestNG | Playwright |
|---|---|---|
| Assertion básica | Assert.assertEquals(a, b) |
await expect(x).toHaveText('y') |
| Auto-retry | No. Falla una vez y listo | Sí. Reintenta hasta el timeout |
| Esperas antes de assertions | Obligatorias (WebDriverWait) |
Innecesarias (built-in) |
| Soft assertions | No nativo. Se puede armar con SoftAssert |
expect.soft() nativo |
| Assertions negativas | try/catch o invisibilityOf |
.not.toBeVisible() |
| Mensaje de error | Genérico | Detallado: locator, expected, received, timeout, call log |
Lo que más cambia no es la sintaxis. Es que en Selenium armé esperas explícitas antes de cada assertion para que no sean flaky. En Playwright, eso viene resuelto.
Estado actual del archivo
serenity-assertions.spec.ts
├── assertions de navegación: URL y título
├── assertions de elementos: texto, visibilidad, atributos
├── soft assertions: múltiples verificaciones sin frenar
└── assertions negativas: verificar que algo NO existe

4 tests. 3 browsers. Todo verde.
Próximo paso
Explorar fixtures — el sistema de inyección de dependencias de Playwright que reemplaza los @BeforeEach y @AfterEach de TestNG. Fixtures son lo que hace que el { page } aparezca mágicamente en cada test.
—
🔗 Todo el código de esta serie está en: github.com/cesarbeassuarez/playwright-typescript-framework