Authentication and Reusing Session State
Log in once instead of once per test: storage state files, a setup project with dependencies, and per-worker accounts when tests mutate the logged-in user.
Logging in through the UI in beforeEach is the most common reason a suite that should take
two minutes takes twenty. The session is just cookies and localStorage — capture it once,
and every test starts already authenticated.
The version to replace
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('correct-horse');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('shows the order list', async ({ page }) => {
await page.goto('/orders');
await expect(page.getByRole('row')).toHaveCount(4);
});
Running 24 tests using 4 workers
✓ 1 [chromium] › tests/orders.spec.ts:12:1 › shows the order list (3.4s)
…
24 passed (28.1s)
Every one of those 24 tests spent roughly 2.5 seconds on a login form that is tested elsewhere. That is 60 seconds of the 28 — and it grows linearly with the suite.
Save the state once
A setup project is an ordinary spec file that other projects depend on:
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('correct-horse');
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for the session to actually exist before saving it.
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: authFile });
});
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
use: { baseURL: 'http://localhost:3000' },
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
});
npx playwright test
Running 25 tests using 4 workers
✓ 1 [setup] › tests/auth.setup.ts:6:1 › authenticate (2.6s)
✓ 2 [chromium] › tests/orders.spec.ts:4:1 › shows the order list (721ms)
✓ 3 [chromium] › tests/orders.spec.ts:9:1 › filters by status (655ms)
…
25 passed (9.4s)
28 seconds to 9. dependencies: ['setup'] guarantees the setup project finishes before any
chromium test starts, and storageState loads that file into every new context.
The saved file is readable JSON:
cat playwright/.auth/user.json
{
"cookies": [
{
"name": "session",
"value": "eyJhbGciOiJIUzI1NiIs…",
"domain": "localhost",
"path": "/",
"expires": 1789459200,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "http://localhost:3000",
"localStorage": [{ "name": "user.id", "value": "42" }]
}
]
}
Live credentials. Add it to .gitignore before the first run:
echo "playwright/.auth" >> .gitignore
$ git status --short
?? playwright.config.ts
?? tests/auth.setup.ts
The auth directory is absent from the list — which is the point.
Testing login itself
The one spec that must not inherit the session opts out:
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
test.use({ storageState: { cookies: [], origins: [] } });
test('rejects a wrong password', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('wrong');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('alert')).toHaveText('Invalid email or password');
await expect(page).toHaveURL(/\/login$/);
});
✓ 1 [chromium] › tests/login.spec.ts:6:1 › rejects a wrong password (1.1s)
1 passed (1.8s)
An empty inline storageState is the documented way to say “clean context” — clearer than
pointing at an empty file.
Skipping the UI entirely
If the app issues a session from an API, the setup does not need a browser at all:
// tests/auth.setup.ts
import { test as setup } from '@playwright/test';
setup('authenticate via API', async ({ request }) => {
const response = await request.post('/api/session', {
data: { email: '[email protected]', password: 'correct-horse' },
});
if (!response.ok()) {
throw new Error(`login failed: ${response.status()} ${await response.text()}`);
}
// request shares a cookie jar with the context, so the Set-Cookie is captured.
await request.storageState({ path: 'playwright/.auth/user.json' });
});
✓ 1 [setup] › tests/auth.setup.ts:4:1 › authenticate via API (218ms)
1 passed (901ms)
218ms against 2.6 seconds. The trade-off is that you are no longer exercising the login
form in setup — which is fine, because login.spec.ts does that deliberately.
For a token in localStorage rather than a cookie, seed it with addInitScript so it is
present before any app code runs:
await context.addInitScript(token => {
localStorage.setItem('auth.token', token);
}, process.env.TEST_TOKEN!);
Several roles
Most apps need more than one logged-in user. Save one file per role and give each a project:
// tests/auth.setup.ts
const roles = [
{ name: 'admin', email: '[email protected]', file: 'playwright/.auth/admin.json' },
{ name: 'viewer', email: '[email protected]', file: 'playwright/.auth/viewer.json' },
];
for (const role of roles) {
setup(`authenticate as ${role.name}`, async ({ page }) => {
await login(page, role.email);
await page.context().storageState({ path: role.file });
});
}
// playwright.config.ts
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'admin',
testMatch: /.*\.admin\.spec\.ts/,
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/admin.json' },
dependencies: ['setup'],
},
{
name: 'viewer',
testMatch: /.*\.viewer\.spec\.ts/,
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/viewer.json' },
dependencies: ['setup'],
},
],
Running 14 tests using 4 workers
✓ 1 [setup] › tests/auth.setup.ts:9:3 › authenticate as admin (2.4s)
✓ 2 [setup] › tests/auth.setup.ts:9:3 › authenticate as viewer (2.5s)
✓ 3 [admin] › tests/users.admin.spec.ts:4:1 › can delete a user (834ms)
✓ 4 [viewer] › tests/users.viewer.spec.ts:4:1 › cannot see the delete button (702ms)
…
14 passed (11.2s)
The project name lands in the report line, so a failure tells you which role broke without opening anything.
When tests mutate the user
A shared account plus parallel workers is a data race: one test renames the profile while another asserts on the old name. Give each worker its own account and its own state file:
// tests/fixtures.ts
import { test as base } from '@playwright/test';
import fs from 'node:fs';
export const test = base.extend<{}, { workerStorageState: string }>({
storageState: ({ workerStorageState }, use) => use(workerStorageState),
workerStorageState: [
async ({ browser }, use) => {
const id = test.info().parallelIndex;
const file = `playwright/.auth/worker-${id}.json`;
if (!fs.existsSync(file)) {
const page = await browser.newPage({ storageState: undefined });
await login(page, `user-${id}@example.com`);
await page.context().storageState({ path: file });
await page.close();
}
await use(file);
},
{ scope: 'worker' },
],
});
Running 24 tests using 4 workers
✓ 1 [chromium] › tests/profile.spec.ts:5:1 › renames the account (1.9s)
✓ 2 [chromium] › tests/profile.spec.ts:11:1 › shows the current name (812ms)
…
24 passed (12.7s)
Four logins for 24 tests, and no two workers touching the same row. Overriding the built-in
storageState fixture is the trick that makes it apply to every context automatically.
Expiry
A state file older than the session lifetime produces a puzzling failure: the test lands on
/login and every locator times out. Two defences — regenerate through the setup project on
every run (the default above), and assert the session is live in setup rather than assuming
it:
setup('authenticate', async ({ page }) => {
await login(page);
await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible();
await page.context().storageState({ path: authFile });
});
✘ 1 [setup] › tests/auth.setup.ts:6:1 › authenticate (5.5s)
Error: Timed out 5000ms waiting for expect(locator).toBeVisible()
Locator: getByRole('button', { name: 'Sign out' })
1 failed
24 did not run
One clear failure in setup, and the dependent tests are skipped rather than producing 24 mysterious timeouts.
Practice
1. Add a setup project and confirm it runs before the tests that depend on it.
npx playwright test --project=chromium
Running 4 tests using 3 workers
✓ 1 [setup] › tests/auth.setup.ts:6:1 › authenticate (2.5s)
✓ 2 [chromium] › tests/orders.spec.ts:4:1 › shows the order list (703ms)
✓ 3 [chromium] › tests/orders.spec.ts:9:1 › filters by status (688ms)
✓ 4 [chromium] › tests/orders.spec.ts:14:1 › opens an order (745ms)
4 passed (4.6s)
Asking for --project=chromium still runs setup — dependencies are pulled in
automatically, which is why they are declared in the config rather than on the command line.
2. Make the setup fail and observe what happens to the dependent tests.
✘ 1 [setup] › tests/auth.setup.ts:6:1 › authenticate (5.4s)
1 failed
[setup] › tests/auth.setup.ts:6:1 › authenticate ────────────────────────────
23 did not run
“did not run” rather than 23 failures. A broken login reports as one problem, which is what you want at 3am — the alternative is a wall of timeouts that all say the same thing.
3. Write a test that asserts a logged-out visitor is redirected from a protected page.
test.use({ storageState: { cookies: [], origins: [] } });
test('redirects anonymous visitors to login', async ({ page }) => {
await page.goto('/orders');
await expect(page).toHaveURL(/\/login\?next=%2Forders$/);
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
});
✓ 1 [chromium] › tests/auth.spec.ts:5:1 › redirects anonymous visitors to login (912ms)
1 passed (1.6s)
Asserting the next parameter as well as the path catches a regression that loses the
return URL — a bug that is invisible if you only check you landed on /login.
4. Inspect the storage state file and remove one cookie by hand, then run a test.
✘ 1 [chromium] › tests/orders.spec.ts:4:1 › shows the order list (5.6s)
Error: Timed out 5000ms waiting for expect(locator).toHaveCount(expected)
Locator: getByRole('row')
Expected: 4
Received: 0
Call log:
- waiting for getByRole('row')
This is exactly what an expired session looks like, and it explains nothing on its own. When a whole project fails this way, check the auth file before you debug a single test.
Next: network interception — mocking an API so a test does not depend on a backend.