Fixtures en Playwright: qué es { page }, beforeEach, custom fixtures y 2 errores reales

Qué es { page }, cómo reemplaza @BeforeMethod de TestNG, custom fixtures reutilizables entre archivos. 2 errores reales: doble login y emojis.

UI Mode mostrando 12 de 12 tests passed con Before Hooks expandido y código de serenity-fixtures con beforeEach y fixtures directos contra demo.serenity.is
12/12 passed. Dos describes conviviendo: uno con beforeEach, otro con fixtures directos.

Nota para el lector

Vengo de TestNG. Ahí tenés @BeforeMethod, @AfterMethod, y el WebDriver lo manejás vos. En Playwright todo eso se reemplaza con una sola idea: fixtures.

Este post cubre qué son los fixtures, cómo reemplazan @BeforeMethod, y cómo crear custom fixtures reutilizables entre archivos.


El problema: login repetido en cada test

Mirá serenity-assertions.spec.ts del Post 4:

test('assertions de navegación: URL y título', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('admin', 'serenity');
    // assertions...
})

test('assertions de elementos: texto, visibilidad, atributos', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('admin', 'serenity');
    // assertions...
})

test('soft assertions: múltiples verificaciones sin frenar', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('admin', 'serenity');
    // assertions...
})

Tres tests, tres veces las mismas tres líneas de login. Si agrego 10 tests más, repito el login 10 veces. Si cambia el formulario de login, lo corrijo en 10 lugares.

En Selenium resolvía esto con @BeforeMethod. En Playwright hay algo mejor.


Antes de resolver: ¿qué es { page }?

Desde el Post 1 (Playwright + TypeScript) vengo escribiendo esto:

test('mi test', async ({ page }) => {

Son dos cosas a la vez.

A nivel de JavaScript: es destructuring. Playwright le pasa un objeto al test con todas las fixtures disponibles ({ page, browser, context, request, ... }). Cuando escribís ({ page }), estás diciendo "de todo lo que me das, solo quiero page". Es lo mismo que:

test('mi test', async (fixtures) => {
    const page = fixtures.page;
});

Pero más corto.

A nivel de Playwright: page es un fixture built-in. Playwright crea un browser → crea un context (como una ventana incógnito) → crea una page (una pestaña) → te la pasa → la usás → el test termina → Playwright destruye todo. Cada test recibe su propia page aislada, con su propio context, sin estado compartido con otros tests.

Por eso más adelante, cuando escriba ({ loginPage, page }), funciona: le estoy pidiendo a Playwright dos fixtures — uno built-in (page) y uno custom mío (loginPage). Playwright crea los dos, me los pasa, y los limpia al final.

En TestNG no existe esto. Ahí hacés una variable de instancia WebDriver driver; en la clase, la inicializás en @BeforeMethod, y todos los tests de esa clase comparten la misma referencia. Si algo sale mal, el estado se contamina. En Playwright cada test es un mundo aparte.

Armé 3 tests idénticos para ver el patrón repetido:

import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';

test.describe('Fixtures en demo.serenity.is', () => {
    test('sin beforeEach: login manual en cada test', async ({ page }) => {
        const loginPage = new LoginPage(page);
        await loginPage.goto();
        await loginPage.login('admin', 'serenity');

        await expect(page.getByRole('heading', { name: 'Tablero' })).toBeVisible();
    })

    test('sin beforeEach: segundo test con login repetido', async ({ page }) => {
        const loginPage = new LoginPage(page);
        await loginPage.goto();
        await loginPage.login('admin', 'serenity');

        await expect(page).toHaveURL(/\/$/);
        await expect(page).toHaveTitle(/Dashboard/);
    })

    test('sin beforeEach: tercer test con login repetido', async ({ page }) => {
        const loginPage = new LoginPage(page);
        await loginPage.goto();
        await loginPage.login('admin', 'serenity');

        await expect(page.getByText('Northwind')).toBeVisible();
    })
})

9/9 passed. Funciona, pero las mismas 3 líneas se repiten 3 veces.

UI Mode mostrando 9 de 9 tests passed con 3 tests que repiten login manual en cada uno y código con new LoginPage en líneas 7 15 y 24
El "antes": 3 tests, 3 veces las mismas 3 líneas de login. Funciona, pero escala mal.

Al hacer click en un test en UI Mode, se vé: Before Hooks y After Hooks. Antes de cada test, Playwright crea 3 fixtures automáticos: Fixture "browser", Fixture "context", Fixture "page". Después del test, los destruye.

UI Mode mostrando 3 de 3 passed con flechas señalando Before Hooks con Navigate Fill Fill Click y After Hooks al final del test
Before Hooks y After Hooks. Playwright crea el browser antes del test y lo destruye después. No hay driver.quit().

Esos fixtures reemplazan todo esto de Selenium:

// En Selenium, esto lo hacía yo:
WebDriverManager.chromedriver().setup();
WebDriver driver = new ChromeDriver();
// ... test ...
driver.quit();

En Playwright, no hago nada de eso. El framework crea el browser, el context y la page, me los pasa, y los limpia. Cada test recibe su propia page aislada.


beforeEach: el equivalente a @BeforeMethod

Reemplacé el código para que el login se ejecute una vez:

import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';

test.describe('Fixtures en demo.serenity.is', () => {

    test.beforeEach(async ({ page }) => {
        const loginPage = new LoginPage(page);
        await loginPage.goto();
        await loginPage.login('admin', 'serenity');
    });

    test('verificar heading del dashboard', async ({ page }) => {
        await expect(page.getByRole('heading', { name: 'Tablero' })).toBeVisible();
    })

    test('verificar URL y título', async ({ page }) => {
        await expect(page).toHaveURL(/\/$/);
        await expect(page).toHaveTitle(/Dashboard/);
    })

    test('verificar menú Northwind', async ({ page }) => {
        await expect(page.getByText('Northwind')).toBeVisible();
    })
})

El login se escribe una vez en beforeEach y se ejecuta automáticamente antes de cada test. Los 3 tests ahora solo tienen la verificación.

9/9 passed. Pero lo interesante está en UI Mode:

UI Mode con beforeEach hook expandido mostrando Fixture browser Fixture context Fixture page y Fixture loginPage con código de LoginPage.ts visible abajo
Con beforeEach: 4 fixtures en Before Hooks. Los 3 de Playwright (browser, context, page) más el mío (loginPage). El login se escribe una vez.
UI Mode mostrando 9 de 9 passed con Before Hooks colapsado y solo Expect toBeVisible como acción del test con código del import desde fixtures
El test solo tiene la assertion. Todo el setup está en Before Hooks. Limpio.

Las acciones de login ahora aparecen dentro de Before Hooks, no en el test. El test solo muestra la assertion. Limpio.

Y en After Hooks, Playwright cierra todo automáticamente. No hay driver.quit(), no hay @AfterMethod. El framework se encarga.

beforeEach vs @BeforeMethod de TestNG

En Selenium:

@BeforeMethod
public void setUp() {
    WebDriverManager.chromedriver().setup();
    driver = new ChromeDriver();
    driver.get("https://demo.serenity.is/Account/Login");
}

@AfterMethod
public void tearDown() {
    driver.quit();
}

En Playwright:

test.beforeEach(async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('admin', 'serenity');
});

No hay @AfterMethod. No hay driver.quit(). No hay WebDriverManager. Playwright crea y destruye todo solo. Escribo menos, controlo lo mismo.


Custom fixtures: lo que TestNG no tiene

beforeEach resuelve la repetición dentro de un archivo. Pero si mañana creo serenity-clientes.spec.ts y serenity-pedidos.spec.ts, tengo que copiar el beforeEach en cada archivo.

Custom fixtures resuelven eso. Creé una carpeta fixtures/ en la raíz del proyecto:

playwright-typescript-framework/
├── fixtures/           ← nueva
│   └── test-fixtures.ts
├── pages/
│   ├── LoginPage.ts
│   ├── DashboardPage.ts
│   └── ClientesPage.ts
├── tests/
│   ├── serenity-fixtures.spec.ts
│   ├── serenity-assertions.spec.ts
│   └── serenity-locators.spec.ts
├── playwright.config.ts
└── package.json
Explorador de VS Code mostrando carpeta fixtures con test-fixtures.ts al mismo nivel que pages y tests con código de base.extend y MyFixtures
Nueva carpeta: fixtures/ con test-fixtures.ts. Custom fixtures con tipos TypeScript.

El archivo test-fixtures.ts Qué hace:

Importa test de Playwright, lo extiende con mis propias pages, y lo exporta. Ahora cualquier archivo que importe de acá recibe loginPage, dashboardPage y clientesPage inyectados automáticamente.

Esa línea await use(loginPage) es clave. Es el punto donde Playwright dice "te presto este objeto, usalo, y cuando termines yo lo limpio". Todo lo que va antes de use() es setup, todo lo que va después es teardown.

El test refactorizado

Cambié el import de @playwright/test a ../fixtures/test-fixtures:

import { test, expect } from '../fixtures/test-fixtures';

test.describe('Con beforeEach: login automático', () => {

    test.beforeEach(async ({ loginPage }) => {
        await loginPage.goto();
        await loginPage.login('admin', 'serenity');
    });

    test('verificar heading del dashboard', async ({ page }) => {
        await expect(page.getByRole('heading', { name: 'Tablero' })).toBeVisible();
    })

    test('verificar URL y título', async ({ page }) => {
        await expect(page).toHaveURL(/\/$/);
        await expect(page).toHaveTitle(/Dashboard/);
    })

    test('verificar menú Northwind', async ({ page }) => {
        await expect(page.getByText('Northwind')).toBeVisible();
    })
})

Mirá el beforeEach: ya no hace new LoginPage(page). Recibe { loginPage } directamente — Playwright lo crea por mí. Una línea menos, y la instanciación queda centralizada en test-fixtures.ts.

Y en UI Mode, el fixture aparece con nombre propio:

UI Mode mostrando Fixture loginPage entre Fixture page y Navigate to Account Login con código de test-fixtures.ts en panel Source a la derecha
Fixture "loginPage" — mi fixture custom visible en el trace. Playwright lo crea, me lo presta, y lo destruye.

Fixture "loginPage" — mi fixture custom, visible en el trace. No es magia, es infraestructura declarada.


Fixtures directos en tests

Los fixtures no solo sirven para beforeEach. Se pueden pedir directamente en cada test:

test.describe('Sin beforeEach: fixtures directos', () => {

    test('usar fixtures custom directamente', async ({ loginPage, page }) => {
        await loginPage.goto();
        await loginPage.login('admin', 'serenity');

        await expect(page.getByRole('heading', { name: 'Tablero' })).toBeVisible();

        await page.getByRole('link', { name: /Northwind/ }).click();
        await page.getByRole('link', { name: /Clientes/ }).click();

        await expect(page.locator('#GridDiv')).toContainText('ALFKI');
    })
})

En la firma del test: { loginPage, page }. Los dos inyectados por Playwright. No hago new de nada.

Esto es algo que en TestNG no existe. En TestNG, para pasar objetos del setup al test, usás variables de instancia en la clase:

public class MiTest {
    WebDriver driver;      // variable de instancia
    LoginPage loginPage;   // variable de instancia

    @BeforeMethod
    public void setUp() {
        driver = new ChromeDriver();
        loginPage = new LoginPage(driver);
    }

    @Test
    public void miTest() {
        loginPage.login("admin", "serenity");  // usa la variable de instancia
    }
}

Estado compartido a nivel de clase. Si dos tests corren en paralelo, pueden pisarse. En Playwright, cada test recibe sus propios fixtures aislados. No hay estado compartido.


Errores reales

Error 1: doble login — HTTP 400

El primer intento de usar fixtures directamente falló. Tenía un describe con beforeEach que hacía login, y dentro un test que también hacía login:

test.describe('Fixtures en demo.serenity.is', () => {

    test.beforeEach(async ({ loginPage }) => {
        await loginPage.goto();
        await loginPage.login('admin', 'serenity');
    });

    // Este test hace login DE NUEVO
    test('usar fixtures custom directamente', async ({ loginPage, page }) => {
        await loginPage.goto();
        await loginPage.login('admin', 'serenity');  // segundo login
        // ...
    })
})
UI Mode mostrando Failed con alerta HTTP Error 400 en demo.serenity.is por doble login con beforeEach y login manual en el mismo describe
Primer error: doble login. beforeEach hizo login, el test intentó de nuevo. HTTP 400.

HTTP 400. La app no acepta un segundo login cuando ya hay una sesión activa. El beforeEach corrió primero, logueó al usuario, y cuando el test intentó loguear de nuevo, el servidor devolvió error.

La solución: separar en dos describe. El que tiene beforeEach es para tests que arrancan logueados. El que usa fixtures directos hace su propio flujo:

test.describe('Con beforeEach: login automático', () => {
    test.beforeEach(async ({ loginPage }) => {
        // login acá
    });
    // tests que arrancan logueados
})

test.describe('Sin beforeEach: fixtures directos', () => {
    // tests que manejan su propio login
})

beforeEach corre para todos los tests dentro de su describe. Si un test necesita un flujo distinto, va en otro describe.

Error 2: emojis que no renderizan — □ en webkit

Los links del menú de demo.serenity.is tienen emojis: 📁 Northwind 📁. Pero al correr en webkit, los emojis se renderizaban como cuadrados (□). El locator buscaba '📁 Northwind 📁' y no encontraba nada. Timeout de 25 segundos y fallo.

La solución: usar regex sin emojis:

// Antes — falla en webkit
await page.getByRole('link', { name: '📁 Northwind 📁' }).click();

// Después — funciona en los 3 browsers
await page.getByRole('link', { name: /Northwind/ }).click();

Regex con solo el texto relevante. Sin depender de cómo cada browser renderiza emojis.

Y después de ese fix, webkit pasó... a veces. Hubo runs donde webkit fallaba en Navigate to "/Account/Login" — se quedaba en about:blank. Al correr de nuevo, pasaba. Flakiness de webkit con demo.serenity.is, no de mi código.

Esto es exactamente para lo que sirve testing cross-browser. Si solo corría en Chromium, nunca hubiera visto estos problemas.


Cuándo usar cada approach

beforeEach: para setup repetido dentro de un solo archivo. Si todos los tests en un archivo necesitan login, beforeEach ahí.

Custom fixtures: para setup reutilizable entre archivos. Si serenity-fixtures.spec.ts, serenity-clientes.spec.ts y serenity-pedidos.spec.ts necesitan loginPage, todos importan de test-fixtures.ts. Escribo una vez, uso en todos lados.

Fixtures directos en tests: para tests que necesitan un flujo diferente al beforeEach. O cuando querés que el test sea explícito sobre qué necesita.

Pueden combinarse: beforeEach usa un fixture custom, y otro test en un describe separado usa el mismo fixture directamente. No son excluyentes.


Selenium vs Playwright: setup y teardown

Concepto Selenium + TestNG Playwright
Crear browser WebDriverManager.chromedriver().setup() + new ChromeDriver() Fixture browser (automático)
Cerrar browser driver.quit() en @AfterMethod Automático
Setup por test @BeforeMethod test.beforeEach o fixtures
Teardown por test @AfterMethod Automático (o código después de use())
Pasar objetos a tests Variables de instancia en la clase Fixtures inyectados en la firma
Aislamiento Manual (cuidar estado compartido) Automático (cada test = su propio context)
Reutilizar setup entre archivos Clase base con herencia Custom fixtures con base.extend

En Selenium armé BasePage, DriverManager, @BeforeMethod, @AfterMethod. Todo manual. En Playwright, los fixtures hacen lo mismo con menos código y aislamiento garantizado.


Estado actual

Estructura del proyecto:

playwright-typescript-framework/
├── fixtures/
│   └── test-fixtures.ts     ← custom fixtures
├── pages/
│   ├── LoginPage.ts
│   ├── DashboardPage.ts
│   └── ClientesPage.ts
├── tests/
│   ├── serenity-assertions.spec.ts
│   ├── serenity-fixtures.spec.ts
│   └── serenity-locators.spec.ts
├── playwright.config.ts
└── package.json

4 tests, 3 browsers, 12/12 passed.

Playwright Test mostrando 12 de 12 tests passed con beforeEach hook expandido mostrando Fixture loginPage y código completo de serenity-fixtures.spec.ts
Versión final: 4 tests, 3 browsers, 12/12. beforeEach con fixture custom y fixtures directos conviviendo.

Próximo post

playwright.config.ts en profundidad. Ese archivo controla browsers, timeouts, retries, paralelismo, base URL, locale y más. Ya lo toqué en el Post 2 para agregar locale: 'es-AR' y baseURL, pero hay mucho más adentro.

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