PRACTICAL GUIDE / Playwright project dependency authentication architecture

Stop sharing broken login state across Playwright projects

Build observable Playwright login setup, choose the right account isolation, diagnose stale browser state, and keep sharded CI runs predictable.

By The Testing AcademyUpdated August 4, 202626 min read
All field guides
In this guide6 sections
  1. See the two handoffs that can fail
  2. Build a setup test that proves the saved state works
  3. Diagnose the graph before touching retries
  4. Match the account model to parallel work
  5. Roll the change through CI without hiding failures
  6. Know when a dependency project is the wrong tool

What you will learn

  • See the two handoffs that can fail
  • Build a setup test that proves the saved state works
  • Diagnose the graph before touching retries
  • Match the account model to parallel work

Your login setup passes on a laptop, yet half the CI projects land back on the sign-in page. Chromium may be green while another job consumes an expired state file, or every shard may be signing in with the same account at once. The dependency graph is only one part of the design. The saved browser state, account allocation, and runner that creates the file have to agree.

See the two handoffs that can fail

A project dependency establishes execution order. If chromium-readonly depends on auth-setup, Playwright runs the tests in auth-setup first. The dependent project starts only after every test in that dependency has passed. When the dependency fails, Playwright does not run the projects that rely on it. That behavior gives authentication a visible failure boundary instead of burying it in a hook that every spec repeats.

The dependency does not pass a logged-in browser, page, or in-memory session to the next project. Setup tests end like other tests, and their browser contexts are isolated. The useful handoff is a file written by browserContext.storageState(). A dependent project's use.storageState option reads that snapshot when it creates a new browser context. Treat the graph and the file as separate components because either can be correct while the other is wrong.

This distinction explains a common false diagnosis. A setup test can reach /account successfully, yet save state too early, save it to the wrong path, or save a session the server has already invalidated. The project graph then behaves exactly as configured. It runs the setup, sees a pass, and starts the consumer. The consumer's new context cannot authenticate because the artifact handed across the boundary is bad. Adding a dependency did not cause the redirect, and adding retries will not repair the artifact.

Start with a small graph. One setup project and one authenticated consumer are easier to prove than a matrix of browsers, devices, roles, and environments. The following configuration deliberately serves a read-only Chromium project. It keeps signed-out coverage separate so an authentication outage does not suppress tests that do not need a session.

TypeScript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

const authFile = 'playwright/.auth/qa-reader.json';

export default defineConfig({
  testDir: './tests',
  reporter: [['list'], ['html', { open: 'never' }]],
  use: {
    baseURL: process.env.BASE_URL ?? 'http://127.0.0.1:3000',
    trace: 'retain-on-failure',
  },
  projects: [
    {
      name: 'auth-setup',
      testMatch: /auth\.setup\.ts/,
    },
    {
      name: 'chromium-readonly',
      testMatch: /authenticated\/.*\.spec\.ts/,
      use: {
        ...devices['Desktop Chrome'],
        storageState: authFile,
      },
      dependencies: ['auth-setup'],
    },
    {
      name: 'chromium-signed-out',
      testMatch: /signed-out\/.*\.spec\.ts/,
      use: {
        ...devices['Desktop Chrome'],
        storageState: { cookies: [], origins: [] },
      },
    },
  ],
});

Project names are contracts. A typo in dependencies: ['auth-setup'] is a configuration problem, not a timing problem. A consumer without dependencies may still pass on a developer machine because yesterday's state file remains on disk. CI starts from a cleaner workspace and exposes the missing edge. For that reason, stale local files are dangerous during migration. They make an incomplete graph look healthy.

Playwright's filtering rules also matter. A location filter, --grep, --project, --shard, or test.only() selects the primary tests. When those selected tests belong to a project with dependencies, the dependency tests run as well. The --no-deps option explicitly ignores dependencies and teardowns. It is useful for a narrow diagnostic, but it bypasses the very guarantee this architecture provides. A normal CI command should not acquire --no-deps merely to make a red job move again.

Saved state is a snapshot, not a lease on a valid server session. Cookies can expire. A login endpoint can revoke older sessions when the same account signs in elsewhere. An application can bind a token to a browser or device. Local storage can point at a different tenant than the account used by the current job. None of these conditions changes the dependency ordering, so the evidence has to include what the new context actually does with the file.

The snapshot format has boundaries too. Playwright can store cookies and local storage, and storageState() can include IndexedDB when indexedDB: true is requested. That option matters for applications that keep authentication tokens in IndexedDB. Session storage is not persisted by the storage-state API. If the application keeps its only login marker in session storage, a perfectly valid JSON file will still produce a signed-out page in the consumer. Describe that as an unsupported handoff for this pattern, not as a flaky redirect.

Project dependencies earn their place because setup is a regular Playwright test. Reporters show it as a separate project. Fixtures are available. Trace recording can cover its actions. That observability is the practical advantage over a hidden login helper. It also costs time: every dependent branch waits for setup, and every independent Playwright invocation may perform the login again. Keep that cost visible when the graph expands.

Build a setup test that proves the saved state works

A click on the sign-in button is not proof of authentication. Neither is a resolved page.goto(), a nonempty cookie jar, or the existence of user.json. Each of those conditions can occur while the application still considers the user anonymous. The setup test needs a product-level assertion at the end of the login flow, followed by a second check that uses the serialized state in a fresh context.

The fresh-context check is the important part. It exercises the same boundary that consumer projects depend on. A regression that moves the token from local storage to session storage could leave the original setup page signed in and break every dependent project. Replaying the file fails during setup, close to the cause, instead of producing dozens of redirects later.

TypeScript
// tests/auth.setup.ts
import { expect, test as setup } from '@playwright/test';
import { mkdir, rm } from 'node:fs/promises';
import path from 'node:path';

const authFile = path.resolve('playwright/.auth/qa-reader.json');

function requiredEnv(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Missing required environment variable: ${name}`);
  }
  return value;
}

setup('authenticate the read-only QA account', async ({ browser, page }) => {
  const email = requiredEnv('TEST_USER_EMAIL');
  const password = requiredEnv('TEST_USER_PASSWORD');
  const baseURL = process.env.BASE_URL ?? 'http://127.0.0.1:3000';

  await mkdir(path.dirname(authFile), { recursive: true });
  await rm(authFile, { force: true });

  await page.goto('/sign-in');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill(password);
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(page).toHaveURL(/\/account(?:\/|$)/);
  await expect(page.getByTestId('signed-in-email')).toHaveText(email);

  await page.context().storageState({
    path: authFile,
    indexedDB: true,
  });

  const replayContext = await browser.newContext({ baseURL, storageState: authFile });
  try {
    const replayPage = await replayContext.newPage();
    await replayPage.goto('/account');
    await expect(replayPage).toHaveURL(/\/account(?:\/|$)/);
    await expect(replayPage.getByTestId('signed-in-email')).toHaveText(email);
  } finally {
    await replayContext.close();
  }
});

Replace the routes and locators with the application contract, but keep the shape of the proof. The final URL helps when a login travels through several redirects and cookies arrive near the end of that chain. The visible identity prevents a false pass in which the browser reaches an account-shaped page for the wrong user. Saving state happens only after both checks succeed. Replaying it checks serialization rather than relying on the original context.

The call uses indexedDB: true because this example supports applications that put a token there. That flag exists on browserContext.storageState() in current Playwright releases, but it should not become ritual. If the application authenticates entirely with cookies, the extra IndexedDB data adds no value. If the installed Playwright version predates the option, remove it or upgrade deliberately. The repository lockfile, not a copied blog snippet, decides which signature the suite can compile.

Do not weaken the identity assertion to expect(page).toHaveURL(/account/) alone. A server can render a generic account shell and then fail its session request. A cached page can preserve a heading while API calls return an authorization error. Choose an element whose value comes from the authenticated backend, such as the current user's email, immutable account id, or expected role. The assertion should fail when the server no longer recognizes the session.

Likewise, do not use “the JSON contains at least one cookie” as the authentication oracle. Consent, load-balancer, and analytics cookies can make that assertion pass. Inspecting cookie names is useful during diagnosis, but it does not prove access. The fresh context and authenticated identity are what connect the file to the product result.

Consumer specs should still assert the identity relevant to the scenario. Setup establishes that the account can be replayed. It cannot guarantee that a test has not navigated to another tenant, switched roles, or logged itself out. A small guard near a destructive action is cheaper than debugging data written under the wrong user.

TypeScript
// tests/authenticated/profile.spec.ts
import { expect, test } from '@playwright/test';

test('shows the authenticated account identity', async ({ page }) => {
  const expectedEmail = process.env.TEST_USER_EMAIL;
  if (!expectedEmail) {
    throw new Error('Missing required environment variable: TEST_USER_EMAIL');
  }

  await page.goto('/account');

  await expect(page).toHaveURL(/\/account(?:\/|$)/);
  await expect(page.getByTestId('signed-in-email')).toHaveText(expectedEmail);
  await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible();
});

The setup test should not log credentials or print the raw storage state. Playwright's authentication guidance warns that state files can contain cookies and headers capable of impersonating the test account. Keep playwright/.auth out of source control. Restrict CI artifact collection so a broad upload-artifact step does not sweep the state file into a downloadable archive. If a failure trace covers login, apply the same access and retention review you use for other authentication evidence.

A file under playwright/.auth can support local reuse, but reuse introduces expiry as a normal condition. If state should never survive a run, an output directory cleaned by the test run is a better home. Either choice needs one owner. A nightly cleanup script, setup test, and teardown project all deleting the same file create races without improving security. In the example, setup removes the known file before logging in and overwrites it only after success.

There is a real latency trade-off in replay validation. Setup launches another context and visits one authenticated page. That extra navigation catches defects at the handoff, but it may be too expensive for a login flow already constrained by an external identity provider. A cheaper alternative is a request to a documented “current user” endpoint from a new context, provided that endpoint uses the same browser credentials and returns an identity you assert. Do not replace the check with a health endpoint that succeeds for anonymous traffic.

Diagnose the graph before touching retries

Start by asking whether setup ran, whether it passed, and whether the consumer loaded the file it produced. Those are three different questions. A retry blurs them by creating another session and another artifact, so run the setup project directly while collecting a trace.

Shell
export BASE_URL='https://staging.example.test'
export TEST_USER_EMAIL='qa-reader@example.test'
export TEST_USER_PASSWORD='replace-with-a-secret-from-your-shell'

npx playwright test --list
npx playwright test --project=auth-setup --trace=on
find test-results -name trace.zip -print
npx playwright show-report

The first command shows which tests Playwright selected for each project. Confirm that auth.setup.ts belongs to auth-setup, not to the ordinary browser project. The focused run removes the consumers from the picture. --trace=on is intentional for diagnosis, so the first attempt has evidence even if the normal configuration retains traces only after a failure. The report should contain the setup test as its own project entry. Trace Viewer then lets you inspect the action sequence, DOM snapshots, console, and network activity around the login.

Do not hard-code a guessed trace path in a team runbook. Output paths include project and test identifiers and can change as configuration changes. Listing matching files first is more reliable. Open the trace that belongs to the failed setup attempt, not a green consumer or a retry from another shard.

When the report has no setup entry, inspect selection before inspecting cookies. The consumer may have lost its dependencies field. Someone may be invoking it with --no-deps. A second config file may define a project with the same name but no edge. In UI mode, the setup project does not run by default, which is documented behavior intended to keep the loop fast. Manually running the setup test there is different from proving the CI graph.

When setup is red and consumers do not run, the dependency is working. The last successful trace action usually narrows the fault better than the consumer failures would have. A failure while filling the password points toward changed login markup or an unexpected identity-provider page. A successful click followed by repeated redirects points toward the login contract, environment, or account. A visible signed-in identity followed by failure in the replay context points directly at the saved-state boundary.

When setup is green but the consumer returns to sign-in, verify the consumer's configured path. Relative storage paths resolve from the process working directory. A monorepo command launched from a package directory can therefore read a different file than a root-level run if paths are assembled inconsistently. path.resolve() in the setup and a single shared constant in config reduce that ambiguity. If packages have independent Playwright configs, give each package its own explicit state path instead of assuming one working directory.

Inspect the state file's shape without printing secret values. This command reports cookie names, cookie domains, and origins represented in local storage. It does not prove authentication, but it answers whether the expected origin was captured and whether a supposedly cookie-based session produced any candidate cookie at all.

Shell
node -e '
const fs = require("node:fs");
const state = JSON.parse(fs.readFileSync("playwright/.auth/qa-reader.json", "utf8"));
console.log({
  cookies: state.cookies.map(({ name, domain, expires }) => ({ name, domain, expires })),
  origins: state.origins.map(({ origin }) => origin),
});
'

An unexpected domain often means login finished on an identity-provider origin without returning to the application. An empty origins array is not automatically a defect because cookie-only authentication may not need local storage. A populated cookie list is not automatically a pass because unrelated cookies exist. Compare the shape with the application's real authentication design, then rely on the fresh-context identity assertion for the decision.

Expiry has a distinct signature. Setup may not run at all during a local UI session, an older file remains, and consumers begin redirecting after working earlier. Running auth-setup manually creates a fresh file and restores the loop. That evidence points to expected state expiry, not a selector flake. CI should normally generate state in the same invocation as its consumers, so a stale file there suggests --no-deps, restored caches, or an artifact copied from another job.

Host mismatch can look identical. A cookie scoped to staging.example.test will not authenticate preview-482.example.test. The file can be fresh, the cookie can be unexpired, and replay on the original host can pass. Inspect the consumer's final baseURL, cookie domain, and trace navigation host. Do not solve this by editing cookie domains in JSON. Generate state against the host whose behavior you are testing, or use an application-supported parent-domain cookie if that is the production design.

Browser-specific authentication is another near miss. Playwright's authentication guide excludes the simple shared-account approach when authentication is browser-specific. If one browser enrolls a device, stores a browser-bound key, or triggers a different identity flow, one Chromium-generated file is not evidence that WebKit can consume it. Give that browser its own setup project and file, or log in through a worker fixture for that browser. The specific evidence is a passing replay in the setup browser and a repeatable rejection only in the other browser.

Shared server data produces a different failure pattern. The account identity remains correct, /account loads, and the trace contains no sign-in redirect. Tests fail because one worker changed preferences, emptied a cart, deleted a record, or advanced an onboarding flag that another worker expected. Regenerating storageState may appear to help because it resets timing, but the conflict returns under parallel load. This is account isolation failure, not authentication serialization failure.

A login system that permits only one active session per user creates yet another pattern. Several shards authenticate concurrently. Later logins invalidate earlier sessions, so setup can pass on every machine while consumers from the first machine begin redirecting. Compare account ids, session creation times from server-side audit data if available, and shard start times. The durable fix is distinct accounts or a session policy designed for parallel automation, not a longer assertion timeout.

Retries belong after classification. A transient identity provider can justify a retry policy, but the first attempt must remain visible in the report and trace. Missing dependencies, wrong paths, unsupported session storage, and shared account collisions are deterministic design defects. Retrying them creates more login traffic and can make session invalidation worse.

Match the account model to parallel work

One shared state file is appropriate only when every test using it can run concurrently without changing account-scoped server data. Read-only catalog checks, permission visibility, and nonmutating searches may fit. A test that updates a profile, changes a feature flag, creates a uniquely named record under the account, or tests logout does not. The browser contexts are isolated, but the backend account is still shared.

Project topology cannot repair the wrong account topology. Adding separate browser projects while they all load qa-reader.json creates more isolated contexts attached to the same server identity. Setting fullyParallel changes scheduling, not ownership. Serializing a whole project avoids some collisions but sacrifices parallelism and still leaves interference with other CI jobs or developer runs.

For tests that mutate server-side state, Playwright documents one account per parallel worker. A worker-scoped fixture can authenticate once, save a state file under that project's output directory, and provide it to tests assigned to that worker. The pool must also be partitioned across CI machines because parallelIndex starts within each Playwright invocation. An offset supplied by the shard job prevents shard one and shard two from taking the same account.

TypeScript
// playwright/fixtures.ts
import { expect, test as base } from '@playwright/test';
import { mkdir } from 'node:fs/promises';
import path from 'node:path';

type Account = { email: string; password: string };
type WorkerFixtures = { workerStorageState: string };

function requiredEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing required environment variable: ${name}`);
  return value;
}

function accountPool(): Account[] {
  const parsed: unknown = JSON.parse(requiredEnv('E2E_ACCOUNTS_JSON'));
  if (!Array.isArray(parsed)) throw new Error('E2E_ACCOUNTS_JSON must be an array');
  return parsed as Account[];
}

export const test = base.extend<{}, WorkerFixtures>({
  storageState: ({ workerStorageState }, use) => use(workerStorageState),

  workerStorageState: [
    async ({ browser }, use, workerInfo) => {
      const offset = Number(requiredEnv('E2E_ACCOUNT_OFFSET'));
      if (!Number.isInteger(offset) || offset < 0) {
        throw new Error('E2E_ACCOUNT_OFFSET must be a non-negative integer');
      }

      const accountIndex = offset + workerInfo.parallelIndex;
      const account = accountPool()[accountIndex];
      if (!account?.email || !account?.password) {
        throw new Error(`No E2E account configured for index ${accountIndex}`);
      }

      const safeProjectName = workerInfo.project.name.replace(
        /[^a-zA-Z0-9._-]+/g,
        '-',
      );
      const authFile = path.resolve(
        workerInfo.project.outputDir,
        `.auth/${safeProjectName}-worker-${workerInfo.parallelIndex}.json`,
      );
      await mkdir(path.dirname(authFile), { recursive: true });

      const baseURL = process.env.BASE_URL ?? 'http://127.0.0.1:3000';
      const context = await browser.newContext({
        baseURL,
        storageState: undefined,
      });
      try {
        const page = await context.newPage();
        await page.goto('/sign-in');
        await page.getByLabel('Email').fill(account.email);
        await page.getByLabel('Password').fill(account.password);
        await page.getByRole('button', { name: 'Sign in' }).click();
        await expect(page.getByTestId('signed-in-email')).toHaveText(account.email);
        await page.context().storageState({ path: authFile, indexedDB: true });
      } finally {
        await context.close();
      }

      await use(authFile);
    },
    { scope: 'worker' },
  ],
});

export { expect };

Specs in this group import test and expect from the fixture module, not directly from @playwright/test. The fixture uses a clean context by setting storageState: undefined, so a project-level shared state cannot leak into account creation. Its state files live under workerInfo.project.outputDir, which Playwright cleans at the start of a run, and the sanitized project name prevents two projects with the same parallel index from choosing one path. The account offset is an explicit CI input rather than an implicit calculation based on shard labels.

The cast after parsing JSON is not validation of every account object, which is why the selected entry is checked before use. A production suite may use a schema validator, but the core failure should remain direct: no configured account for this worker. Avoid falling back to account zero. That fallback makes an undersized pool pass configuration and then creates the exact cross-worker collision the fixture was meant to prevent.

This model has concrete costs. The account inventory must cover the maximum workers across all simultaneous jobs, not only one laptop. Credentials need rotation. Test data cleanup must be tied to the account that created it. Login traffic increases with worker count. If an identity provider throttles automation, an internal test-only token exchange or account broker may be more reliable, but it must be a real supported endpoint with the same authorization semantics needed by the tests.

Multiple roles inside one test require separate contexts and separate files. A manager and requester cannot safely alternate through one page by overwriting user.json. The second login changes the artifact, while an already-created context continues with whatever it loaded earlier. That temporal mismatch makes failures hard to read. Name state by role and construct both contexts explicitly.

TypeScript
// tests/authenticated/approval-flow.spec.ts
import { expect, test } from '@playwright/test';

test('manager and requester retain distinct identities', async ({ browser }) => {
  const baseURL = process.env.BASE_URL ?? 'http://127.0.0.1:3000';
  const requesterContext = await browser.newContext({
    baseURL,
    storageState: 'playwright/.auth/requester.json',
  });
  const managerContext = await browser.newContext({
    baseURL,
    storageState: 'playwright/.auth/manager.json',
  });

  try {
    const requesterPage = await requesterContext.newPage();
    const managerPage = await managerContext.newPage();

    await requesterPage.goto('/account');
    await managerPage.goto('/account');

    await expect(requesterPage.getByTestId('current-role')).toHaveText('Requester');
    await expect(managerPage.getByTestId('current-role')).toHaveText('Manager');
  } finally {
    await requesterContext.close();
    await managerContext.close();
  }
});

The setup dependency for that project must create both files and assert both roles before it passes. If the application prevents simultaneous sessions for the same human account, use two accounts rather than two role selections on one account. A role assertion catches credentials mapped to the wrong tenant or environment, which a generic “signed in” check would miss.

Browser projects deserve the same naming discipline. chromium-requester.json and webkit-requester.json are verbose, but they expose ownership. A single user.json silently overwritten by two setup projects is a race. If the state is demonstrably portable, one file may be shared, but portability should be proven by replay checks in each consumer browser before the matrix is widened.

Roll the change through CI without hiding failures

Move an existing suite in stages. First, inventory every source of authentication: globalSetup, beforeAll, fixture overrides, API login helpers, checked-in JSON, cached workspace directories, and CI steps that download state. Two producers writing one path are worse than either design alone. Record which project consumes each file before changing the graph.

Next, add the setup project without deleting the old path. Run it directly and prove replay in a fresh context. Attach one canary consumer project, ideally a small read-only group with an identity assertion. Disable retries for the canary while wiring is under review, because a second login can hide ordering mistakes. Once the report shows setup followed by the canary and a clean workspace succeeds, move the remaining read-only specs.

Keep signed-out and login tests outside the dependency. They should start with empty state and prove the anonymous contract. If every project depends on authentication, an outage in the identity provider suppresses valuable public-page coverage. The separate chromium-signed-out project in the first configuration continues to run because it has no dependency edge.

Sharding changes the operational cost. Playwright treats --shard as a filter on primary tests, and dependency tests of those selected projects also run. Four shard jobs therefore create four independent setup executions. That is often correct because each machine needs a local state file, but it also means four logins. Give each shard a distinct account when the identity system invalidates concurrent sessions or the tests mutate server data.

The following GitHub Actions job makes shard ownership explicit. Its matrix names the credential pair for each shard, and the expression reads those values from repository or environment secrets. It enables Corepack before invoking pnpm and does not ask setup-node to call a package manager that is not yet on PATH.

YAML
name: Playwright authenticated tests

on:
  pull_request:

jobs:
  authenticated-e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 45
    strategy:
      fail-fast: false
      matrix:
        include:
          - shard: 1/4
            emailSecret: E2E_READER_1_EMAIL
            passwordSecret: E2E_READER_1_PASSWORD
          - shard: 2/4
            emailSecret: E2E_READER_2_EMAIL
            passwordSecret: E2E_READER_2_PASSWORD
          - shard: 3/4
            emailSecret: E2E_READER_3_EMAIL
            passwordSecret: E2E_READER_3_PASSWORD
          - shard: 4/4
            emailSecret: E2E_READER_4_EMAIL
            passwordSecret: E2E_READER_4_PASSWORD

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: corepack enable
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps chromium
      - name: Run authenticated shard
        run: pnpm exec playwright test --project=chromium-readonly --shard=${{ matrix.shard }}
        env:
          BASE_URL: ${{ vars.E2E_BASE_URL }}
          TEST_USER_EMAIL: ${{ secrets[matrix.emailSecret] }}
          TEST_USER_PASSWORD: ${{ secrets[matrix.passwordSecret] }}

This job intentionally runs setup in each shard. It does not upload the state file to another job, so the impersonation credential stays inside the job workspace. Reports and traces can still be uploaded under the team's normal retention policy, but artifact globs should select report directories rather than the entire repository.

Dynamic secret lookup should fail visibly if a matrix entry is misspelled. The requiredEnv function in setup turns an absent secret into a named configuration error before opening the browser. Do not substitute a default email or password for CI. Defaults are reasonable for a public BASE_URL; they are dangerous for credentials because they can point a run at the wrong account without a clear failure.

If the suite uses the per-worker fixture, the CI matrix supplies E2E_ACCOUNTS_JSON and a nonoverlapping E2E_ACCOUNT_OFFSET instead of one pair. Calculate offsets from the configured worker count, and reserve ranges for concurrent workflows. Do not derive uniqueness only from parallelIndex; each shard and each separate CI run can have worker zero.

Avoid transporting a state file between jobs unless there is a measured need and a security review. A bootstrap job plus artifact download appears to eliminate repeated logins, but the state can expire before consumers start, may be scoped to the bootstrap host, and becomes a credential stored by the CI artifact service. It also creates a new ordering system outside Playwright. If the login cost is unacceptable, compare that risk with per-shard API authentication, a short-lived token service, or an account broker supported by the application.

Teardown is available when setup creates resources that must be removed after dependent projects finish. Configure the setup project's teardown field with the name of a teardown project, then put cleanup in a regular test. Remember that --no-deps ignores teardowns as well as dependencies. Local file deletion alone rarely justifies a complex teardown project, but server-side test accounts, leases, or seeded tenants may. Cleanup needs stable resource ids, not a search for “whatever this account created most recently.”

During rollout, keep one dashboard or report query that distinguishes setup failures from consumer failures. A red setup with unrun consumers is one incident. A green setup followed by authentication redirects is another. A green identity check followed by data conflicts belongs to account isolation. Collapsing all three into “E2E auth flaky” guarantees that the team will tune retries instead of repairing ownership.

The last migration step is deleting duplicate login hooks and rejecting stale artifacts. Do that only after a clean checkout or fresh CI workspace proves the dependency path. A local run that inherits yesterday's state is not proof. A canary that passes with --no-deps is evidence that the file exists, not that the architecture creates it.

Know when a dependency project is the wrong tool

Tests that exercise the login experience should not consume preauthenticated state. Put them in a signed-out project with { cookies: [], origins: [] }. They need to observe validation errors, redirects, multifactor prompts, password reset, logout, and session expiry. Making those tests depend on a successful login setup can also prevent them from running precisely when authentication is broken.

Applications that keep their only credential in session storage need another design. The standard state file does not persist session storage. A fixture can create a context and inject known session data with addInitScript when that reflects a supported application contract, or each test can perform the actual login. Copying session storage through an undocumented workaround may test the workaround more than the product. Make the limitation explicit in the project name and runbook.

Short-lived, one-use, device-bound, or browser-bound credentials do not fit one shared artifact. A setup that proves Chromium can replay a state file says nothing about a WebAuthn ceremony bound to a particular authenticator or a server session invalidated after one use. Create the credential at the scope where it remains valid. That may be per browser, per worker, or per test. The extra login latency is the price of testing the actual security boundary.

A shared account is also wrong when tests change server-side state, even if the state file itself is flawless. Use worker accounts, isolated tenants, or product-supported data namespaces. Project dependencies can still prepare common infrastructure, but they should not be presented as account isolation. The evidence that separates these cases is simple: authentication identity stays stable while business data changes underneath another test.

Very small suites may not need a reusable auth artifact at all. If three tests each log in quickly through a supported API and run sequentially, a setup project, file lifecycle, ignore rules, and CI graph can cost more maintenance than they save. Measure the repeated work before creating a framework. Do not invent timing figures. Compare actual job logs and identity-provider limits from the suite you own.

An external environment pipeline may already create a tenant and issue narrowly scoped credentials before Playwright starts. In that case, a Playwright dependency that repeats the provisioning is duplicate ownership. Consume the documented CI input, validate it with an authenticated smoke check, and keep environment cleanup with the system that created the environment. The report should show the validation that Playwright owns, not pretend it owns the upstream deployment.

UI mode deserves a deliberate exception. It does not run the setup project by default, so a developer can manually refresh auth.setup.ts when state expires. For a frequently expiring session, a worker fixture or a dedicated local authentication command may provide a less surprising loop. Do not change CI to --no-deps merely to make it resemble UI mode. They have different optimization goals.

Project dependencies are a poor place for unrelated global chores. A setup project that authenticates, seeds a database, starts feature flags, creates tenants, and warms caches has a large blast radius. One failure blocks every dependent project, and the trace mixes several owners. Split independent prerequisites into separate dependency projects when consumers truly need different subsets. Playwright can run multiple dependencies before a consumer, and a failed dependency then prevents that consumer from starting.

Finally, avoid a dependency when its output has no observable consumer contract. Writing a file “just in case,” logging a success message, or checking that a helper returned cannot establish authentication. A useful setup project has a dependent project that names the file, a fresh-context check that proves replay, and an application assertion that identifies the user or role. Without those, the graph adds ceremony while leaving the original failure unexplained.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Why do my Playwright tests not run after the auth setup project fails?

A failed dependency prevents projects that rely on it from running. Fix the setup failure first, or remove the dependency only from projects that genuinely do not require that authenticated state.

Should every Playwright project reuse one logged-in account?

Only read-only tests that can run concurrently without changing shared server data should share one account. Use separate accounts per worker or role when tests edit settings, create records, or invalidate sessions.

How can I tell whether a Playwright storageState file is stale?

Open the setup project's trace and then replay the saved file in a fresh browser context. If setup reached the signed-in page but the fresh context returns to login, the saved state is incomplete, expired, or rejected by the server.

Does a Playwright setup dependency run in every CI shard?

Each shard is a separate Playwright invocation, and filtering by shard still causes dependencies of the selected primary tests to run. Budget for one setup execution per shard unless CI provides another deliberate authentication boundary.

Can I rely on the auth setup project in Playwright UI mode?

UI mode does not run the setup project by default. Run the setup test manually when the state expires, or choose a development workflow that creates fresh state outside that UI loop.