Playwright architecture, selector reliability, and advanced interaction patterns.

Testing Multiple User Roles in Parallel

Most authenticated applications behave differently for an administrator, an editor, and a read-only viewer, so a serious suite has to exercise all three — and it has to do it without tripling the wall-clock time. Playwright already runs spec files in parallel across worker processes, but the moment those workers share one login, one seeded account, or one storageState file, the suite starts failing in ways that never reproduce locally. This page shows how to give every role its own authenticated state, how to keep concurrent workers from mutating each other's data, and how to write a single test that drives two identities at once.

Per-role setup projects feeding parallel test projects Three setup projects each log in as one role, write a storage state file, and gate the matching test project through a dependency. setup projects saved state test projects log in as admin admin.json admin specs log in as editor editor.json editor specs log in as viewer viewer.json viewer specs each test project names its setup project in dependencies
One setup project per role writes an isolated state file, and the dependency edge guarantees it exists before the matching specs fan out.

Root cause: parallel workers share identity, not isolation

Playwright isolates each test in its own BrowserContext, so cookies and local storage never leak between tests — but isolation of the browser is not isolation of the backend. When three workers authenticate as the same seeded account, they share rows in a database, a single set of feature flags, and one notification inbox, and any test that writes data becomes visible to every other test in flight. The second failure mode is authentication cost: logging in through the UI inside each spec multiplies a two-second redirect chain by the number of tests, and under concurrency it can trip login rate limiting, which surfaces as a 429 on the sign-in POST rather than as an obvious auth bug. Both problems are solved at the project layer described in Authentication & Session State, part of Playwright Setup & Core Architecture.

Minimal reproducible example

The spec below is the version most suites start with: every test signs in through the form, and all of them use the same account. It passes with --workers=1 and fails intermittently the moment the suite fans out.

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

// ANTI-PATTERN — reproduced here so the failure is visible.
test.describe('project dashboard', () => {
  // Each test repeats the full login flow: slow, and rate-limited under load.
  async function signIn(page: import('@playwright/test').Page, email: string) {
    await page.goto('/login');
    await page.getByLabel('Email').fill(email);          // same seeded account
    await page.getByLabel('Password').fill('hunter2');   // for every role
    await page.getByRole('button', { name: 'Sign in' }).click();
    await page.waitForURL('/dashboard');
  }

  test('admin archives the Atlas project', async ({ page }) => {
    await signIn(page, 'shared@example.com');
    await page.getByRole('row', { name: 'Atlas' }).getByRole('button', { name: 'Archive' }).click();
    await expect(page.getByRole('status')).toHaveText('Project archived');
  });

  test('viewer sees Atlas in the active list', async ({ page }) => {
    await signIn(page, 'shared@example.com');
    // Runs in a different worker at the same moment as the test above.
    // If the archive lands first, this assertion times out:
    //   Error: expect(locator).toBeVisible() failed
    //   Locator: getByRole('row', { name: 'Atlas' })
    await expect(page.getByRole('row', { name: 'Atlas' })).toBeVisible();
  });
});

The failure is not a selector problem and not a timing problem inside the browser. One test mutated a record that the other test asserted on, and because both ran as the same identity there was no boundary between them. Retrying the spec hides the symptom roughly half the time, which is exactly what makes this class of bug expensive: it survives review, lands on the main branch, and then fails once a week in CI at a different position in the run. The fix is structural. Authentication moves out of the specs entirely, each role gets a session that is created once and read many times, and every role that writes gets an identity no other worker can touch. The three sections below build that in order — first the state files, then the account allocation, then the cross-role pattern that needs both.

Step-by-step fix

  1. Write one setup spec per role. Create admin.setup.ts, editor.setup.ts, and viewer.setup.ts under a tests/auth/ folder. Each imports test as setup, performs the login once, and serializes cookies and origin storage with page.context().storageState({ path }). Reading credentials from the environment keeps the files identical apart from the role name, and the deeper mechanics of this file live in Setting Up an Auth Setup Project.
import { test as setup, expect } from '@playwright/test';

const ADMIN_STATE = 'playwright/.auth/admin.json';

setup('authenticate as admin', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.ADMIN_EMAIL!);
  await page.getByLabel('Password').fill(process.env.ADMIN_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();

  // Wait for a post-login signal, not a URL guess — an unfinished redirect
  // chain serializes a half-written session and every later spec 302s to /login.
  await expect(page.getByRole('navigation')).toContainText('Admin console');

  // Persist cookies + localStorage for every origin the app touched.
  await page.context().storageState({ path: ADMIN_STATE });
});
  1. Declare setup projects as dependencies and pin storageState per project. In playwright.config.ts, give each role a setup project matched by testMatch and a test project whose use.storageState points at that role's file. The dependencies array makes Playwright run the setup project to completion first, and it also propagates failure: if the admin login breaks, the admin specs are skipped rather than reported as 40 unrelated assertion failures.
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  fullyParallel: true,                 // spread specs across workers, not just files
  workers: process.env.CI ? 4 : undefined,
  projects: [
    { name: 'setup:admin', testMatch: /admin\.setup\.ts/ },
    { name: 'setup:viewer', testMatch: /viewer\.setup\.ts/ },
    {
      name: 'admin',
      testMatch: /.*\.admin\.spec\.ts/,
      dependencies: ['setup:admin'],   // gate: state file exists before specs run
      use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/admin.json' },
    },
    {
      name: 'viewer',
      testMatch: /.*\.viewer\.spec\.ts/,
      dependencies: ['setup:viewer'],
      use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/viewer.json' },
    },
  ],
});
Account allocation across parallel workers A matrix mapping each role to a dedicated account per worker index so no two concurrent workers mutate the same records. worker 0 worker 1 worker 2 worker 3 admin admin-w0 admin-w1 admin-w2 admin-w3 editor edit-w0 edit-w1 edit-w2 edit-w3 viewer read-only: one shared account is safe for all workers parallelIndex selects the column, so mutating specs never collide
Roles whose specs write data get one account per worker index; purely read-only roles can keep sharing a single session.
  1. Give each parallel worker its own account for mutating specs. Playwright exposes testInfo.parallelIndex, a small integer stable for the lifetime of a worker slot, and mirrors it into process.env.TEST_PARALLEL_INDEX. Provision one account per role per slot — admin-w0@example.test through admin-w3@example.test — and let a worker-scoped fixture log the right one in exactly once. Worker scope means the cost is paid per process, not per test, following the pattern in Worker-Scoped Fixtures for Expensive Setup.
import { test as base, expect, type Page } from '@playwright/test';
import fs from 'node:fs';

type Fixtures = { adminPage: Page };
type WorkerFixtures = { adminStatePath: string };

export const test = base.extend<Fixtures, WorkerFixtures>({
  // Worker scope: one login per worker process, reused by every test it runs.
  adminStatePath: [async ({ browser }, use, workerInfo) => {
    const slot = workerInfo.parallelIndex;               // 0..workers-1, stable
    const path = `playwright/.auth/admin-${slot}.json`;
    if (!fs.existsSync(path)) {                          // survives worker restarts
      const context = await browser.newContext();        // clean, unauthenticated
      const page = await context.newPage();
      await page.goto('/login');
      await page.getByLabel('Email').fill(`admin-w${slot}@example.test`);
      await page.getByLabel('Password').fill(process.env.ROLE_PASSWORD!);
      await page.getByRole('button', { name: 'Sign in' }).click();
      await expect(page.getByRole('navigation')).toContainText('Admin console');
      await context.storageState({ path });
      await context.close();
    }
    await use(path);
  }, { scope: 'worker' }],

  // Test scope: a fresh context per test, seeded from this worker's own session.
  adminPage: async ({ browser, adminStatePath }, use) => {
    const context = await browser.newContext({ storageState: adminStatePath });
    await use(await context.newPage());
    await context.close();                               // no state leaks forward
  },
});
  1. Expose roles as fixtures so one test can drive two identities. Permission tests are inherently cross-role: an editor submits, an admin approves, a viewer confirms the result is visible. Rather than splitting that into three specs coordinated by a database seed, build two contexts inside one test. Each context is a separate cookie jar, so the two sessions coexist without interfering — the same isolation model covered in Browser Contexts & Isolation.
import { test, expect } from '@playwright/test';

test('editor submits and admin approves in one run', async ({ browser }) => {
  // Two independent cookie jars inside a single test, each with its own session.
  const editorCtx = await browser.newContext({ storageState: 'playwright/.auth/editor.json' });
  const adminCtx = await browser.newContext({ storageState: 'playwright/.auth/admin.json' });
  const editor = await editorCtx.newPage();
  const admin = await adminCtx.newPage();

  await editor.goto('/articles/new');
  await editor.getByLabel('Title').fill('Quarterly report');
  await editor.getByRole('button', { name: 'Submit for review' }).click();
  await expect(editor.getByRole('status')).toHaveText('Submitted for review');

  await admin.goto('/review-queue');
  const row = admin.getByRole('row', { name: 'Quarterly report' });
  await row.getByRole('button', { name: 'Approve' }).click();
  await expect(row).toContainText('Approved');

  // Assert the editor's own view reflects the admin action after a reload.
  await editor.reload();
  await expect(editor.getByRole('status')).toHaveText('Approved');

  await Promise.all([editorCtx.close(), adminCtx.close()]);
});
Two role contexts inside a single test A sequence showing an editor context submitting an article, the application recording it, and an admin context approving it in the same test. editor context application admin context submit for review 201 draft queued admin approves the row reload shows Approved
Both sessions live in the same test, so the handoff is asserted directly instead of being coordinated through database seeding.
  1. Validate the state before the suite fans out. A serialized session is a snapshot with an expiry. Have each setup spec finish by navigating to a protected route and asserting the authenticated shell renders, so an expired refresh token fails one setup project instead of every spec that depends on it. When the login goes through an identity provider, the redirect chain needs the extra handling described in Handling OAuth and SSO Redirects in Tests; capture state only after the final callback settles on your own origin. Short-lived access tokens deserve one extra guard: if the token lives for less than the suite's typical duration, refresh it inside the worker fixture rather than trusting a file written at the start of the run, because the last spec in a long project will otherwise start with a session that expired twenty minutes earlier.

  2. Keep credentials and state files out of the repository. Add playwright/.auth/ to .gitignore and inject passwords through CI secrets. A committed admin.json is a live session token, and a stale one produces the confusing case where the suite passes on a developer machine and 401s in the pipeline. The generation and reuse rules are unpacked in Reusing Login State with storageState.

  3. Size workers and shards so role projects finish together. Setup projects run before their dependents, so a suite with three roles has three short serial phases at the head of the run. Keep them cheap — one login each, no data seeding — and let the role projects consume the remaining workers. Across machines, shard on the whole suite rather than per role, so a slow admin project cannot leave one runner idle; the mechanics are in Running Playwright Tests in GitHub Actions with Sharding.

Choosing an account strategy per role A decision tree that routes mutating specs to per-worker accounts and read-only specs to a single shared role session. does the spec write data? yes no account per worker slot one shared role session worker fixture keyed by index storageState in the project
The write/read distinction decides everything: only mutating roles need the extra accounts and the worker-scoped fixture.

Troubleshooting variants

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

The test project started before its setup project produced the file. Three causes account for nearly all occurrences: the dependencies entry does not exactly match the setup project's name, the setup project's testMatch regex does not match the file on disk, or the run was filtered with --project=admin, which executes the named project and its dependencies but silently skips them if the names diverge. Run npx playwright test --list and confirm the setup spec appears; if it does not, the regex is wrong. Deleting a stale .auth directory between config changes also avoids the opposite trap, where a leftover file makes a broken setup project look healthy.

Specs pass individually but fail with the wrong role's data

Two workers are authenticated as the same account and one of them is writing. Add a temporary console.log(test.info().parallelIndex) alongside the account email used by each worker; if two slots print the same address, step 3 has not been applied to that role. The same symptom appears when the app stores identity in sessionStorage or IndexedDB, neither of which storageState() captures — only cookies and localStorage are serialized. In that case, re-hydrate the missing keys with context.addInitScript() after loading the state file. Persistent cross-worker interference of this kind is one of the recurring patterns in Detecting and Fixing Flaky Playwright Tests.

Error: Playwright Test did not expect test.use() to be called here

test.use({ storageState }) is a file- or describe-level declaration; calling it inside a test() body or after the first test has started throws this error. Switching identity mid-test is not what test.use() does. Either split the scenario into two specs in separate files, each with its own test.use() at the top, or create the second identity as an extra browser.newContext() inside the test as in step 4. A related mistake is putting test.use() inside a test.describe.configure({ mode: 'serial' }) block and expecting the state to reset between retries; retries reuse the declared state, so any per-test mutation must be undone in an afterEach hook.

Verification

Confirm the design holds under real concurrency rather than trusting a single green run. Start with npx playwright test --workers=4 --repeat-each=3, which forces every spec through several worker slots and surfaces account collisions that a single pass hides. Then run one role project in isolation with npx playwright test --project=viewer and check the terminal output lists the setup:viewer project first — that ordering is proof the dependency edge is wired. Third, open a failing run in the Playwright Trace Viewer and inspect the first request's Cookie header in the network tab; the session identifier tells you exactly which account that worker used, which settles any argument about whether the state file or the fixture is at fault. Finally, assert identity inside the tests themselves: a one-line await expect(page.getByRole('banner')).toContainText('admin-w0') at the top of a mutating spec converts a silent wrong-role run into an immediate, readable failure. Pair that with the per-worker resource conventions in Setting Up Global Fixtures for Parallel Tests and the suite stays deterministic as the worker count grows.

Frequently Asked Questions

Do I need a separate account for every role, or just a separate session?

A separate session is enough only when the role never writes. Read-only roles such as a viewer or an auditor can share one account across every worker, because concurrent reads cannot interfere. Any role whose specs create, edit, archive, or delete records needs one account per worker slot; otherwise two workers operating on the same rows will occasionally observe each other's changes, and the resulting failure looks like a locator timeout rather than a data conflict.

Can I switch roles inside a single test instead of creating a second context?

You can, by calling context.clearCookies() and logging in again, but it is slower and it discards the ability to assert on both views at once. Creating a second BrowserContext from the same browser costs a few milliseconds, keeps two cookie jars alive simultaneously, and lets a permission handoff be asserted end to end. Reserve the clear-and-relogin approach for testing the sign-out flow itself.

How many storage state files should a large suite keep?

One per role for read-only roles, plus one per role per worker slot for mutating roles. With three roles, two of which write, and four workers, that is nine files — small, cheap to regenerate, and all disposable. Name them predictably (admin-2.json) so a worker fixture can derive the path from parallelIndex without a lookup table, and regenerate them whenever the login flow or the token lifetime changes.

Does increasing workers make role-based suites faster indefinitely?

No. Each worker slot needs its own accounts and its own share of backend capacity, so throughput flattens once the application under test becomes the bottleneck — usually visible as rising response times and sporadic 429 responses on login. Raise workers gradually, watch the reported duration per role project, and move to sharding across machines once a single machine stops improving.

Back to overview