Playwright architecture, selector reliability, and advanced interaction patterns.

Setting Up an Auth Setup Project

A suite that logs in inside beforeEach pays the login cost once per test, hammers the identity provider from every worker at once, and turns a single slow authentication response into a failure that looks like it belongs to the test under it. Playwright's answer is a setup project: an ordinary project in playwright.config.ts whose spec files perform the sign-in, write the resulting cookies and local storage to a JSON file, and are declared as a dependency of every other project. The runner executes it first, exactly once, before any dependent project starts — and because it is a real project, its runs are traced, retried, and reported like any other test. This page builds that wiring end to end, from auth.setup.ts through dependencies and storageState, and covers the failure modes you will hit in CI. It sits under Authentication & Session State in Playwright Setup & Core Architecture.

Setup project feeding three browser projects A setup project signs in once and writes a storage state file that three dependent browser projects read at context creation. one sign-in, many dependent projects setup project auth.setup.ts playwright/.auth/ user.json chromium tests firefox tests webkit tests
The setup project is the only place a password is typed; every downstream project starts from the serialized state it wrote.

Root cause: authentication is a shared prerequisite, not a per-test step

Signing in is a dependency of the tests, not part of what they are checking, so repeating it inside each spec inflates runtime linearly with test count and couples every assertion to the availability of the login form. Playwright models shared prerequisites with testProject.dependencies, which guarantees that all tests in the named project finish successfully before any test in the depending project starts. The setup run persists browser state with browserContext.storageState(), and each dependent project reloads that state through use.storageState, so the login happens once per run instead of once per test.

Minimal reproducible example

The setup file is an ordinary spec. The only convention is that it is matched by a distinct testMatch pattern so it never runs as part of the normal suite, and that it aliases test to setup for readability.

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

// Relative to the process working directory, which Playwright pins to the
// config file's directory — the same string is reused in playwright.config.ts.
const STORAGE_STATE = 'playwright/.auth/user.json';

setup('authenticate as the standard user', async ({ page }) => {
  await page.goto('/login');

  // Role queries survive the marketing team restyling the login form.
  await page.getByLabel('Email').fill(process.env.E2E_USER!);
  await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();

  // Wait on a post-login signal, never on a fixed delay. This assertion is
  // the gate: if it fails, no dependent project runs at all.
  await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible();

  // Cookies are only written after the server has actually set them, so the
  // assertion above must prove the session exists before we serialize.
  await page.context().storageState({ path: STORAGE_STATE });
});

Nothing here is Playwright-specific magic — it is a test that happens to end by writing a file. What makes it a setup project is the configuration that runs it first.

Step-by-step fix

  1. Create tests/auth.setup.ts and alias the test function. Put the file outside your normal testDir glob or give it a suffix you can match exactly, then import { test as setup } from '@playwright/test'. The alias is cosmetic but it makes the reporter output read [setup] › auth.setup.ts:5:1 › authenticate, which is much easier to scan when a run fails before any real test executes.

  2. Drive a real login and gate it on a web-first assertion. Fill the form with getByLabel() and submit with getByRole(), then assert on an element that only exists after the session cookie is set. Do not use page.waitForLoadState('networkidle') — it resolves on network quiet, not on authentication, and a chatty analytics beacon will make it either hang or return early. Role-based queries here follow the same reasoning as getByRole & Accessibility Selectors.

  3. Serialize the context with storageState({ path }). Call page.context().storageState({ path: 'playwright/.auth/user.json' }) as the last line. The returned JSON contains a cookies array and an origins array holding localStorage entries per origin. It does not contain sessionStorage, and it does not contain IndexedDB unless you opt in with indexedDB: true, so a token stored in either of those needs a different strategy.

  4. Declare the setup project in playwright.config.ts. Add a project named setup whose testMatch is /.*\.setup\.ts/. Giving it a dedicated match pattern is what stops the setup file from also being collected by your browser projects and running a second time under each of them.

  5. Wire dependencies and use.storageState on every dependent project. Each browser project gets dependencies: ['setup'] and use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE }. The dependency controls ordering; the storageState option controls what every BrowserContext in that project is seeded with. Both are required — one without the other silently produces logged-out tests or a race on a missing file.

  6. Keep the state file out of version control and the credentials out of the repo. Add playwright/.auth/ to .gitignore. The file is a bearer credential: anyone holding it is logged in as that user until the cookie expires. Read the username and password from environment variables and inject them as CI secrets, as described in CI/CD Integration.

  7. Add expiry handling, and a teardown project if the account needs cleanup. Before serializing, read the session cookie's expires value and fail loudly if it is shorter than your longest expected run. If the setup also provisions data, set teardown: 'cleanup' on the setup project and add a matching project that deletes it; Playwright runs the teardown after everything that depended on the setup has finished.

The configuration that steps 4 through 7 describe is a single file:

import { defineConfig, devices } from '@playwright/test';

const STORAGE_STATE = 'playwright/.auth/user.json';

export default defineConfig({
  testDir: './tests',
  projects: [
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,       // only the setup specs land here
      teardown: 'cleanup',              // runs after all dependents finish
    },
    {
      name: 'cleanup',
      testMatch: /.*\.teardown\.ts/,    // deletes fixtures the setup created
    },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
      dependencies: ['setup'],          // ordering guarantee
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'], storageState: STORAGE_STATE },
      dependencies: ['setup'],
    },
  ],
});
Order of operations for a run with a setup dependency Sequence showing the runner starting the setup project, the state file being written, and worker processes loading it before the first test. test runner setup project user.json on disk workers run setup project write storageState setup passed spawn workers seed each context
The dependency edge is what makes step three a hard barrier: a failing setup aborts the dependent projects instead of letting them fail one by one.

Troubleshooting variants

Error: ENOENT: no such file or directory, open 'playwright/.auth/user.json'

The dependent project tried to create a context before the file existed. Three causes account for nearly all of these. First, dependencies: ['setup'] is missing from the project, so nothing ordered the setup ahead of it — the storageState option alone creates no ordering. Second, the setup project's testMatch did not match your file, so the setup project collected zero tests and passed vacuously; run npx playwright test --list --project=setup and confirm the file is listed. Third, the path in the setup file and the path in use.storageState disagree, usually because one of them is relative to a spec directory and the other to the config directory. Keep the value in a single exported constant that both import.

Tests still land on /login even though the setup project passed

The state was written but is not being applied, or it is applied and the server rejected it. Open the failing test in the Playwright Trace Viewer and look at the first request's Cookie header — if it is absent, the context was never seeded, which usually means the test calls browser.newContext() itself and thereby discards the project's use options. If the cookie is present but the app redirects anyway, the session is scoped to an origin your baseURL does not match, or the token lives in sessionStorage, which storageState() deliberately does not capture. A cookie with a Domain of app.example.com will not be sent to example.com, so the setup must navigate to the same origin the tests use. Context-level state behaviour is covered in depth under Browser Contexts & Isolation.

The setup project reruns on every shard, or seems to run twice

Both are expected and both have fixes. Sharding with --shard=1/4 starts four independent runner processes, and each one resolves the dependency graph on its own, so four logins happen — one per machine. That is correct behaviour, and usually cheap enough to ignore; if the identity provider rate-limits you, authenticate once in a preceding CI job and pass the JSON artifact to the shards, a pattern that fits naturally into the matrix described in Running Playwright Tests in GitHub Actions with Sharding. A setup that runs twice within a single process instead means the setup file is matched by two projects at once — typically because your browser projects have no testMatch and default to collecting everything in testDir. Move the setup file into its own directory, or add testIgnore: /.*\.setup\.ts/ to the browser projects.

Three ways to authenticate a suite, compared Matrix comparing per-test login, a globalSetup function, and a setup project by number of logins and trade-off. approach logins per run trade-off login in beforeEach one per test slow, rate-limits the IdP globalSetup function one per run no trace, no retry, no HTML setup project one per run traced, retried, uses fixtures a setup project keeps the login inside the reporting pipeline
A `globalSetup` function still works, but it runs outside the reporter, so a broken login produces a stack trace instead of a failed test with a trace attached.

Verification

Prove the wiring four ways before you trust it. Run npx playwright test --project=chromium and confirm the reporter lists a [setup] line first even though you named only one project — dependencies are pulled in automatically, and their absence from the output is the clearest signal that dependencies is misconfigured. Next, delete playwright/.auth/user.json and run again; a correct configuration recreates it, while a configuration that only reads the file will fail with ENOENT on the first context. Third, add a temporary assertion at the top of any spec — await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible() after a bare page.goto('/') — and check it passes with no login code in the test body. Finally, deliberately break the credentials and confirm the run reports 1 failed in the setup project and N did not run for the dependents, rather than N separate timeouts. That last check is the point of the whole arrangement: one honest failure instead of a wall of misleading ones.

Lifecycle of the stored session between runs State machine moving from no state file to a valid session, then to an expired token and back through a fresh setup run. no state file valid session expired token setup re-runs setup writes file cookie TTL lapses guard sees the redirect state refreshed, dependent tests resume
Treat the JSON file as a cache with a lifetime: the only safe assumption is that it is stale until a run has re-created it.

Once the single-user case is solid, the same machinery generalizes. One setup project can hold several setup() calls that each write a different file — admin.json, viewer.json — which is the foundation for Testing Multiple User Roles in Parallel. If the identity provider bounces through a third-party domain, the setup file is also where you handle those hops, as covered in Handling OAuth and SSO Redirects in Tests. And when a handful of tests must run signed out, override the option per file with test.use({ storageState: { cookies: [], origins: [] } }) rather than building a second project. The deeper mechanics of the file format itself are in Reusing Login State with storageState, and the fixture scoping that decides when the state is read live in Playwright Config & Fixtures.

Frequently Asked Questions

When should I use a setup project instead of globalSetup?

Use a setup project whenever the preparation involves a browser. A globalSetup function is a plain module that runs before the runner starts, so it gets no fixtures, no automatic tracing, no retries, and its failures surface as an unhandled error rather than a reported test. A setup project is a real test file: it can use page, it appears in the HTML report, --trace on records it, and retries applies to it. Reserve globalSetup for work that does not touch a browser at all, such as seeding a database over HTTP or computing a run identifier.

Does the setup project run again for each worker?

No. Dependencies are resolved once per runner process, so a suite with eight workers still performs one login. The exception is sharding: each --shard invocation is its own process with its own dependency graph, so an eight-shard matrix produces eight logins. If that volume is a problem for your identity provider, run the authentication in a separate CI job and publish the JSON file as an artifact the shard jobs download.

Can one setup project write more than one state file?

Yes, and that is the intended pattern for multi-role suites. Put several setup() blocks in the same file, or several files matched by the same testMatch, each signing in as a different account and writing to its own path. Then give each browser project a storageState pointing at the file for the role it exercises. Because every block in the setup project must pass before dependents begin, a broken admin login blocks the admin project without any viewer test having wasted time first.

What happens if the stored session expires mid-run?

Nothing rescues you automatically — the cookies in the JSON file are replayed verbatim, expiry included, and an expired cookie is simply not sent. Tests then redirect to the login page and fail on whatever assertion comes first, which is confusing because the error points at the feature rather than the session. Guard against it by reading the session cookie's expires timestamp in the setup and failing there if it is under, say, an hour, and by keeping the state file out of long-lived caches so a fresh setup runs on every CI execution.

Back to overview