QA How-To
Playwright Worker Fixtures for Multi-User Testing
Playwright worker fixtures multi user testing examples with reusable accounts, isolated browser contexts, cleanup, parallel execution, and reliable verification.
17 min read | 2,460 words
TL;DR
Create a worker-scoped account fixture keyed by workerInfo.workerIndex, then create a fresh test-scoped browser context from that account's storage state. For tests involving two actors, create separate contexts inside a test so each user has independent cookies and local storage while the account pool remains efficient.
Key Takeaways
- Use worker-scoped fixtures for expensive account provisioning that can be safely reused by tests in one worker.
- Use test-scoped browser contexts and pages so cookies, local storage, and session state never leak between tests.
- Derive unique account identities from workerInfo.workerIndex instead of sharing one mutable login across parallel workers.
- Model worker and test fixtures as separate TypeScript types so scope and ownership remain obvious.
- Verify isolation with two users performing concurrent actions and asserting each user's visible state.
- Clean up worker-owned data once per worker and make teardown tolerant of partially completed setup.
Playwright worker fixtures multi user testing examples are most useful when a suite needs several authenticated roles without logging in from scratch before every test. Put expensive account creation or authentication in a worker-scoped fixture, but keep each test's browser context test-scoped so session data cannot leak.
This tutorial builds that pattern with TypeScript and Playwright Test. It is a focused companion to the Playwright 1.5x advanced automation complete guide, which explains the larger fixture, locator, accessibility, and debugging architecture.
You will create unique accounts per parallel worker, save storage state, open isolated authenticated pages, run a two-user scenario, and clean up safely. The sample uses a small local test application contract so you can replace its API routes and selectors with those from your product.
What You Will Build
By the end, you will have:
- A typed
workerAccountfixture created once for each Playwright worker. - A test-scoped
userPagewith a freshBrowserContextfor every test. - A reusable helper that authenticates through an API and writes storage state.
- A two-user test with separate administrator and member contexts.
- Parallel-safe account names based on the worker index.
- Worker teardown that removes generated accounts after the final test in that worker.
The central ownership rule is simple: workers own reusable backend resources, while tests own browser state. That division provides speed without allowing one test's cookies or UI changes to contaminate another test.
Prerequisites
Use Node.js 20 or newer and a current Playwright Test release. Initialize a TypeScript project and install the browser binaries:
npm init playwright@latest
npx playwright install chromium
Choose TypeScript when prompted. The examples expect an application at http://127.0.0.1:3000 with these replaceable contracts:
POST /api/test/userscreates a test user from{ email, password, role }.DELETE /api/test/users/:iddeletes that user.POST /api/loginreturns a successful response and sets an authentication cookie./dashboardrenders[data-testid=account-email]for the signed-in user./messagessupports sending and reading a message.
Test-only API endpoints must be disabled outside controlled environments. If your team seeds users through a database task, identity provider, or CI script, retain the fixture boundaries and replace only the provisioning functions. Set a base URL before running:
export BASE_URL=http://127.0.0.1:3000
Playwright worker fixtures multi user testing examples: scope choices
A fixture's scope controls its lifetime. Choose scope based on the resource, not only on setup speed.
| Resource | Recommended scope | Reason |
|---|---|---|
| Generated account | Worker | Creation is expensive and identity can be reused safely |
| Storage-state file | Worker | It represents the worker's reusable authenticated identity |
| Browser context | Test | Cookies, permissions, and local storage must be isolated |
| Page | Test | Navigation and DOM state belong to one test |
| Temporary message or order | Test | Business data can affect assertions and should be removed per test |
A worker fixture runs once in each worker process, not once for the entire suite. If Playwright starts four workers, it creates four instances. Tests scheduled in worker 2 reuse worker 2's fixture, but never receive a fixture value from worker 1.
Do not make page worker-scoped. A page is mutable, and reusing it introduces order dependence through URL, cookies, dialogs, downloads, and local storage. Reuse the identity material, then create a clean context for every test.
Step 1: Configure parallel execution
Create playwright.config.ts with an explicit base URL, worker count, and trace policy:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
workers: process.env.CI ? 2 : 4,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: process.env.BASE_URL ?? 'http://127.0.0.1:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
...devices['Desktop Chrome'],
},
reporter: [['list'], ['html', { open: 'never' }]],
});
fullyParallel permits tests in the same file to run in separate workers. The worker count is deliberately explicit so account capacity is predictable. Retries may start a replacement worker after a failure, so identity generation must tolerate new worker indexes and repeated cleanup.
Verify the step: run npx playwright test --list. Playwright should load the configuration and list discovered tests without a TypeScript error. If no tests exist yet, add the files from the following steps before repeating the command.
Step 2: Define account and fixture types
Create tests/fixtures/types.ts:
import type { Page } from '@playwright/test';
export type Role = 'member' | 'admin';
export interface TestAccount {
id: string;
email: string;
password: string;
role: Role;
storageStatePath: string;
}
export interface WorkerFixtures {
workerAccount: TestAccount;
}
export interface TestFixtures {
userPage: Page;
}
Separate worker fixture types from test fixture types. This makes the declaration in Step 5 readable and prevents a reviewer from mistaking a worker-owned account for a test-owned page. The account stores the state-file path rather than sharing a live context.
Never put production passwords in source control. The password used later is generated test data for an isolated environment. If password policy requires a secret prefix, read it from an environment variable and fail setup when it is missing.
Verify the step: run npx tsc --noEmit. The command should finish without errors. If your generated project does not include a standalone TypeScript check script, npx playwright test --list also compiles imported test files after they exist.
Step 3: Create API provisioning helpers
Create tests/fixtures/accounts.ts. Playwright's APIRequestContext lets setup call your supported test API without opening a page:
import { expect, type APIRequestContext } from '@playwright/test';
import type { Role } from './types';
interface CreatedUser {
id: string;
email: string;
}
export async function createUser(
request: APIRequestContext,
workerIndex: number,
role: Role,
): Promise<CreatedUser & { password: string; role: Role }> {
const runId = process.env.CI_RUN_ID ?? `local-${process.pid}`;
const email = `pw-${runId}-w${workerIndex}-${role}@example.test`;
const password = `Test-${runId}-${workerIndex}!`;
const response = await request.post('/api/test/users', {
data: { email, password, role },
});
expect(response, `create ${role} user`).toBeOK();
const created = (await response.json()) as CreatedUser;
return { ...created, password, role };
}
export async function deleteUser(
request: APIRequestContext,
id: string,
): Promise<void> {
const response = await request.delete(`/api/test/users/${id}`);
if (!response.ok() && response.status() !== 404) {
throw new Error(`User cleanup failed: ${response.status()}`);
}
}
The run ID and worker index form a unique namespace. In CI, set CI_RUN_ID to a pipeline or job identifier. A process ID is adequate for local disposable data, although a UUID is preferable if several runners can share the same backend.
Cleanup accepts 404 because teardown should be idempotent. It still throws for authorization failures or server errors, which keeps leaked test data visible.
Verify the step: run npx playwright test --list. Imports should compile. You can also call the creation endpoint with a temporary API test, then confirm that the response contains id and email. Do not continue if the endpoint is exposed in production.
Step 4: Authenticate once and save storage state
Add this helper to tests/fixtures/auth.ts:
import { expect, type Browser } from '@playwright/test';
import type { TestAccount } from './types';
export async function saveAuthenticatedState(
browser: Browser,
baseURL: string,
account: Omit<TestAccount, 'storageStatePath'>,
storageStatePath: string,
): Promise<void> {
const context = await browser.newContext({ baseURL });
const response = await context.request.post('/api/login', {
data: { email: account.email, password: account.password },
});
expect(response, `login ${account.email}`).toBeOK();
await context.storageState({ path: storageStatePath });
await context.close();
}
Requests made through context.request share cookie storage with that browser context. When the login response sets a cookie, context.storageState() serializes it. If your token lives in local storage and the login API does not populate it, perform the supported UI login instead, wait for the authenticated URL, and then save state.
Storage-state files contain credentials. Keep them under the test output directory, do not commit them, and let Playwright remove that directory between runs.
Verify the step: after Step 5 creates the fixture, run one test and inspect test-results/.auth/. It should contain a JSON file for the active worker. Do not print its contents in CI logs because cookies or tokens may be sensitive.
Step 5: Implement the worker-scoped fixture
Create tests/fixtures/test.ts:
import { test as base, expect } from '@playwright/test';
import path from 'node:path';
import { mkdir } from 'node:fs/promises';
import { createUser, deleteUser } from './accounts';
import { saveAuthenticatedState } from './auth';
import type { TestFixtures, WorkerFixtures } from './types';
export const test = base.extend<TestFixtures, WorkerFixtures>({
workerAccount: [
async ({ browser, request, baseURL }, use, workerInfo) => {
if (!baseURL) throw new Error('baseURL is required');
const created = await createUser(request, workerInfo.workerIndex, 'member');
const authDir = path.join(workerInfo.project.outputDir, '.auth');
await mkdir(authDir, { recursive: true });
const storageStatePath = path.join(
authDir,
`member-${workerInfo.workerIndex}.json`,
);
const account = { ...created, storageStatePath };
try {
await saveAuthenticatedState(
browser,
baseURL,
account,
storageStatePath,
);
await use(account);
} finally {
await deleteUser(request, created.id);
}
},
{ scope: 'worker' },
],
userPage: async ({ browser, workerAccount, baseURL }, use) => {
const context = await browser.newContext({
baseURL,
storageState: workerAccount.storageStatePath,
});
const page = await context.newPage();
try {
await use(page);
} finally {
await context.close();
}
},
});
export { expect };
The second generic argument to extend declares worker fixtures. The tuple assigns the setup function and { scope: 'worker' }. Playwright waits at await use(account) while tests assigned to that worker execute, then enters finally for cleanup.
The userPage fixture creates a context per test from the saved state. Closing the context in finally handles failed assertions and prevents leaked pages. This is also where you can add test-level permissions, locale, or recording options.
Verify the step: create a temporary test importing test from ./fixtures/test, then run with --workers=2. The output directory should receive one state file per worker that actually ran a test, and generated accounts should be absent after the run.
Step 6: Write parallel authenticated tests
Create tests/account.spec.ts:
import { test, expect } from './fixtures/test';
test('shows the worker account on the dashboard', async ({
userPage,
workerAccount,
}) => {
await userPage.goto('/dashboard');
await expect(userPage.getByTestId('account-email')).toHaveText(
workerAccount.email,
);
});
test('starts each test in an isolated context', async ({ userPage }) => {
await userPage.goto('/preferences');
await userPage.evaluate(() => localStorage.setItem('draft-theme', 'dark'));
await expect(userPage.getByRole('heading', { name: 'Preferences' })).toBeVisible();
});
test('does not inherit local storage from another test', async ({ userPage }) => {
await userPage.goto('/preferences');
const value = await userPage.evaluate(() => localStorage.getItem('draft-theme'));
expect(value).toBeNull();
});
These tests may share an account when they land in the same worker, but they cannot share browser storage because userPage constructs a new context. The last two tests demonstrate the distinction directly. Avoid asserting which worker receives a test because scheduling can change after retries.
If tests mutate server-side preferences for the shared account, browser isolation is not enough. Reset those preferences in a test-scoped fixture, generate a separate account per test, or design assertions around unique records. Worker scope is appropriate only when server-side mutation does not make tests order-dependent.
Verify the step: run npx playwright test tests/account.spec.ts --workers=2 --repeat-each=3. All repetitions should pass. The local-storage assertion should remain null regardless of scheduling.
Step 7: Add a true two-user interaction
A multi-user scenario needs two independent contexts alive in one test. Create the second role inside the test when only a few scenarios need it. Add tests/messaging.spec.ts:
import { test, expect } from './fixtures/test';
import { createUser, deleteUser } from './fixtures/accounts';
import { saveAuthenticatedState } from './fixtures/auth';
import path from 'node:path';
test('admin sends a message that a member receives', async ({
browser, request, userPage: memberPage, workerAccount, baseURL,
}, testInfo) => {
if (!baseURL) throw new Error('baseURL is required');
const admin = await createUser(request, testInfo.workerIndex, 'admin');
const adminState = path.join(testInfo.outputDir, 'admin-state.json');
const adminAccount = { ...admin, storageStatePath: adminState };
let adminContext;
try {
await saveAuthenticatedState(browser, baseURL, adminAccount, adminState);
adminContext = await browser.newContext({
baseURL,
storageState: adminState,
});
const adminPage = await adminContext.newPage();
await adminPage.goto('/messages');
await adminPage.getByLabel('Recipient').fill(workerAccount.email);
await adminPage.getByLabel('Message').fill('Fixture isolation check');
await adminPage.getByRole('button', { name: 'Send' }).click();
await expect(adminPage.getByText('Message sent')).toBeVisible();
await memberPage.goto('/messages');
await expect(memberPage.getByText('Fixture isolation check')).toBeVisible();
} finally {
await adminContext?.close();
await deleteUser(request, admin.id);
}
});
The member uses the efficient worker identity. The admin is test-owned because the scenario mutates role-specific business data. testInfo.outputDir is unique to this test result, so its admin state file cannot collide with another test.
For a suite with many admin-member scenarios, introduce a second worker fixture such as adminAccount. Keep both pages test-scoped. Do not switch identities in one page by clearing cookies because that can miss IndexedDB, service workers, or application caches.
Verify the step: run npx playwright test tests/messaging.spec.ts --workers=2. Confirm the sender sees Message sent, the member sees the exact message, and the temporary administrator is deleted afterward.
Step 8: Make cleanup and diagnostics resilient
Add an automatic test fixture when every test creates business records that need cleanup. For example, extend TestFixtures with recordIds: string[], then register an automatic fixture:
recordIds: [
async ({ request }, use) => {
const ids: string[] = [];
await use(ids);
for (const id of ids.reverse()) {
const response = await request.delete(`/api/test/records/${id}`);
if (!response.ok() && response.status() !== 404) {
console.error(`Could not delete record ${id}: ${response.status()}`);
}
}
},
{ auto: true },
],
Add recordIds to TestFixtures, then push an ID immediately after its record is created. Reverse-order deletion is useful when later records depend on earlier ones. Decide whether cleanup failures should throw or log based on whether residue can invalidate later tests. Account cleanup should usually fail loudly in a controlled shared environment.
Attach safe identifiers, not tokens, when debugging worker allocation:
await testInfo.attach('test-identity', {
body: Buffer.from(JSON.stringify({
workerIndex: testInfo.workerIndex,
email: workerAccount.email,
})),
contentType: 'application/json',
});
Verify the step: force an assertion failure and open npx playwright show-report. The report should contain test-identity, a trace on retry in CI, and no password or storage-state contents. Restore the assertion and confirm teardown still removes created records.
Troubleshooting
Problem: several workers try to create the same email -> Include both a run identifier and workerInfo.workerIndex. If retries or sharding share a backend, also include the shard number or a UUID.
Problem: a test is unexpectedly logged out -> Confirm the saved state contains the application's actual authentication mechanism. For expiring tokens, authenticate near worker setup, reduce run duration, or refresh through a supported API.
Problem: tests pass alone but fail in parallel -> Look for shared server-side data, not only browser state. Namespace records by account and run ID, and avoid tests that edit the same profile or singleton configuration.
Problem: baseURL is undefined in a fixture -> Define it under use in playwright.config.ts and retain the explicit runtime guard. Environment variables alone do not populate Playwright's baseURL option.
Problem: cleanup never runs after an abrupt CI termination -> finally handles normal failures but cannot handle a killed process. Add a scheduled janitor that removes expired test data by a run prefix or creation timestamp.
Problem: TypeScript rejects { scope: 'worker' } -> Place worker fixture types in the second generic argument: base.extend<TestFixtures, WorkerFixtures>(). A worker-scoped fixture cannot depend on a test-scoped fixture such as page.
Best Practices and Common Mistakes
Do create identities from stable worker metadata. Do create a new context for every test. Do keep authentication artifacts in ignored output directories. Do use supported APIs for provisioning, and protect test-only endpoints from production access.
Do not share one account across every worker unless the product guarantees concurrent independent sessions and tests never mutate its backend state. Do not reuse Page or BrowserContext as worker fixtures. Do not depend on worker execution order or assume a particular test always uses worker zero.
Treat authentication state as a credential. Never attach the full state file to an HTML report or upload it as a public artifact. Prefer role-specific factory functions over conditionals scattered across tests. Keep teardown close to setup so ownership is obvious during review.
A useful rule is to begin with test-scoped resources for correctness. Promote only the expensive, demonstrably reusable layer to worker scope. Measure suite duration after the design is reliable, because a fast suite that flakes under parallel load is not an optimization.
Interview Questions and Answers
Q: What is a worker-scoped fixture in Playwright?
It is a fixture initialized once per worker process and torn down after that worker finishes. Tests scheduled to the same worker can reuse its value. It is appropriate for expensive resources such as accounts or service connections that are safe to share.
Q: Why should a browser context remain test-scoped?
A browser context contains mutable cookies, permissions, cache, and local storage. A fresh context gives each test clean browser state and prevents order-dependent failures while still allowing the worker to reuse stored authentication.
Q: How do you prevent account collisions during parallel execution?
Include the run identifier, shard identifier when applicable, role, and worker index in the account key. Enforce uniqueness on the backend and make cleanup idempotent.
Q: Can a worker fixture depend on page?
No. A worker-scoped fixture cannot depend on a test-scoped fixture because the dependency has a shorter lifetime. Depend on worker-compatible fixtures such as browser and create a temporary context during worker setup.
Q: When should each test get its own account?
Use a test-owned account when tests mutate account-level settings, security state, balances, or other shared backend data. Worker reuse is safe only when server-side effects are isolated or reset.
Q: How should two users be represented in one test?
Create a separate context for each user and load the correct storage state into each context. Keep both contexts alive during the interaction and close them in teardown.
Q: What happens to worker fixtures on retries?
A failed test can cause Playwright to discard the worker process and start a new one. Setup and cleanup must therefore tolerate replacement workers, repeated provisioning, and indexes that should not be treated as permanent identities.
Where To Go Next
Use the Playwright 1.5x advanced automation complete guide to connect this fixture design with the rest of a production automation architecture. Then deepen the same design skills with these tutorials:
- Playwright fixture box lazy initialization tutorial for deferring expensive fixture creation until a test actually needs it.
- Playwright indeterminate checkbox testing step by step for reliable assertions on a three-state control.
- Playwright ARIA snapshot matching for dynamic pages for accessible structural assertions that tolerate intentional dynamic content.
Apply the pattern first to one role and a small parallel group. Once account provisioning, state creation, and cleanup are observable and stable, add more roles through typed factories instead of duplicating fixture code.
Conclusion
Reliable multi-user automation separates reusable identity setup from mutable browser sessions. Create accounts and storage state once per worker, but construct and close a fresh context for every test. Use independent contexts for concurrent actors and unique namespaces for all parallel data.
Start with the member fixture in this tutorial, run it repeatedly with multiple workers, and inspect cleanup. After it remains stable under retries, add administrator or reviewer roles using the same ownership rules.
Interview Questions and Answers
What problem do worker-scoped fixtures solve in Playwright?
They amortize expensive setup across tests that run in one worker. A worker fixture is useful for creating an account, starting a service, or preparing authentication state once. The resource must be safe to reuse, and mutable browser contexts should normally remain test-scoped.
How do you declare a typed worker fixture in TypeScript?
Pass test fixture types as the first generic and worker fixture types as the second generic to `base.extend<TestFixtures, WorkerFixtures>()`. Declare the fixture as a tuple containing its setup function and `{ scope: 'worker' }`. This also prevents invalid dependencies on shorter-lived fixtures.
Why not make Playwright's page fixture worker-scoped for speed?
A page carries navigation, DOM, dialog, download, and session state. Reusing it makes tests order-dependent and creates hard-to-debug parallel failures. Reuse account or authentication material, then create a fresh context and page for each test.
How would you design a Playwright test with an admin and a member?
Provision or lease role-specific accounts and create a separate browser context for each identity. Load each account's storage state into its context, perform actions through separate pages, and assert the cross-user result. Close both contexts and delete test-owned data in `finally` blocks or fixtures.
How do you make worker account creation safe in CI shards?
Build the account namespace from a unique pipeline run ID, shard ID, worker index, and role. Ensure the backend applies a uniqueness constraint and make deletion idempotent. A periodic cleanup job should remove expired test accounts after abruptly terminated jobs.
When is a worker-scoped account the wrong choice?
It is wrong when tests change shared account-level state such as passwords, balances, permissions, or profile settings without resetting it. Those tests need a test-scoped account or a reliable per-test reset. Browser-context isolation cannot isolate backend mutations.
How do retries affect worker fixtures?
A failure may cause Playwright to discard the current worker and launch a new process. The new worker runs fixture setup again, and the old worker runs teardown when possible. Provisioning must support repeated setup, while external cleanup must also cover abrupt process termination.
Frequently Asked Questions
What is the difference between a Playwright worker fixture and a test fixture?
A worker fixture is created once for each worker process and reused by tests assigned to that worker. A test fixture is created and torn down for every test, making it appropriate for mutable resources such as pages and browser contexts.
Can Playwright use a different login for every parallel worker?
Yes. Generate or lease an account inside a worker-scoped fixture and key it with `workerInfo.workerIndex` plus a unique run ID. Each worker then receives a distinct identity without requiring a new login before every test.
Should storage state be worker-scoped or test-scoped?
Storage-state material can be produced once per worker when the identity is reusable. Each test should load that state into a new browser context so mutable cookies and local storage remain isolated.
How do I test two users at the same time in Playwright?
Create two independent browser contexts, load the correct authentication state into each one, and open one page per context. Keep both contexts alive during the scenario and close both during teardown.
Why do multi-user tests fail only when run in parallel?
The usual causes are shared account records, colliding generated data, or reused browser state. Namespace backend data by run and worker, keep contexts test-scoped, and avoid concurrent mutations to singleton settings.
Does Playwright always reuse the same worker after a test failure?
No. Playwright can stop a failed worker and start a replacement process for later work or retries. Fixture setup and teardown must tolerate replacement workers and must not treat a worker index as a permanent account.
Is it safe to commit Playwright storage-state files?
No. Storage-state files can contain session cookies and tokens, so treat them as credentials. Write them to an ignored output directory, restrict artifact access, and never print their contents in CI logs.