Almost every substantive scenario requires an authenticated user, and the naive path - logging in through the form at the start of each test - kills the suite twice. It is slow: a hundred tests pass the login form a hundred times. And it is fragile: any instability of the login page brings down all scenarios at once, though the login has nothing to do with their essence. The professional approach separates logging in and using: you authenticate once and reuse the state many times.
The mechanism is built around storageState - a snapshot of the authentication state. After a successful login Playwright can save the context's cookies and localStorage to a file, and then start a new context already with this state, bypassing the form. This is the same way a browser remembers you between sessions: not a repeated login but a restoration of the already obtained state. Log in once - and then all tests start already authenticated.
The login is moved into a separate setup test. The auth.setup.ts file passes the login form once - like an ordinary scenario, with getByLabel and getByRole - waits for a sign of successful login and calls context().storageState({ path }), saving the state to a file. The credentials are taken from environment variables, not hardcoded: secrets have no place in test code. This is the only place where login actually happens.
The setup and the main tests are tied via projects with a dependency. A separate project named setup catches files like *.setup.ts by testMatch, and the main browser project declares dependencies on it and picks up the ready storageState via use. Playwright guarantees the order itself: setup runs first, writes the state file, and only then the main scenarios start - already authenticated, without a single repeated login.
It helps to see both parts side by side once - the setup test that saves the state and the projects configuration that picks it up. Below is the authentication file and the projects bundle with dependencies; you return to this pair when setting up a new environment or adding a second role that needs its own saved login.
The saved state is a sensitive file. storageState holds live session cookies with which one can sign in as the user; it must be treated as a secret. The directory with these files is necessarily added to .gitignore, so the authentication state does not leak into the repository and its history. This is not a formality: a committed storageState is essentially the account keys posted to git.
Roles and a shared account need separate attention. Different roles - admin, ordinary user, guest - get separate storageStates and separate setup projects, so a scenario starts in the needed role right away. And if tests change the account's server-side state - the profile, settings, balance - a shared account for all will lead to races; then you create a separate account per worker or scenario, so the changes do not overlap.
The typical authentication failures are predictable. Logging in through the UI in every test - a slow suite where a login-form failure ruins everything at once. Hardcoding the password right in the test - a secret in the code and in git history. A committed .auth directory - leaked session cookies. And one shared account under tests that change it - races on server-side state. Authenticate once in setup, keep the state out of git and separate roles and mutable accounts.
// auth.setup.ts - log in once, state to a file
import { test as setup, expect } from '@playwright/test'
import path from 'node:path'
const authFile = path.join(import.meta.dirname, '.auth/user.json')
setup('authenticate', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill(process.env.E2E_EMAIL!)
await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!)
await page.getByRole('button', { name: 'Sign in' }).click()
await expect(page).toHaveURL('/dashboard')
await page.context().storageState({ path: authFile })
})// playwright.config.ts - setup runs first, the main project takes the state
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
]