Cinco líneas de setup de login copiadas en 40 tests significa actualizar 40 archivos cuando cambia el flujo de login. Los fixtures personalizados eliminan eso con test.extend(): el código antes de await use(value) se ejecuta como setup, el código después se ejecuta como teardown independientemente de si el test pasó o falló. Este artículo cubre cómo construir un fixture authenticatedPage, cómo componer fixtures que resuelven dependencias automáticamente, el scope de worker para compartir setup costoso entre tests, y los casos donde una función helper es más limpia que un fixture.

Por qué los fixtures integrados no son suficientes

Playwright incluye fixtures como page, browser, context y request. Cubren lo básico. Pero no saben nada sobre tu aplicación: tu flujo de login, tu estado autenticado, tus objetos de dominio.

En cuanto tienes más de un puñado de tests, empiezas a topar con el mismo problema una y otra vez:

// tests/items.spec.ts
import { test, expect } from '@playwright/test';

test('el usuario puede agregar un ítem de viaje', async ({ page }) => {
  // Setup repetido en cada test
  await page.goto('https://lab.becomeqa.com');
  await page.getByRole('button', { name: 'Login' }).click();
  await page.getByLabel('Username').fill('admin@becomeqa.com');
  await page.getByLabel('Password').fill('testpass123');
  await page.getByRole('button', { name: 'Submit' }).click();
  await page.getByText('My Travel Items').waitFor();

  // Test real
  await page.getByRole('button', { name: 'Add Item' }).click();
  await page.getByLabel('Item name').fill('Pasaporte');
  await page.getByRole('button', { name: 'Save' }).click();
  await expect(page.getByRole('cell', { name: 'Pasaporte' })).toBeVisible();
});

Cinco líneas de login antes de llegar al test. Multiplicado por 40 tests, son 200 líneas de código que no agregan ningún valor a tu suite. Cambias el flujo de login y estás actualizando los 40 archivos.

Los fixtures personalizados mueven el setup fuera del test y lo ponen en una definición compartida. El test recibe el resultado (una página autenticada, un page object listo para usar) sin importarle cómo fue preparado.

El patrón test.extend()

La API es test.extend(). Le pasás un objeto donde cada clave es el nombre de un fixture y cada valor es una función asíncrona que recibe los fixtures existentes y un callback use.

El ejemplo mínimo, un fixture que inicia sesión antes de que corra el test:

// fixtures/auth.fixture.ts
import { test as base, Page } from '@playwright/test';

type Fixtures = {
  authenticatedPage: Page;
};

export const test = base.extend<Fixtures>({
  authenticatedPage: async ({ page }, use) => {
    await page.goto('https://lab.becomeqa.com');
    await page.getByRole('button', { name: 'Login' }).click();
    await page.getByLabel('Username').fill('admin@becomeqa.com');
    await page.getByLabel('Password').fill('testpass123');
    await page.getByRole('button', { name: 'Submit' }).click();
    await page.getByText('My Travel Items').waitFor();

    // Entregar la página autenticada al test
    await use(page);

    // El teardown corre después de que use() retorna
    // (nada que limpiar aquí: la página se cierra automáticamente)
  },
});

export { expect } from '@playwright/test';

Ahora el archivo de test importa test desde el archivo de fixture en lugar de hacerlo directamente desde Playwright:

// tests/items.spec.ts
import { test, expect } from '../fixtures/auth.fixture';

test('el usuario puede agregar un ítem de viaje', async ({ authenticatedPage }) => {
  const page = authenticatedPage;

  await page.getByRole('button', { name: 'Add Item' }).click();
  await page.getByLabel('Item name').fill('Pasaporte');
  await page.getByRole('button', { name: 'Save' }).click();
  await expect(page.getByRole('cell', { name: 'Pasaporte' })).toBeVisible();
});

El test empieza en el dashboard. El login desapareció. La intención es inmediatamente obvia.

Siempre re-exporta expect desde tu archivo de fixture: export { expect } from '@playwright/test'. Así los archivos de test solo necesitan una línea de import y no corres el riesgo de usar accidentalmente el expect de Playwright en lugar de una versión envuelta personalizada.

Fixtures de page objects

Los fixtures y los page objects resuelven problemas distintos pero funcionan muy bien juntos. Un page object envuelve el cómo interactuar con una página. Un fixture maneja el cuándo: configurar el objeto e inyectarlo en el test.

Empieza con page objects simples:

// pages/DashboardPage.ts
import { Page, Locator } from '@playwright/test';

export class DashboardPage {
  readonly page: Page;
  readonly addItemButton: Locator;
  readonly itemsTable: Locator;

  constructor(page: Page) {
    this.page = page;
    this.addItemButton = page.getByRole('button', { name: 'Add Item' });
    this.itemsTable = page.getByRole('table');
  }

  async isLoaded() {
    await this.page.getByText('My Travel Items').waitFor({ state: 'visible' });
  }

  async getRowCount() {
    const rows = this.page.getByRole('row');
    return (await rows.count()) - 1; // restar la fila de encabezado
  }
}

// pages/AddItemModal.ts
import { Page, Locator } from '@playwright/test';

export class AddItemModal {
  readonly itemNameInput: Locator;
  readonly categorySelect: Locator;
  readonly saveButton: Locator;

  constructor(page: Page) {
    this.itemNameInput = page.getByLabel('Item name');
    this.categorySelect = page.getByLabel('Category');
    this.saveButton = page.getByRole('button', { name: 'Save' });
  }

  async fillAndSave(name: string, category: string) {
    await this.itemNameInput.fill(name);
    await this.categorySelect.selectOption(category);
    await this.saveButton.click();
  }
}

Ahora conéctalos como fixtures:

// fixtures/pages.fixture.ts
import { test as base } from '@playwright/test';
import { DashboardPage } from '../pages/DashboardPage';
import { AddItemModal } from '../pages/AddItemModal';

type PageFixtures = {
  dashboardPage: DashboardPage;
  addItemModal: AddItemModal;
};

export const test = base.extend<PageFixtures>({
  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },
  addItemModal: async ({ page }, use) => {
    await use(new AddItemModal(page));
  },
});

export { expect } from '@playwright/test';

Los tests ahora declaran exactamente qué objetos necesitan:

// tests/items.spec.ts
import { test, expect } from '../fixtures/pages.fixture';

test('el dashboard muestra ítems existentes', async ({ page, dashboardPage }) => {
  await page.goto('https://lab.becomeqa.com');
  // ... pasos de login
  await dashboardPage.isLoaded();

  const count = await dashboardPage.getRowCount();
  expect(count).toBeGreaterThan(0);
});

Setup y teardown en fixtures

El callback use es la línea divisoria entre setup y teardown. El código antes de await use(value) corre antes del test. El código después corre cuando el test termina, haya pasado o fallado.

Aquí es donde los fixtures empiezan a brillar para todo lo que necesita limpieza:

// fixtures/data.fixture.ts
import { test as base, request } from '@playwright/test';

type DataFixtures = {
  testItemId: string;
};

export const test = base.extend<DataFixtures>({
  testItemId: async ({}, use) => {
    // Setup: crear un ítem de viaje vía API antes del test
    const apiContext = await request.newContext({
      baseURL: 'https://lab.becomeqa.com/api',
      extraHTTPHeaders: {
        Authorization: 'Bearer test-token-123',
      },
    });

    const response = await apiContext.post('/items', {
      data: { name: 'Fixture Item', category: 'Documents' },
    });
    const { id } = await response.json();

    // Entregar el ID al test
    await use(id);

    // Teardown: eliminar el ítem después del test
    await apiContext.delete(`/items/${id}`);
    await apiContext.dispose();
  },
});

El test no gestiona el ciclo de vida del ítem en absoluto:

test('el usuario puede eliminar un ítem de viaje', async ({ page, testItemId }) => {
  // El ítem ya existe. Solo navegar y eliminarlo.
  await page.goto(`https://lab.becomeqa.com/items`);
  await page.getByTestId(`item-row-${testItemId}`).getByRole('button', { name: 'Delete' }).click();
  await page.getByRole('button', { name: 'Confirm' }).click();

  await expect(page.getByTestId(`item-row-${testItemId}`)).not.toBeVisible();
});

Si el test falla a mitad de camino, el teardown igual se ejecuta. La llamada a la API igual elimina el ítem. Tu base de datos no acumula datos de test sobrantes.

No lances excepciones dentro del bloque de teardown. Si tu código de limpieza lanza un error, Playwright puede reportar un fallo de test confuso u ocultar el fallo original. Envolvé el teardown en try/catch y logueá los errores en lugar de relanzarlos.

Scope de fixtures: test vs worker

Por defecto, cada fixture se crea nuevo para cada test. Ese es el scope: 'test'. Es el valor seguro por defecto. Los tests están aislados y no hay filtrado de estado entre ellos.

Pero la autenticación es costosa. Navegar, hacer clic, llenar campos, esperar: son 1 a 3 segundos por test. Con 100 tests que todos inician sesión desde cero, son potencialmente 3 minutos de tiempo de test gastados solo en login.

El scope de worker ejecuta el fixture una vez por proceso worker y comparte el resultado entre todos los tests de ese worker. El enfoque correcto para autenticación es guardar el estado del almacenamiento del navegador una vez y reutilizarlo:

// fixtures/worker-auth.fixture.ts
import { test as base, chromium, BrowserContext } from '@playwright/test';

type WorkerFixtures = {
  workerContext: BrowserContext;
};

export const test = base.extend<{}, WorkerFixtures>({
  workerContext: [
    async ({}, use) => {
      // Esto corre una vez por worker, no una vez por test
      const browser = await chromium.launch();
      const context = await browser.newContext();
      const page = await context.newPage();

      // Iniciar sesión y guardar el estado
      await page.goto('https://lab.becomeqa.com');
      await page.getByRole('button', { name: 'Login' }).click();
      await page.getByLabel('Username').fill('admin@becomeqa.com');
      await page.getByLabel('Password').fill('testpass123');
      await page.getByRole('button', { name: 'Submit' }).click();
      await page.getByText('My Travel Items').waitFor();

      await use(context);

      await context.close();
      await browser.close();
    },
    { scope: 'worker' },
  ],
});

El segundo argumento { scope: 'worker' } es cómo optas por este comportamiento. Los fixtures con scope de worker se declaran en el segundo parámetro genérico de extend() en lugar del primero.

La implicación práctica: los fixtures con scope de worker comparten estado entre tests. Está bien para un contexto autenticado de solo lectura. Es un problema si los tests modifican el estado que comparten (un test cierra sesión, el test siguiente no encuentra la sesión). Usa scope de worker para cosas costosas de crear y seguras de compartir; usa scope de test para todo lo demás.

Componer fixtures

Los fixtures pueden usar otros fixtures.

Tienes un fixture de auth que maneja el login. Tienes fixtures de page objects. Quieres un fixture que entregue un dashboard ya autenticado, combinando ambos:

// fixtures/index.ts
import { test as base, Page } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
import { AddItemModal } from '../pages/AddItemModal';

type AppFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
  addItemModal: AddItemModal;
  loggedInDashboard: DashboardPage;
};

export const test = base.extend<AppFixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },

  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },

  addItemModal: async ({ page }, use) => {
    await use(new AddItemModal(page));
  },

  // Este fixture usa loginPage y dashboardPage
  loggedInDashboard: async ({ loginPage, dashboardPage }, use) => {
    await loginPage.goto();
    await loginPage.login('admin@becomeqa.com', 'testpass123');
    await dashboardPage.isLoaded();

    await use(dashboardPage);
  },
});

export { expect } from '@playwright/test';

Los tests que necesitan un dashboard autenticado lo obtienen con una sola palabra:

// tests/items.spec.ts
import { test, expect } from '../fixtures';

test('el dashboard muestra al menos un ítem', async ({ loggedInDashboard }) => {
  const count = await loggedInDashboard.getRowCount();
  expect(count).toBeGreaterThan(0);
});

test('el usuario puede abrir el modal para agregar ítems', async ({ loggedInDashboard, addItemModal }) => {
  await loggedInDashboard.addItemButton.click();
  await expect(addItemModal.itemNameInput).toBeVisible();
});

Playwright resuelve el grafo de dependencias de fixtures automáticamente. Cuando un test solicita loggedInDashboard, Playwright ve que depende de loginPage y dashboardPage, los crea primero, y luego ejecuta el setup de loggedInDashboard. Nunca gestionás esta resolución manualmente.

La estructura de carpetas que surge de este enfoque:

proyecto/
  pages/
    LoginPage.ts
    DashboardPage.ts
    AddItemModal.ts
  fixtures/
    index.ts          ← todos los fixtures exportados desde un solo lugar
  tests/
    items/
      items-list.spec.ts
      items-crud.spec.ts
    payments/
      payment-flow.spec.ts
  playwright.config.ts

Cada archivo de test importa desde ../fixtures y obtiene todo lo que necesita sin boilerplate.

Cuándo NO usar fixtures

Los fixtures son suficientemente poderosos como para que los equipos a veces los sobreapliquen. Un fixture que usa un solo test no es un fixture. Es solo setup inline con más ceremonia. Antes de crear un fixture, pregúntate: ¿lo van a usar al menos tres tests, o el setup realmente complica un test si está inline?

Algunos casos específicos donde los fixtures agregan fricción en lugar de eliminarla:

Escenarios de test únicos

Si tienes un test que verifica comportamiento después de un estado muy específico e inusual (un ítem con un campo corrupto, una sesión a punto de expirar), el setup inline es más claro. La naturaleza inusual del setup es documentación en sí misma.

Tests que necesitan verificar el setup

Si tu test es sobre el flujo de login, quieres los pasos de login visibles en el test. Ocultarlos detrás de un fixture loggedInDashboard derrota el propósito. Los tests sobre autenticación deberían usar el fixture page crudo y configurar el estado explícitamente.

Setup que varía significativamente entre tests

Si cada test necesita datos iniciales ligeramente distintos, un fixture que intenta acomodar todas las variaciones va a crecer una lista de parámetros más difícil de entender que escribir el setup inline. Una función factory (una función TypeScript normal que tu test llama) suele ser más limpia que un fixture parametrizado.

// En lugar de un fixture parametrizado complejo, usá una función factory
// helpers/createItem.ts
import { APIRequestContext } from '@playwright/test';

export async function createItem(
  request: APIRequestContext,
  overrides: Partial<{ name: string; category: string }> = {}
) {
  const response = await request.post('https://lab.becomeqa.com/api/items', {
    data: {
      name: 'Default Item',
      category: 'Documents',
      ...overrides,
    },
  });
  return response.json();
}

// tests/items-edge-cases.spec.ts
import { test, expect } from '@playwright/test';
import { createItem } from '../helpers/createItem';

test('el ítem con nombre muy largo se trunca en la tabla', async ({ page, request }) => {
  const item = await createItem(request, { name: 'A'.repeat(256) });

  await page.goto('https://lab.becomeqa.com/items');
  // ... resto del test
});

La distinción vale la pena enunciarla claramente: los fixtures son para infraestructura como estado autenticado, contexto compartido, page objects. La lógica de negocio y el setup de datos específico de un test suele pertenecer a funciones helper.

La función storageState de Playwright es una alternativa práctica a un fixture de login para algunos proyectos. Corrés un script auth.setup.ts una vez que guarda el estado del navegador con sesión iniciada en un archivo JSON, y luego todos los tests cargan ese estado vía playwright.config.ts. Este enfoque es más rápido que un fixture de login por test, pero requiere un proyecto de setup en tu configuración. Ambos enfoques son válidos. Elegí el que mejor se adapte a tu pipeline de CI.

FAQ

¿Puedo sobreescribir un fixture para un test específico?

Sí. Usa test.extend() de nuevo para crear una versión más específica, o usa test.use() dentro de un bloque describe para sobreescribir opciones de fixtures para ese grupo. test.use() acepta un objeto con valores de fixture y los aplica a todos los tests en el scope actual.

¿Debería poner todos los fixtures en un archivo o separarlos?

Separarlos por dominio cuando el archivo se vuelva largo. Un proyecto puede tener auth.fixture.ts, data.fixture.ts y pages.fixture.ts, y luego re-exportar todo desde un index.ts. Los archivos de test importan desde el index y nunca necesitan saber en qué archivo vive un fixture.

¿Los fixtures funcionan con test.describe?

Sí. Los fixtures están disponibles dentro de cualquier bloque describe. También podés usar test.describe.configure({ mode: 'parallel' }) dentro de un bloque describe. Los fixtures respetan la configuración de paralelismo automáticamente.

¿Qué pasa si el setup de un fixture lanza una excepción?

Playwright marca el test como fallido y aún intenta ejecutar el código de teardown en los fixtures que completaron su fase de setup. Los fixtures que nunca llegaron a await use() no tienen su código de teardown ejecutado.

¿Puedo usar fixtures en beforeAll o beforeEach?

No directamente. beforeAll y beforeEach no reciben fixtures como argumentos. Si necesitas setup compartido que use fixtures, convierte el beforeEach en un fixture con su propio scope. Esta es una de las motivaciones más limpias para adoptar fixtures: hacen que beforeAll y beforeEach sean prácticamente innecesarios.

→ See also: Fixtures de Playwright Explicados: De los Integrados a los Personalizados | Page Object Model en Playwright: De Caótico a Mantenible | Manejo de Autenticación en Playwright con storageState (Sin Iniciar Sesión en Cada Test) | Datos de Prueba Reutilizables: Factories, Fixtures y Faker.js en Playwright | Archivo de Configuración de Playwright Explicado: Todas las Opciones