DEV Community

Cover image for Authentication in Playwright: You Might Not Need Project Dependencies
Vitaliy Potapov
Vitaliy Potapov

Posted on

Authentication in Playwright: You Might Not Need Project Dependencies

If your Playwright suite mixes public pages and authenticated areas, or you test multiple roles like user and admin, the standard “setup project” approach for auth can make your runs slower than they need to be.

Here’s a simpler pattern that authenticates on demand, plays nicely with sharding, and keeps your config to one project per browser.

The usual way: a “setup” project

The Playwright docs on authentication recommend authenticating in a dedicated setup project and declaring it as a dependency for your browser projects. The dependency project runs first and prepares storageState that other projects reuse.

That’s convenient and visible in reports, but there’s a cost.

The big drawback: setup runs even when not needed

From the docs:

The setup project will always run and authenticate before all the tests

In practice this means:

  • You have public and authenticated tests. Even if you run only public tests, the setup still authenticates.
  • You have multiple roles. The docs show multiple signed in roles where the setup logs in both admin and user. If you run only admin tests, the user login still happens because the setup runs in full.
  • It gets worse with sharding. Imagine 2 shards:

    • Shard 1 executes user.spec.ts
    • Shard 2 executes admin.spec.ts

    With project dependencies, both shards still execute the full setup up front, repeating both logins unnecessarily.

You can split the config into separate projects per role, each with its own setup. That works, but once you add different browsers, the matrix grows and your config becomes hard to reason about. I prefer one project per browser and nothing else.

A simpler idea

What I really wanted:

  1. The first test that actually needs auth does the sign-in while others wait.
  2. Once done, the same storage state is shared across workers/tests that need it.
  3. Optionally, persist the state for a while on disk so local runs become instant.

That’s exactly what I use today with @global-cache/playwright, which I built after running into these downsides. Here’s how.

Code: multi-role auth on demand (no setup project)

I’ll use the example from the docs with admin and user roles. But instead of putting both logins into a setup project, I create a helper function and wrap authentication steps in globalCache.get():

// tests/helpers/auth.ts
import type { Browser } from '@playwright/test';
import { expect } from '@playwright/test';
import { globalCache } from '@global-cache/playwright';

/**
 * Performs sign-in for a given role and caches the auth state for the whole run.
 */
export async function signIn(browser: Browser, role: 'admin' | 'user') {
  return globalCache.get(`auth-state-${role}`, async () => {
    console.log(`Signing-in as: ${role}`);
    const page = await browser.newPage();

    // Perform authentication steps. Replace these actions with your own.
    await page.goto('https://github.com/login');
    await page.getByLabel('Username or email address').fill(role);
    await page.getByLabel('Password').fill('password');
    await page.getByRole('button', { name: 'Sign in' }).click();

    // Wait until the page reaches a state where all cookies are set.
    await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible();

    // Return authenticated state (cookies + localStorage)
    return page.context().storageState();
  });
}
Enter fullscreen mode Exit fullscreen mode

Now each spec calls this helper inside a storageState fixture to authenticate only the role it needs, and only if a test actually runs:

tests/admin.spec.ts

import { test } from '@playwright/test';
import { signIn } from './helpers/auth';

// Make all tests in this file run as "admin"
test.use({
  storageState: async ({ browser }, use) => {
    const state = await signIn(browser, 'admin');
    await use(state);
  },
});

test('admin: sees dashboard', async ({ page }) => {
  await page.goto('/admin');
  // ...
});
Enter fullscreen mode Exit fullscreen mode

tests/user.spec.ts

import { test } from '@playwright/test';
import { signIn } from './helpers/auth';

// Make all tests in this file run as "user"
test.use({
  storageState: async ({ browser }, use) => {
    const state = await signIn(browser, 'user');
    await use(state);
  },
});

test('user: can view profile', async ({ page }) => {
  await page.goto('/me');
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Running the tests

Below are 4 common runs to check the authentication setup:

1) Run everything (user + admin)

$ npx playwright test

Running 2 tests using 2 workers

  ✓  1 test/admin.spec.ts:11:5 › admin: sees dashboard (2.7s)
  ✓  2 test/user.spec.ts:11:5 › user: can view profile (2.8s)
Signing in as: admin
Signing in as: user

  2 passed (3.5s)
Enter fullscreen mode Exit fullscreen mode

What happens: user and admin authenticate once each, then all tests reuse their storage states.

2) Run only "user" tests

$ npx playwright test tests/user.spec.ts

Running 1 test using 1 worker

  ✓  1 test/user.spec.ts:11:5 › user: can view profile (2.8s)
Signing in as: user
Enter fullscreen mode Exit fullscreen mode

What happens: only the user role authenticates; admin never runs.

3) Run only "admin" tests

$ npx playwright test tests/admin.spec.ts

Running 1 test using 1 worker

  ✓  1 test/admin.spec.ts:11:5 › admin: sees dashboard (2.7s)
Signing in as: admin
Enter fullscreen mode Exit fullscreen mode

What happens: only the admin role authenticates; user never runs.

4) Run on two shards (split by files)

Shard 1:

$ npx playwright test --shard=1/2

Running 1 test using 1 worker, shard 1 of 2

  ✓  1 test/admin.spec.ts:11:5 › admin: sees dashboard (2.7s)
Signing in as: admin

  1 passed (3.7s)
Enter fullscreen mode Exit fullscreen mode

Shard 2:

$ npx playwright test --shard=2/2

Running 1 test using 1 worker, shard 2 of 2

  ✓  1 test/user.spec.ts:11:5 › user: can view profile (2.7s)
Signing in as: user

  1 passed (3.6s)
Enter fullscreen mode Exit fullscreen mode

What happens: the first shard authenticates admin; the second shard authenticates user. Each shard pays only for its role, which is why this setup executes faster than a multi-role dependency project.

In all examples, authentication runs only for roles that your tests actually touch. In practice this cuts setup time and makes your tests run faster.


Bonus: persistent auth for local dev

For local development, you can keep login state on disk for a limited time. For example, to cache for 1 hour, add a { ttl: '1 hour' } parameter to the authentication call:

await globalCache.get(`auth-state-${role}`, { ttl: '1 hour' }, async () => {
  // ...perform login and return storageState()
});
Enter fullscreen mode Exit fullscreen mode

The cache files live under .global-cache by default. You can inspect them and delete them to force a cache update.

How to enable Global Cache

To enable Global Cache, wrap your Playwright config with globalCache.wrap():

// playwright.config.ts
import { defineConfig } from '@playwright/test';
import { globalCache } from '@global-cache/playwright';

const config = defineConfig({
  projects: [
    { name: 'chromium' }, // keep it simple: one project per browser
  ],
  // ...any other options
});

export default globalCache.wrap(config);
Enter fullscreen mode Exit fullscreen mode

Notes

  • If tests modify server-side state and you need isolation per worker, scope the cache key by worker:
  const key = `auth-state-${role}-${testInfo.workerIndex}`;
Enter fullscreen mode Exit fullscreen mode
  • Make cache keys role-aware and environment-aware. Include tenant/locale and base URL if you test multiple envs:
  const key = `auth-${role}-${locale}-${envName}`;
Enter fullscreen mode Exit fullscreen mode

Side-by-side: dependency project vs Global Cache

Aspect Dependency project Global Cache
When auth runs Always before tests Only when a test needs it
Multiple roles All roles log in up front Only selected roles log in
Sharding Each shard pays full setup Each shard computes only its role
Config Projects × roles × browsers One project per browser
Persist across runs Roll your own TTL for local runs

Wrap-up

Project dependencies are solid and officially recommended. But when you mix public pages, multiple roles, or heavy sharding, paying the authentication cost every time adds up. By authenticating on demand and sharing that state, you keep config lean, you only log in when a test truly needs it, and your local loop becomes fast with a short TTL.

If that sounds familiar, give this pattern a try and share your feedback ❤️

Top comments (0)