Autenticación con storageState en Playwright: login una vez, tests sin login
Login una vez, guardar cookies en JSON, tests sin login. Un typo de 30s, la prueba sin archivo, y lo que en Selenium es código custom acá son 2 líneas.
El problema
Hasta ahora, cada test hace login. Incluso con el custom fixture del Post Fixtures en Playwright, cada test ejecuta: ir a /Account/Login, llenar usuario, llenar contraseña, clickear "Iniciar sesión", esperar "Tablero".
Eso son 4-5 acciones por test. Si tengo 20 tests, son 100 acciones de login. Si tengo 100 tests, son 500. Cada una contra la red, contra el servidor, con tiempos de carga.
El login no es lo que estoy testeando. Es un prerequisito. Y repetirlo en cada test es tiempo desperdiciado.
storageState resuelve esto: hacés login una vez, guardás las cookies en un archivo JSON, y todos los tests arrancan ya logueados. Sin tocar la página de login.
Cómo funciona
El flujo:
1. auth.setup.ts hace login → guarda cookies en .auth/user.json
2. playwright.config.ts dice: "antes de correr tests, corré setup"
3. Cada browser carga las cookies de .auth/user.json
4. Los tests arrancan logueados
No hay código de login en los tests. No hay beforeEach con login. No hay fixture de login. Solo cookies.
auth.setup.ts — el archivo que hace login una vez
Creé tests/auth.setup.ts:
import { test as setup, expect } from '@playwright/test';
const authFile = '.auth/user.json';
setup('autenticación', async ({ page }) => {
await page.goto('/Account/Login');
await page.getByRole('textbox', { name: '* Nombre de usuario' }).fill('admin');
await page.getByRole('textbox', { name: '* Contraseña' }).fill('serenity');
await page.getByRole('button', { name: 'Iniciar sesión', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Tablero' })).toBeVisible();
await page.context().storageState({ path: authFile });
});

Es un "test" que no testea nada. Solo hace login y guarda el estado del browser en un archivo JSON. La línea clave es page.context().storageState({ path: authFile }) — toma todas las cookies del context actual y las escribe en .auth/user.json.
Importo test as setup para que quede claro en los reportes que esto no es un test, es un setup.
Primer error: un typo de 30 segundos
Corrí npx playwright test y esto pasó:
Error: locator.fill: Test timeout of 30000ms exceeded.
Call log:
- waiting for getByRole('textbox', { name: '* Constraseña' })

30 segundos esperando un campo que no existe. 36 tests que no corrieron. El problema: escribí "Constraseña" en vez de "Contraseña". Una 's' de más.
Playwright no te dice "este locator no existe". Se queda esperando hasta que el timeout explota. Por eso el error dice timeout exceeded, no locator not found. Es auto-retry trabajando — pero cuando el locator tiene un typo, el auto-retry es tu enemigo. 30 segundos buscando algo que nunca va a aparecer.
Corregí el typo. Una letra. Constraseña → Contraseña.
La config: dependencies y storageState
El playwright.config.ts necesita dos cosas: un proyecto setup que corra el login, y que los browsers dependan de ese setup.
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' },
dependencies: ['setup'],
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'], storageState: '.auth/user.json' },
dependencies: ['setup'],
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'], storageState: '.auth/user.json' },
dependencies: ['setup'],
},
],

testMatch: /auth\.setup\.ts/ le dice a Playwright que el proyecto setup solo corre ese archivo. dependencies: ['setup'] dice que chromium, firefox y webkit esperan a que setup termine antes de arrancar. storageState: '.auth/user.json' dice que cada browser cargue las cookies de ese archivo.
Punto importante: agregar .auth/ al .gitignore. Las cookies tienen tokens de autenticación.
37/37 — todo verde
Con el typo corregido:
Running 37 tests using 2 workers
37 passed (1.7m)

37 tests. auth.setup.ts corre primero (7.3s), hace login, guarda cookies. Después los 36 tests restantes corren en 3 browsers con esas cookies precargadas.
Un detalle: los tests de serenity-assertions, serenity-fixtures y serenity-locators están haciendo doble login. storageState ya los logueó (las cookies están cargadas), y después el beforeEach o el código inline vuelve a hacer login encima. Funciona porque demo.serenity.is no se rompe con doble login, pero es redundante. Los tests nuevos que escriba no necesitan login en absoluto.
Tests sin login — el punto de storageState
Creé tests/serenity-storagestate.spec.ts:
import { test, expect } from '@playwright/test';
test.describe('Tests sin login (storageState)', () => {
test('acceder al dashboard sin login', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Tablero' })).toBeVisible();
});
test('acceder a clientes sin login', async ({ page }) => {
await page.goto('/Northwind/Customer');
await expect(page.locator('#GridDiv')).toContainText('ANTON');
});
});

Lo relevante de este archivo es lo que no necesita: no importa LoginPage. No tiene beforeEach. No llena campos. No clickea botones de login. Va directo a la página y verifica.
Lo relevante de este archivo es lo que no necesita.
Resultado:
Running 7 tests using 2 workers
7 passed (38.2s)

7 tests: 1 de setup + 6 de contenido (2 tests × 3 browsers). Los 6 tests entraron al dashboard y a clientes sin hacer login. Las cookies hicieron todo.
Qué hay adentro de user.json
Abrí .auth/user.json:
{
"cookies": [
{
"name": ".AspNetCore.Antiforgery",
"value": "CfDJ8CI7v...",
"domain": "demo.serenity.is",
"path": "/",
"httpOnly": true,
"sameSite": "Strict"
},
{
"name": ".AspNetAuth",
"value": "CfDJ8CI7v...",
"domain": "demo.serenity.is",
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "CSRF-TOKEN",
"value": "CfDJ8CI7v...",
"domain": "demo.serenity.is",
"httpOnly": false,
"sameSite": "Lax"
}
],
"origins": []
}

3 cookies: antiforgery, autenticación y CSRF. Eso es lo que el servidor necesita para reconocerte como logueado. Playwright las inyecta en el browser context antes de que el test empiece.
La prueba: sin cookies, todo se rompe
Borré .auth/user.json y corrí solo los tests de storageState sin el setup:
npx playwright test tests/serenity-storagestate.spec.ts --project=chromium --no-deps
--no-deps salta el proyecto setup. Sin cookies, no hay login.
Error: Error reading storage state from .auth/user.json:
ENOENT: no such file or directory



2 failed en 9ms cada uno. Playwright ni siquiera llegó a abrir una página — falló al crear el context porque el archivo de cookies no existe. Esto confirma que storageState es lo que mantiene los tests logueados. Sin el archivo, no hay sesión.
Comparación con Selenium
En mi framework de Selenium (qa-automation-lab), para reutilizar sesión tendría que:
// Guardar cookies después del login
Set<Cookie> cookies = driver.manage().getCookies();
// Serializar con Gson o Jackson
String json = new Gson().toJson(cookies);
Files.write(Paths.get("cookies.json"), json.getBytes());
// En cada test: leer y restaurar
String json = new String(Files.readAllBytes(Paths.get("cookies.json")));
Type type = new TypeToken<Set<Cookie>>(){}.getType();
Set<Cookie> cookies = new Gson().fromJson(json, type);
for (Cookie cookie : cookies) {
driver.manage().addCookie(cookie);
}
driver.navigate().refresh();
Código custom. Dependencia extra (Gson). Serialización manual. Y encima, hay que navegar primero al dominio antes de poder agregar cookies — si no, Selenium tira invalid cookie domain.
En Playwright: storageState({ path: authFile }) para guardar. storageState: '.auth/user.json' en el config para restaurar. Dos líneas. Sin dependencias extra. Sin serialización manual. Sin problemas de dominio.
| Concepto | Selenium + Java | Playwright |
|---|---|---|
| Guardar sesión | getCookies() + Gson + Files.write() |
storageState({ path }) |
| Restaurar sesión | readAllBytes + Gson + loop addCookie() |
storageState en config |
| Navegar al dominio antes | Obligatorio (si no, invalid cookie domain) |
No necesario |
| Dependencias extra | Gson o Jackson | Ninguna |
| Integración con config | No existe (código manual) | Propiedad del config |
| Ejecutar login una vez para toda la suite | Custom con @BeforeSuite + variable global |
Proyecto setup con dependencies |
Estado actual del proyecto
playwright-typescript-framework/
├── .auth/
│ └── user.json ← cookies (gitignored)
├── fixtures/
│ └── test-fixtures.ts
├── pages/
│ ├── LoginPage.ts
│ ├── DashboardPage.ts
│ └── ClientesPage.ts
├── tests/
│ ├── auth.setup.ts ← login una vez, guarda cookies
│ ├── serenity-storagestate.spec.ts ← tests sin login
│ ├── serenity-assertions.spec.ts
│ ├── serenity-fixtures.spec.ts
│ ├── serenity-locators.spec.ts
│ ├── example.spec.ts
│ └── saucedemo.spec.ts
├── playwright.config.ts ← projects con setup + dependencies
└── package.json
El siguiente post cubre tests parametrizados con múltiples datasets — login válido, inválido, campos vacíos. Equivalente a los DataProviders de TestNG.
—
🔗 Todo el código de esta serie está en: github.com/cesarbeassuarez/playwright-typescript-framework