02 · Authentication State Reuse¶
Logging in through the UI at the start of every test is slow and, worse, makes every single test depend on the login form working — a bug in login takes down your entire suite instead of just the login tests. Playwright lets you log in once, save the resulting browser storage state, and reuse it across tests and workers.
Saving storage state after a UI login¶
# auth_setup.py — run once to produce storage_state.json
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://example.com/login")
page.get_by_label("Email").fill("qa-user@example.com")
page.get_by_label("Password").fill("correct-horse-battery-staple")
page.get_by_role("button", name="Sign in").click()
page.wait_for_url("**/dashboard")
page.context.storage_state(path="storage_state.json")
browser.close()
// storage_state.json (shape)
{
"cookies": [
{"name": "session_id", "value": "…", "domain": "example.com", "path": "/", "expires": 1735689600}
],
"origins": [
{"origin": "https://example.com", "localStorage": [{"name": "auth_token", "value": "…"}]}
]
}
storage_state() captures both cookies and localStorage/sessionStorage
per origin, which matters because many SPAs keep the actual auth token in
localStorage, not a cookie.
Reusing it as a pytest fixture¶
# conftest.py
import pytest
from playwright.sync_api import Browser
@pytest.fixture
def authenticated_page(browser: Browser):
context = browser.new_context(storage_state="storage_state.json")
page = context.new_page()
yield page
context.close()
# test_dashboard.py
def test_dashboard_shows_username(authenticated_page):
authenticated_page.goto("/dashboard")
expect(authenticated_page.get_by_text("Welcome, qa-user")).to_be_visible()
# no login form is ever touched — new_context(storage_state=...)
# seeds the browser context's cookie jar and origin storage
# before the first page is even created, so the app believes
# a real login already happened
A session-scoped fixture to log in once per test run¶
Logging in via the UI still has a cost even at "once per test file" — do it once for the whole run instead, then hand every test a context built from that saved state:
# conftest.py
import pytest
from playwright.sync_api import Playwright, sync_playwright
@pytest.fixture(scope="session")
def storage_state_path(tmp_path_factory):
path = tmp_path_factory.mktemp("auth") / "state.json"
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://example.com/login")
page.get_by_label("Email").fill("qa-user@example.com")
page.get_by_label("Password").fill("correct-horse-battery-staple")
page.get_by_role("button", name="Sign in").click()
page.wait_for_url("**/dashboard")
page.context.storage_state(path=str(path))
browser.close()
return path
@pytest.fixture
def page(browser, storage_state_path):
context = browser.new_context(storage_state=str(storage_state_path))
page = context.new_page()
yield page
context.close()
# login runs exactly once for the entire pytest session
# (scope="session"), and every test's `page` fixture builds
# a fresh context from the resulting file — tests stay
# isolated from each other (separate contexts) while sharing
# the one-time login cost
Multiple roles: admin vs. regular user¶
Real apps need more than one identity. Parameterize the saved-state fixture by role instead of hard-coding one login:
import pytest
ROLES = {
"admin": ("admin@example.com", "admin-pass"),
"member": ("member@example.com", "member-pass"),
}
@pytest.fixture(scope="session")
def storage_state_for(tmp_path_factory, playwright):
cache = {}
def _get(role: str):
if role in cache:
return cache[role]
email, password = ROLES[role]
browser = playwright.chromium.launch()
page = browser.new_page()
page.goto("/login")
page.get_by_label("Email").fill(email)
page.get_by_label("Password").fill(password)
page.get_by_role("button", name="Sign in").click()
page.wait_for_url("**/dashboard")
path = tmp_path_factory.mktemp("auth") / f"{role}.json"
page.context.storage_state(path=str(path))
browser.close()
cache[role] = str(path)
return cache[role]
return _get
def test_admin_sees_settings(browser, storage_state_for):
context = browser.new_context(storage_state=storage_state_for("admin"))
page = context.new_page()
page.goto("/settings")
expect(page.get_by_role("heading", name="Admin Settings")).to_be_visible()
context.close()
def test_member_cannot_see_settings(browser, storage_state_for):
context = browser.new_context(storage_state=storage_state_for("member"))
page = context.new_page()
page.goto("/settings")
expect(page.get_by_text("You don't have access")).to_be_visible()
context.close()
Handling expiring sessions¶
A saved state with a short-lived session cookie will start failing tests hours after it was captured. Regenerate it on a schedule rather than trusting a stale file indefinitely:
import json, time
def is_state_fresh(path: str, max_age_seconds: int = 3600) -> bool:
import os
return os.path.exists(path) and (time.time() - os.path.getmtime(path)) < max_age_seconds
# a session-scoped fixture can check is_state_fresh() first
# and only re-run the UI login when the cached file is
# missing or older than the app's actual token lifetime
How It Actually Works¶
context.storage_state() works by querying two separate CDP surfaces and
combining them into one JSON document: cookies come from
Network.getCookies (or Storage.getCookies, scoped to the whole browser
context rather than one page), while localStorage/sessionStorage per
origin come from DOMStorage.getDOMStorageItems, called once for every
origin the context has ever navigated to. Because both are captured at the
BrowserContext level rather than per-page, a multi-tab login flow's state
is captured completely in one call.
Feeding that file back in via browser.new_context(storage_state=...)
replays it in the opposite direction before any page exists in the new
context: cookies are set via Network.setCookie calls issued against the
freshly created context, and each origin's storage entries are written
through DOMStorage.setDOMStorageItem calls scheduled to run the instant
that origin is first navigated to — Chromium doesn't let you write
localStorage for an origin with no document loaded yet, so Playwright
defers those writes and injects them via an init script that runs before
the page's own JavaScript on first load. This ordering is exactly why the
app "believes a real login already happened" the moment page.goto()
resolves: by the time any of your test's own JavaScript or React code runs,
both the cookie jar and the storage entries a real login would have
produced are already in place.
Exercise¶
- Write a script that logs in through your app's real login form and saves
storage_state.json. - Write a fixture that builds a
BrowserContextfrom that file and confirm a test can load an authenticated page with zero login steps of its own. - Extend the fixture to support two roles (e.g. admin/member) using separate saved state files, and write one test per role asserting role-specific UI.
- Add an
is_state_fresh()-style check (or equivalent) that forces regeneration when the saved file is older than your app's session lifetime, and explain in a comment why blindly trusting an old file would eventually make every test fail for a reason unrelated to the code being tested.