storageState captura cookies y localStorage después de un login real y los escribe en un archivo JSON. Cada contexto de test posterior carga ese archivo en lugar de pasar por el flujo de login de la UI, reduciendo 2 a 4 segundos por test en suites donde la mayoría de los tests requieren autenticación. Este artículo cubre el setup global para generar el archivo de auth, el patrón de proyecto setup que hace visibles los fallos de login en el reporte HTML, el storageState por proyecto para múltiples roles de usuario, fixtures con scope de worker para tests que modifican el estado de auth, y login por API cuando el login por UI es el cuello de botella.

Qué guarda realmente storageState

Cuando te autenticas en un navegador, el servidor prueba tu identidad a través de uno de dos mecanismos: una cookie (generalmente un ID de sesión o un JWT en una cookie HTTP-only), o un token almacenado en localStorage o sessionStorage. A veces ambos.

El storageState de Playwright captura todo. Llamar a context.storageState() devuelve un objeto JSON que contiene cada cookie del contexto y un snapshot de localStorage y sessionStorage para cada origen. Puedes escribir ese JSON en disco, y cuando Playwright crea un nuevo contexto de navegador con storageState: './auth.json', precarga todos esos datos antes de la primera navegación. Para el servidor, la petición se ve idéntica a una proveniente de la sesión autenticada original.

// Cómo se ve el archivo guardado (abreviado)
{
  "cookies": [
    {
      "name": "session",
      "value": "eyJhbGciOi...",
      "domain": "lab.becomeqa.com",
      "path": "/",
      "expires": 1748000000,
      "httpOnly": true,
      "secure": true,
      "sameSite": "Lax"
    }
  ],
  "origins": [
    {
      "origin": "https://lab.becomeqa.com",
      "localStorage": [
        { "name": "auth_token", "value": "eyJhbGciOi..." }
      ]
    }
  ]
}

El archivo es solo JSON. Podés inspeccionarlo, commitearlo en una rama solo para tests, o regenerarlo a demanda. La mayoría de los equipos lo agrega a .gitignore y lo regenera al inicio de cada ejecución de CI.

Configurar global-setup.ts

El patrón estándar es un archivo global-setup.ts que se ejecuta una vez antes de toda la suite de tests. Lanza un navegador, realiza el login real por UI, y guarda el estado resultante en un archivo. Cada worker de tests luego lee ese archivo en lugar de hacer login.

// global-setup.ts
import { chromium, FullConfig } from '@playwright/test';

async function globalSetup(config: FullConfig) {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();

  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();

  // Esperar hasta estar realmente en el dashboard autenticado
  await page.getByText('My Travel Items').waitFor({ state: 'visible' });

  // Guardar cookies + localStorage en un archivo
  await context.storageState({ path: 'playwright/.auth/admin.json' });

  await browser.close();
}

export default globalSetup;

Crea el directorio antes de ejecutar los tests, o Playwright lanzará un error de archivo no encontrado:

mkdir -p playwright/.auth

Agrega el directorio a .gitignore para que los tokens de auth no terminen en control de versiones:

# .gitignore
playwright/.auth/

Conectarlo en playwright.config.ts

Dos cosas deben pasar en tu configuración. Primero, decirle a Playwright dónde está global-setup.ts. Segundo, decirle a cada proyecto de test que use el estado guardado como contexto inicial.

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  globalSetup: require.resolve('./global-setup'),

  use: {
    baseURL: 'https://lab.becomeqa.com',
    storageState: 'playwright/.auth/admin.json',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
  ],
});

Eso es suficiente para eliminar el login de cada test. El storageState definido en use aplica globalmente, así que cada contexto de navegador que crea Playwright arrancará pre-autenticado.

Pon la ruta del archivo de auth en una constante al principio de tu archivo de configuración en lugar de repetir el string. Cuando agregás un segundo rol de usuario, solo actualizás un lugar: const ADMIN_AUTH = 'playwright/.auth/admin.json'.

El patrón de proyecto setup (recomendado para suites más grandes)

El hook globalSetup funciona, pero tiene una desventaja: se ejecuta fuera del sistema de proyectos y reporters de Playwright. Los fallos en global-setup.ts producen output mínimo, y el setup no aparece en tu reporte HTML.

La alternativa recomendada, introducida en Playwright 1.31, es un proyecto setup dedicado. Se ejecuta antes que otros proyectos y se beneficia de todo el pipeline de reporting.

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  use: {
    baseURL: 'https://lab.becomeqa.com',
  },

  projects: [
    // El proyecto setup se ejecuta primero y produce los archivos de auth
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },

    // Los proyectos de test dependen de que setup complete primero
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/admin.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'firefox',
      use: {
        ...devices['Desktop Firefox'],
        storageState: 'playwright/.auth/admin.json',
      },
      dependencies: ['setup'],
    },
  ],
});

El archivo de setup en sí es un archivo de test ordinario de Playwright:

// tests/auth.setup.ts
import { test as setup } from '@playwright/test';

setup('autenticarse como admin', async ({ page }) => {
  await page.goto('/');
  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({ state: 'visible' });

  await page.context().storageState({ path: 'playwright/.auth/admin.json' });
});

Ahora el paso de login aparece en tu reporte HTML, la lógica de reintentos aplica si la página de login es flaky, y los screenshots en caso de fallo se capturan automáticamente.

StorageState por proyecto para múltiples roles de usuario

La mayoría de las aplicaciones tienen más de un rol de usuario y necesitas testarlos de forma independiente. Un usuario admin ve los controles de administración. Un usuario regular no los ve. Si ejecutás tests de admin con la sesión de un usuario regular, van a fallar por el motivo equivocado.

Agregá un paso de setup por rol, un archivo de auth por rol, y un proyecto de test por rol:

// tests/auth.setup.ts
import { test as setup } from '@playwright/test';

setup('autenticarse como admin', async ({ page }) => {
  await page.goto('/');
  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({ state: 'visible' });

  await page.context().storageState({ path: 'playwright/.auth/admin.json' });
});

setup('autenticarse como usuario regular', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button', { name: 'Login' }).click();
  await page.getByLabel('Username').fill('user@becomeqa.com');
  await page.getByLabel('Password').fill('userpass456');
  await page.getByRole('button', { name: 'Submit' }).click();
  await page.getByText('My Travel Items').waitFor({ state: 'visible' });

  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

const ADMIN_AUTH = 'playwright/.auth/admin.json';
const USER_AUTH = 'playwright/.auth/user.json';

export default defineConfig({
  use: {
    baseURL: 'https://lab.becomeqa.com',
  },

  projects: [
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },

    // Tests de admin
    {
      name: 'admin-chromium',
      testMatch: /.*admin.*\.spec\.ts/,
      use: {
        ...devices['Desktop Chrome'],
        storageState: ADMIN_AUTH,
      },
      dependencies: ['setup'],
    },

    // Tests de usuario regular
    {
      name: 'user-chromium',
      testMatch: /.*user.*\.spec\.ts/,
      use: {
        ...devices['Desktop Chrome'],
        storageState: USER_AUTH,
      },
      dependencies: ['setup'],
    },

    // Tests que no necesitan auth (landing page, tests del flujo de login)
    {
      name: 'public',
      testMatch: /.*public.*\.spec\.ts/,
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

Cada proyecto carga un archivo de auth diferente, y tus tests de admin nunca corren accidentalmente con la sesión de un usuario regular.

Los tests que verifican el flujo de login en sí (contraseña incorrecta, cuenta bloqueada, sesión expirada) deberían vivir en el proyecto public y usar el fixture page sin ningún storageState. El objetivo de esos tests es precisamente pasar por la UI de login.

Fixtures con scope de worker para storageState (patrón avanzado)

Hay un problema sutil con storageState en playwright.config.ts: aplica al contexto del navegador. Si un test hace algo que modifica el estado de autenticación, como actualizar el perfil del usuario, cambiar su email, o (el peor caso) hacer logout, el contexto modificado puede filtrarse al siguiente test en el mismo worker.

La solución es crear un contexto fresco por test, cargado desde el archivo de auth estático, en lugar de compartir un contexto entre tests. Un fixture con scope de worker maneja esto limpiamente:

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

const ADMIN_AUTH = path.resolve('playwright/.auth/admin.json');

type AuthFixtures = {
  // Worker-scoped: la ruta del archivo de auth, cargada una vez por worker
  adminStorageState: string;
};

type TestFixtures = {
  // Test-scoped: un contexto fresco por test, cargado desde el archivo de estado
  adminContext: BrowserContext;
};

export const test = base.extend<TestFixtures, AuthFixtures>({
  // El fixture de worker solo guarda la ruta, valida que el archivo exista una vez
  adminStorageState: [
    async ({}, use) => {
      await use(ADMIN_AUTH);
    },
    { scope: 'worker' },
  ],

  // El fixture de test crea un contexto fresco desde el estado guardado
  adminContext: async ({ browser, adminStorageState }, use) => {
    const context = await browser.newContext({
      storageState: adminStorageState,
    });

    await use(context);

    await context.close();
  },
});

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

Los tests que usan este fixture obtienen un contexto de navegador aislado que arranca autenticado, pero cualquier cambio de estado dentro del test no afecta a otros tests:

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

test('el admin puede ver el panel de administración', async ({ adminContext }) => {
  const page = await adminContext.newPage();
  await page.goto('/');
  await expect(page.getByRole('link', { name: 'Admin Panel' })).toBeVisible();
});

test('el admin puede eliminar cualquier ítem', async ({ adminContext }) => {
  const page = await adminContext.newPage();
  await page.goto('/items');
  await page.getByTestId('item-row-1').getByRole('button', { name: 'Delete' }).click();
  await page.getByRole('button', { name: 'Confirm' }).click();
  await expect(page.getByTestId('item-row-1')).not.toBeVisible();
});

Cada test obtiene su propio BrowserContext inicializado fresco desde el archivo de auth. La eliminación en el segundo test no afecta ningún estado compartido.

Combinar storageState con login por API (setup más rápido)

El archivo auth.setup.ts mostrado anteriormente hace un login completo por UI: navega, hace clic, llena formularios y espera. Funciona, pero sigue siendo varios segundos. En una máquina de CI lenta o cuando el formulario de login tiene animaciones, puede ser el cuello de botella.

Si tu aplicación tiene un endpoint de login por API, podés llamarlo directamente desde el paso de setup, saltarte la UI por completo, y escribir el token resultante en el storage state manualmente. Esto es típicamente entre 5 y 10 veces más rápido que el enfoque por UI:

// tests/auth.setup.ts (versión por API)
import { test as setup, request } from '@playwright/test';
import fs from 'fs';
import path from 'path';

const AUTH_FILE = 'playwright/.auth/admin.json';

setup('autenticarse como admin vía API', async ({ request }) => {
  // Llamar al endpoint de login directamente
  const response = await request.post('https://lab.becomeqa.com/api/auth/login', {
    data: {
      email: 'admin@becomeqa.com',
      password: 'testpass123',
    },
  });

  const { token, sessionCookie } = await response.json();

  // Construir la estructura de storageState manualmente
  const storageState = {
    cookies: [
      {
        name: 'session',
        value: sessionCookie,
        domain: 'lab.becomeqa.com',
        path: '/',
        expires: Math.floor(Date.now() / 1000) + 86400, // 24 horas
        httpOnly: true,
        secure: true,
        sameSite: 'Lax' as const,
      },
    ],
    origins: [
      {
        origin: 'https://lab.becomeqa.com',
        localStorage: [
          { name: 'auth_token', value: token },
        ],
      },
    ],
  };

  // Asegurarse de que el directorio existe
  fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true });
  fs.writeFileSync(AUTH_FILE, JSON.stringify(storageState, null, 2));
});

El tradeoff: este enfoque requiere que conozcas la estructura exacta del almacenamiento de auth de tu aplicación (qué cookies configura, qué claves de localStorage lee). El enfoque por UI funciona independientemente de los detalles de implementación. Solo haces login y guardas lo que termina en el navegador. Empieza con el enfoque por UI y cambia a la versión por API si el login se convierte en un cuello de botella medible.

Cuándo falla storageState

storageState no es magia. Es un snapshot del estado del navegador en un momento específico. Algunas cosas lo harán dejar de funcionar.

Expiración del token

Si tu aplicación usa JWTs de corta duración (15 minutos, 1 hora), el token guardado estará expirado cuando corran los tests posteriores. La solución es regenerar el archivo de auth al inicio de cada ejecución de CI (lo que deberías estar haciendo de todas formas), o cambiar al login por API que siempre emite un token fresco.

Invalidación de sesión del lado del servidor

Algunas aplicaciones invalidan sesiones cuando detectan patrones anómalos. Múltiples peticiones simultáneas desde la "misma" sesión en diferentes procesos worker es uno de esos patrones. Si ves errores 401 aleatorios en tests que deberían estar autenticados, verifica si tu aplicación tiene protecciones de fijación de sesión que están tratando a los workers paralelos como sospechosos.

Autenticación de dos factores

El 2FA rompe por completo el setup de storageState por UI. El flujo de login requiere un código TOTP o verificación por SMS que no puedes automatizar a través de Playwright de forma general. Las soluciones prácticas son: usar una cuenta de test dedicada con el 2FA deshabilitado (si tu aplicación lo permite), usar login por API que emite tokens sin 2FA para entornos de test, o agregar una variable de entorno que omita el 2FA cuando NODE_ENV=test.

Sesiones ligadas al navegador

Algunas aplicaciones atan sesiones a fingerprints del navegador, certificados de cliente TLS o IDs de dispositivo. Si tus cookies de sesión tienen atributos que las restringen a características específicas del dispositivo, guardarlas y restaurarlas entre diferentes instancias del navegador no va a funcionar. Es poco común en aplicaciones web pero vale la pena conocerlo.

// Verificar que el estado guardado sigue siendo válido. Agregar este check a auth.setup.ts.
setup('autenticarse como admin', async ({ page }) => {
  // Intentar cargar el estado existente primero
  const AUTH_FILE = 'playwright/.auth/admin.json';

  if (fs.existsSync(AUTH_FILE)) {
    // Verificar si el token existente sigue siendo válido
    const checkContext = await browser.newContext({ storageState: AUTH_FILE });
    const checkPage = await checkContext.newPage();
    await checkPage.goto('/');

    const isAuthenticated = await checkPage.getByText('My Travel Items').isVisible();
    await checkContext.close();

    if (isAuthenticated) {
      console.log('El estado de auth existente es válido, saltando login');
      return; // Reutilizar el archivo existente
    }
  }

  // El estado es inválido o no existe, hacer el login completo
  await page.goto('/');
  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({ state: 'visible' });

  await page.context().storageState({ path: AUTH_FILE });
});

No commitees archivos de auth en tu rama principal. playwright/.auth/*.json contiene tokens de sesión reales que dan acceso a tus cuentas de test. Agrega el directorio a .gitignore y rota las contraseñas de las cuentas de test regularmente. Si estás usando variables de entorno para las credenciales (lo que deberías estar haciendo en CI), asegúrate de que esas variables no se registren en el output del pipeline.
→ See also: Fixtures Personalizados en Playwright: El Patrón que Hace los Tests Legibles | Archivo de Configuración de Playwright Explicado: Todas las Opciones | Pruebas de API con el APIRequestContext de Playwright (Sin Postman) | Aislamiento de Tests: Por qué Cada Test de Playwright Debe ser sin Estado | Configuración y Limpieza Global en Playwright