~16 min · playwright-advanced, auth, storage-state
Level 0Test Curious
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Logging in every test is paying the auth tax fifty times."
The Cost of Per-Test Login
A typical login flow takes 5-15 seconds — navigate to login page, fill email, fill password, click submit, wait for redirect, wait for dashboard. If every test starts with that, a suite of 50 tests adds 4-12 minutes of pure login. Multiply by three browsers and you've blown your CI budget on auth.
The fix: log in once, capture the resulting browser state (cookies, localStorage, sessionStorage), and inject it into every other test. Each test starts already logged in. The login flow itself gets ONE dedicated test that verifies it still works.
The Setup Project Pattern
Playwright's project dependencies model fits perfectly:
Define a setup project that runs before everything else.
The setup spec navigates through the login flow ONCE and saves storage state to a file (typically playwright/.auth/user.json).
Other projects declare dependencies: ['setup'] and use: { storageState: 'playwright/.auth/user.json' }.
Every test in those projects starts with that storage state already loaded — already logged in.
If your app needs multiple roles (admin, member, anonymous), create one setup per role and let projects pick the right state file.
Where the Auth State Goes
playwright/.auth/ is a conventional directory. Add it to .gitignore — the file contains real session tokens. Each test setup writes the file fresh, so it's never stale across runs.
Test the login flow once. Test what login enables many times. A 50-test auth-required suite shouldn't include 50 login-flow tests. Test the login flow as one focused spec; everything else assumes login worked and tests the post-login features.
API-Based Login — Even Faster
If your app has an API endpoint that returns auth tokens (most modern apps do), you can skip the UI login entirely:
POST /api/auth/login via Playwright's request fixture.
Set the resulting cookie / localStorage item via context.addCookies() or context.addInitScript().
Save storage state.
This takes ~100ms instead of seconds. The trade-off: it bypasses your real login UI, so the login flow needs its own dedicated test to make sure that UI still works.
Per-Test Logout
Some tests need to start logged-out — testing the login flow, testing public pages. For those, override the project setting per-test or per-file: test.use({ storageState: { cookies: [], origins: [] } }) gives that file an empty auth state. The Playwright project for public tests should not declare a storageState at all.
Code
Setup spec — runs once, saves auth·typescript
// e2e/global.setup.ts — runs first, saves auth state for everyone else
import { test as setup, expect } from '@playwright/test';
const USER_AUTH_FILE = 'playwright/.auth/user.json';
setup('authenticate as standard user', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for the post-login landing page so the cookie is actually set.
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
// Save cookies + localStorage to the file.
await page.context().storageState({ path: USER_AUTH_FILE });
});
// playwright.config.ts — projects that consume the saved state
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
// Setup project — runs first, no auth itself
{
name: 'setup',
testMatch: /global\.setup\.ts/,
},
// Authenticated project — depends on setup, loads the saved state
{
name: 'chromium',
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
},
// Public project — no auth (for landing page, login flow, etc.)
{
name: 'chromium-public',
testMatch: /\.public\.spec\.ts/,
use: { ...devices['Desktop Chrome'] },
// No storageState — these tests are logged out.
},
],
});
Tests using the saved state (or explicitly not)·typescript
// Authenticated test — starts logged in already
import { test, expect } from '@playwright/test';
test('shows the dashboard for the logged-in user', async ({ page }) => {
await page.goto('/dashboard');
// No login dance — the storage state from setup is already loaded.
await expect(
page.getByRole('heading', { name: /welcome back/i })
).toBeVisible();
});
// e2e/landing.public.spec.ts — runs in the chromium-public project
import { test as publicTest, expect } from '@playwright/test';
publicTest('landing page shows Sign in for anonymous users', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible();
});
API-based auth — faster than UI login·typescript
// e2e/global.setup.ts — API-based auth (much faster)
import { test as setup, expect } from '@playwright/test';
const USER_AUTH_FILE = 'playwright/.auth/user.json';
setup('authenticate via API', async ({ request, page }) => {
// Hit the login API directly — ~100ms instead of ~5s
const response = await request.post('/api/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD,
},
});
expect(response.ok()).toBeTruthy();
// The API set a Set-Cookie header on the response. Now save the context state.
await page.context().storageState({ path: USER_AUTH_FILE });
});
Take your existing E2E suite (or set up the example login flow if you don't have one). Implement the storage-state pattern: a setup project that logs in and saves state, an authenticated project that uses it. Measure the suite runtime before and after — for a suite with even 10 auth-required tests, you should see a noticeable drop. Bonus: add an API-based variant of setup and see how much faster than UI login it is.
Hint
If your authenticated tests fail with 'expected logged-in state, got login page,' the storageState file may not have been written (setup project failed silently) or the file path doesn't match. Run setup alone with --project=setup, confirm the file exists, then run a single authenticated test.
Progress
Progress is local-only — sign in to sync across devices.