Cuando escribes async ({ page }) => {...} en un test de Playwright, estás recibiendo un fixture: Playwright creó una página de navegador nueva antes de que corriera tu test y la cerrará automáticamente después. Los fixtures personalizados funcionan de la misma manera, declarados con test.extend() y recibidos por nombre en la firma del test. La diferencia es que tú defines el setup y el teardown, con await use(value) como la línea divisoria. Esta guía cubre los cinco fixtures integrados, el patrón test.extend() para fixtures personalizados, las opciones de scope para compartir setup costoso entre tests, y cómo los fixtures personalizados se componen entre sí.

Qué es un fixture

Un fixture es un valor (u objeto) que Playwright prepara antes de que corra tu test y limpia después. Es inyección de dependencias para tests.

En lugar de escribir esto:

test('el usuario puede iniciar sesión', async () => {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();
  
  // código del test
  
  await page.close();
  await context.close();
  await browser.close();
});

Escribes esto:

test('el usuario puede iniciar sesión', async ({ page }) => {
  // page está lista para usar: el setup y el teardown están manejados
});

Playwright gestiona el ciclo de vida. Obtenés una page limpia para cada test, y se cierra automáticamente después.

Fixtures integrados

Playwright provee estos fixtures de fábrica:

| Fixture | Tipo | Qué es |

|---------|------|--------|

| page | Page | Una nueva página del navegador (pestaña) para cada test |

| browser | Browser | La instancia del navegador (compartida entre tests en un worker) |

| context | BrowserContext | Contexto del navegador, como una ventana de incógnito |

| browserName | string | El navegador actual: 'chromium', 'firefox', 'webkit' |

| request | APIRequestContext | Cliente HTTP para peticiones a API |

page

El fixture más usado. Cada test obtiene su propia página aislada. Después del test, se cierra automáticamente.

test('la página carga correctamente', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com');
  await expect(page).toHaveTitle(/BecomeQA/);
});

context

Un contexto del navegador es como una ventana de incógnito: tiene sus propias cookies, almacenamiento y sesión. Si necesitás múltiples páginas en un test, crealas desde el mismo contexto:

test('dos páginas comparten la misma sesión', async ({ context }) => {
  const page1 = await context.newPage();
  const page2 = await context.newPage();
  
  await page1.goto('/login');
  // Iniciar sesión en page1
  
  // page2 también ve la sesión (mismo contexto = mismas cookies)
  await page2.goto('/dashboard');
  await expect(page2.getByTestId('user-name')).toBeVisible();
});

browser

Generalmente no necesitas browser directamente. Úsalo cuando necesitas crear contextos con ajustes específicos:

test('viewport móvil', async ({ browser }) => {
  const context = await browser.newContext({
    viewport: { width: 390, height: 844 },
    userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)...',
  });
  const page = await context.newPage();
  await page.goto('/');
  // Test en página de tamaño móvil
  await context.close();
});

request

Hace peticiones HTTP sin un navegador. Se usa para testing de API y para configurar datos de test vía API antes de tests de UI.

test('crear usuario vía API', async ({ request }) => {
  const response = await request.post('/api/users', {
    data: { email: 'nuevo@test.com', password: 'ClaveValida1' },
  });
  expect(response.status()).toBe(201);
});

browserName

Úsalo para saltear condicionalmente tests en navegadores específicos:

test('descarga de archivo', async ({ page, browserName }) => {
  test.skip(browserName === 'firefox', 'API de descarga diferente en Firefox');
  // ...
});

Fixtures personalizados

El verdadero poder de los fixtures: puedes crear los tuyos. Los fixtures personalizados funcionan exactamente igual que los integrados: se declaran una vez, se usan en cualquier lugar mediante destructuring.

Fixture personalizado simple: una página pre-navegada

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

// Definir qué fixtures personalizados existen
type MyFixtures = {
  loggedInPage: Page;
};

export const test = base.extend<MyFixtures>({
  loggedInPage: async ({ page }, use) => {
    // SETUP
    await page.goto('/login');
    await page.fill('[data-testid="email"]', 'usuario@test.com');
    await page.fill('[data-testid="password"]', 'ClaveValida1');
    await page.click('[data-testid="submit"]');
    await page.waitForURL('/dashboard');
    
    // Darle al test acceso a la página
    await use(page);
    
    // TEARDOWN (corre después del test)
    // Nada necesario aquí: la página se cierra automáticamente
  },
});

export { expect };

// tests/dashboard.spec.ts
import { test, expect } from '../fixtures';  // importá TU test, no @playwright/test

test('el dashboard muestra el mensaje de bienvenida', async ({ loggedInPage }) => {
  // Ya autenticado: loggedInPage ES la página, después del login
  await expect(loggedInPage.getByTestId('welcome')).toBeVisible();
});

Fixture personalizado: Page Object

El patrón más común: un fixture que provee una clase de page object inicializada:

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

type PageObjects = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
};

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

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

test('login exitoso', async ({ loginPage, dashboardPage }) => {
  await loginPage.goto();
  await loginPage.login('usuario@test.com', 'ClaveValida1');
  await expect(dashboardPage.welcomeMessage).toBeVisible();
});

No hace falta instanciar page objects en cada test.

Fixture personalizado con teardown

Si tu fixture crea algo que necesita limpieza:

type TestFixtures = {
  testUser: { id: number; email: string; token: string };
};

export const test = base.extend<TestFixtures>({
  testUser: async ({ request }, use) => {
    // SETUP: crear un usuario
    const response = await request.post('/api/users', {
      data: {
        email: `test_${Date.now()}@ejemplo.com`,
        password: 'ClaveValida1',
        role: 'member',
      },
    });
    const user = await response.json();
    
    // Iniciar sesión para obtener el token
    const loginResp = await request.post('/api/auth/login', {
      data: { email: user.email, password: 'ClaveValida1' },
    });
    const { token } = await loginResp.json();
    
    // Darle acceso al test
    await use({ id: user.id, email: user.email, token });
    
    // TEARDOWN: eliminar el usuario
    await request.delete(`/api/users/${user.id}`, {
      headers: { Authorization: `Bearer ${adminToken}` },
    });
  },
});

test('el usuario puede actualizar su perfil', async ({ page, testUser }) => {
  // testUser tiene id, email, token: fresco y único por test
  await page.goto(`/users/${testUser.id}`);
  // ...
  // Después del test, el usuario se elimina automáticamente
});

Scope de fixtures

Por defecto, los fixtures tienen scope 'test': se recrean para cada test. Puedes establecer el scope en 'worker' para fixtures costosos y seguros de compartir:

export const test = base.extend<{}, { sharedToken: string }>({
  sharedToken: [async ({ request }, use) => {
    // Corre una vez por worker, no una vez por test
    const response = await request.post('/api/auth/login', {
      data: { email: 'admin@test.com', password: 'AdminPass1' },
    });
    const { token } = await response.json();
    await use(token);
  }, { scope: 'worker' }],
});

Usa scope 'worker' para cosas que son costosas de recrear (seeding de base de datos, generación de archivos), de solo lectura (tokens de autenticación que solo lees, no modificas) y seguras de compartir (sin estado que un test pueda corromper para otro).

Combinar fixtures

Los fixtures personalizados pueden depender de otros fixtures (incluyendo otros personalizados):

export const test = base.extend<{
  loginPage: LoginPage;
  authenticatedPage: Page;
}>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  
  // Este fixture USA loginPage
  authenticatedPage: async ({ page, loginPage }, use) => {
    await loginPage.goto();
    await loginPage.login('usuario@test.com', 'ClaveValida1');
    await page.waitForURL('/dashboard');
    await use(page);
  },
});

Una estructura de proyecto limpia

proyecto/
├── fixtures/
│   └── index.ts        ← exporta tu test extendido + expect
├── pages/
│   ├── LoginPage.ts
│   └── DashboardPage.ts
└── tests/
    ├── login.spec.ts   ← importa desde fixtures/index.ts
    └── dashboard.spec.ts

Todos los archivos de test importan desde fixtures/index.ts, no desde @playwright/test directamente. Esto significa que cada test tiene acceso automático a todos los fixtures personalizados.

Resumen

| | Integrados | Personalizados |

|-|------------|----------------|

| Dónde se definen | Internos de Playwright | test.extend() en tu código |

| Dónde se usan | Cualquier test con { page }, { request }, etc. | Cualquier test que use tu test exportado |

| Ejemplos | page, browser, request | loginPage, testUser, authToken |

| Ciclo de vida | Playwright lo gestiona | Tú defines setup + await use() + teardown |

→ See also: Fixtures Personalizados en Playwright: El Patrón que Hace los Tests Legibles | Estructura de Tests en Playwright: describe, beforeEach, afterEach y Hooks | Manejo de Autenticación en Playwright con storageState (Sin Iniciar Sesión en Cada Test)