intermediate21 min

Authentication State and Browser Projects

Signing in once and reusing that session across every test — and running the exact same suite against multiple real browser engines through Playwright projects.

What you'll learn

  • Explain why signing in inside every single test is slow and how storageState avoids it
  • Configure multiple browser projects and understand what each one actually verifies
  • Build a locator-quality and fixture-composition exercise reflecting real Playwright authentication patterns

Prerequisites

Explanation

Signing in through the UI inside every single test — filling a login form, submitting, waiting for redirect — is slow and repetitive, and it means a bug in the login flow itself breaks every other test in the suite, not just the ones actually testing login. Playwright's recommended pattern: sign in once, in a dedicated setup step, save the resulting cookies and storage via await context.storageState({ path: "auth.json" }), and have every other test start from a browser context that loads that saved state (test.use({ storageState: "auth.json" })) — instantly "already signed in," with zero login-form interaction needed in the tests that don't actually care about the login flow itself.

A project in playwright.config.ts is a named configuration — a browser engine, a viewport, a device emulation, or a specific storageState — and the same test files run once per configured project. This is exactly the mechanism from this course's first lesson's guided local lab (chromium/firefox/webkit projects), extended: a project can also specify storageState to pre-authenticate, or ...devices["iPhone 13"] to run the identical suite against a mobile emulation, all without duplicating a single test file. A common, effective structure uses a dependency: a setup project that runs first and performs the real login, saving storageState, and other projects declared with dependencies: ["setup"] so Playwright's test runner automatically runs the login step before any test that needs it, exactly once per full run, not once per test.

The genuinely important limitation worth stating honestly: storageState only captures cookies and localStorage/sessionStorage — it does not capture server-side session state that might expire independently, nor does re-using a saved auth state prove the login flow itself still works (that's exactly why a small, separate, real test of the login flow itself remains worthwhile even once most other tests bypass it via storageState). Treating storageState as a total replacement for ever testing login again is a common, easy mistake — it's an optimization for tests that aren't about login, not a reason to stop testing login at all.

Example

Modeling the sign-in-once, reuse-everywhere pattern and multi-project configuration as data.

function buildProjectConfig(name, deviceOverrides, useStorageState) {
  return {
    name,
    use: {
      ...deviceOverrides,
      ...(useStorageState ? { storageState: "auth.json" } : {}),
    },
    ...(useStorageState ? {} : { dependencies: [] }),
  };
}

const setupProject = { name: "setup", testMatch: /.*\.setup\.ts/ };
const chromiumAuthed = { ...buildProjectConfig("chromium", { browserName: "chromium" }, true), dependencies: ["setup"] };
const chromiumAnonymous = buildProjectConfig("chromium-anonymous", { browserName: "chromium" }, false);

console.log(chromiumAuthed.use.storageState);   // "auth.json" -- starts pre-authenticated
console.log(chromiumAnonymous.use.storageState); // undefined -- starts with a clean, signed-out context, deliberately

Try it yourself

Build a webkit project with device overrides for 'Desktop Safari' and confirm its use object merges correctly.

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Guided exercise

Guided exercise

Write needsLoginSetup(testTags) modeling which tests need the storageState-providing setup dependency: return true unless testTags includes 'anonymous' or 'login-flow' (tests that deliberately start signed-out, including the login test itself, must NOT depend on a pre-authenticated setup).

Checks: a normal feature test needs the login setup · the login flow test itself does not depend on pre-authentication · a deliberately anonymous test does not use the setup dependency

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Stuck? Get a hint.

Independent exercise

Independent exercise

Write buildBrowserProjectMatrix(browserNames, includeMobile) returning an array of project name strings: one per browser name in browserNames, plus (if includeMobile is true) one additional 'mobile-chromium' entry. This models composing a real playwright.config.ts projects array from a small set of choices.

Checks: includes a mobile project when requested · excludes the mobile project when not requested

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Stuck? Get a hint.

Guided local lab

Build Reliable Tests with Locators, Fixtures, and Authentication State

Runs on your computer
This lab runs on your own computer, in your own terminal and editor — not in your browser. VisaSparkSchools does not execute, run, or verify these commands for you. Follow the verification steps yourself to confirm your result.

Extend your Playwright project from Module 1 with a real sign-in-once setup, a shared fixture, and role-based locators — the combination that makes a real test suite both fast and reliable.

Required tools

  • Node.js (20.x or 22.x LTS)
  • @playwright/test (1.62.x)
  • A terminal (any)

Setup

  1. Continue from the pw-learning-lab project created in this course's first guided local lab (or recreate it if starting fresh).
  2. Add a tests-setup/ folder for the authentication setup project.
  3. Pick any real, publicly-accessible site with a login form you're comfortable using for practice (or use a local test app if you have one) — this lab's file structure assumes a generic login form.

Project structure

pw-learning-lab/
  playwright.config.ts
  tests-setup/
    auth.setup.ts
  tests/
    dashboard.spec.ts
  playwright/.auth/
    user.json (generated, not committed)

Starter files

tests-setup/auth.setup.ts

import { test as setup, expect } from "@playwright/test";

const authFile = "playwright/.auth/user.json";

setup("authenticate once", async ({ page }) => {
  // TODO: navigate to your chosen site's login page
  // TODO: fill in credentials using environment variables (process.env.TEST_USER / process.env.TEST_PASS),
  //       never hard-coded real credentials
  // TODO: submit and assert something confirming a successful sign-in
  // TODO: await page.context().storageState({ path: authFile });
});

tests/dashboard.spec.ts

import { test, expect } from "@playwright/test";

test("an already-authenticated page shows signed-in content", async ({ page }) => {
  // TODO: navigate directly to a page that requires authentication --
  // this test should start ALREADY signed in, via the project's storageState
  // TODO: assert something that only appears when signed in
});

Requirements

  • auth.setup.ts reads credentials from environment variables, never hard-coded literals.
  • auth.setup.ts saves storageState to playwright/.auth/user.json after a real, successful sign-in.
  • playwright.config.ts defines a 'setup' project and at least one other project with `dependencies: ['setup']` and `use: { storageState: 'playwright/.auth/user.json' }`.
  • dashboard.spec.ts uses only role-based locators (getByRole/getByLabel/getByText), no structural CSS/XPath selectors.
  • playwright/.auth/ is added to .gitignore so the saved session is never committed.

Commands to run

  • Set credentials for this session only (not committed anywhere)

    export TEST_USER=your-test-username TEST_PASS=your-test-password
  • Run the full suite (setup runs automatically first, via dependencies)

    npx playwright test

Expected behavior

Running `npx playwright test` runs the setup project first (a real sign-in, producing playwright/.auth/user.json), then runs dashboard.spec.ts already signed in, with no login-form interaction inside that test at all.

Verify it yourself

  • npx playwright test

    Expected: The setup project passes, then dashboard.spec.ts passes without ever visiting the login page

  • cat playwright/.auth/user.json

    Expected: A real JSON file containing cookies/storage state now exists locally

  • git status

    Expected: playwright/.auth/ does NOT appear as untracked — confirms .gitignore is working

Troubleshooting

  • dashboard.spec.ts still shows signed-out contentConfirm the project running dashboard.spec.ts actually declares `dependencies: ['setup']` and `use: { storageState: ... }` pointing at the same path auth.setup.ts wrote to.
  • `Error: TEST_USER is not defined`Export the environment variables in the same terminal session before running the tests — they are never hard-coded in the committed files.
  • storageState file is empty or missing expected cookiesConfirm auth.setup.ts's assertion after submitting genuinely confirms a signed-in state BEFORE calling storageState() — capturing state before sign-in actually completes saves a signed-out session.

Stuck? Get a hint.

Extension challenge

Add a second, deliberately anonymous project (no storageState) running a dedicated login.spec.ts that tests the sign-in flow itself for real — confirming the pattern from this lesson: storageState is an optimization for tests that aren't about login, not a replacement for testing login at all.

When you've verified this locally, use the "Mark lesson complete" button below to record your progress.

Common mistakes

  • Signing in through the UI inside every single test -- this is slow, repetitive, and means a bug in the login flow breaks tests that have nothing to do with login.
  • Hard-coding real or realistic-looking credentials directly in a committed test file -- credentials belong in environment variables, read via process.env, never committed even for a 'throwaway' test account.
  • Treating storageState as a total replacement for ever testing the login flow again -- it's an optimization for tests NOT about login; a dedicated, real login test should still exist and run.

Knowledge check

Knowledge check

1. What does context.storageState({ path: 'auth.json' }) actually save?
2. What does declaring `dependencies: ['setup']` on a Playwright project accomplish?
3. Why should a real login-flow test still exist even after most other tests adopt storageState?

Takeaway

Sign in once via a setup project, save storageState, and have other projects depend on it and load that state — this makes most tests fast and decoupled from the login flow, but a real, dedicated login test must still exist, since storageState only proves a previously-saved session works, not that signing in still does.

Summary

storageState captures cookies/web storage from a real sign-in, reusable across tests via test.use({ storageState }). A setup project with dependencies wires this in automatically. Playwright projects also configure different browsers/devices, running the same test files against each. storageState is an optimization for non-login tests, not a replacement for testing login itself.

References

Your notes

Notes save automatically.

Finished this lesson?

Mark it complete to track your progress and schedule a future review.