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.
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
- Write one setup spec per role. Create
admin.setup.ts,editor.setup.ts, andviewer.setup.tsunder atests/auth/folder. Each importstest as setup, performs the login once, and serializes cookies and origin storage withpage.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 });
});
- Declare setup projects as dependencies and pin
storageStateper project. Inplaywright.config.ts, give each role a setup project matched bytestMatchand a test project whoseuse.storageStatepoints at that role's file. Thedependenciesarray 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' },
},
],
});
- 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 intoprocess.env.TEST_PARALLEL_INDEX. Provision one account per role per slot —admin-w0@example.testthroughadmin-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
},
});
- 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()]);
});
-
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.
-
Keep credentials and state files out of the repository. Add
playwright/.auth/to.gitignoreand inject passwords through CI secrets. A committedadmin.jsonis 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. -
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.
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.