Worker Scope, the Fixture, and Closing Out Concurrency- Scalable Playwright Test Automation
- Anuradha Agarwal
- 1 day ago
- 8 min read
Updated: 13 hours ago
Joining partway through?
This is Post 5 of Phase 3: Enterprise Scalability, part of my Playwright Automation Testing Udemy course.
Post 1 - Scale Test Automation lays out what this series is about.
Post 2 - Getting Your Playwright Framework Running gets the Playwright framework running on your own machine.
Post 3 - Running in parallel for the first time is where a single shared login collides with itself the moment you turn workers up from one to four- the exact problem this post exists to solve
Post 4 - Designing and Building Concurrency built three classes, IdentityProvider, AuthenticationManager, WorkerResolver, each with one job, safe up to the size of your account pool. Nothing in that post actually called them yet.
How the pool actually gets its data
Before any of that, worth tracing where an identity's email and password actually come from, end to end, since it's easy to lose track of once the classes are in place.
It starts in .env, plain values, nothing clever:
TEST_USER_1_EMAIL=your-email@example.com
TEST_USER_1_PASSWORD=your-chosen-password
TEST_USER_2_EMAIL=your-second-email@example.com
TEST_USER_2_PASSWORD=your-second-password
playwright.config.ts loads that file with dotenv.config(), then validates every variable exists before anything else runs:
if (!process.env.TEST_USER_1_EMAIL) throw new Error('TEST_USER_1_EMAIL is not set in .env');
if (!process.env.TEST_USER_1_PASSWORD) throw new Error('TEST_USER_1_PASSWORD is not set in .env');
if (!process.env.TEST_USER_2_EMAIL) throw new Error('TEST_USER_2_EMAIL is not set in .env');
if (!process.env.TEST_USER_2_PASSWORD) throw new Error('TEST_USER_2_PASSWORD is not set in .env');
That's the same fail-fast principle from Post 4's auth.setup.ts, just one level earlier. A missing credential stops the whole run before a browser even opens, not partway through, buried in whichever test happens to need it.
IdentityProvider.load() reads those same process.env values directly, nothing passed in, nothing configured elsewhere:
{ id: 'user1', email: process.env.TEST_USER_1_EMAIL!, password: process.env.TEST_USER_1_PASSWORD! },
One chain, four links: .env holds the values, playwright.config.ts loads and validates them, IdentityProvider reads them into an array, everything downstream, AuthenticationManager, WorkerResolver, the fixture, works with that array and never touches process.env again.
The badge that lasts a shift, and the workspace that doesn't
Back to the shift metaphor from Post 4. The badge (which account) lasts a whole shift. The workspace a test uses does not; each task still gets a fresh one. This is where that distinction stops being a nice mental picture and becomes the thing that decides whether this design works, and where it decides which file gets which piece.
The badge and the workspace don't belong in the same file. Deciding an identity, once per shift, is a different job than building a browser page, fresh every task, and they get separated the same way the classes in Post 4 did.
Create this file: fixtures/parallelAuth.ts, a new file inside your fixtures folder, alongside baseFixtures.ts. This is the badge, and only the badge:
import { test as base } from '@playwright/test';
import { IdentityProvider } from '../auth/IdentityProvider';
import { WorkerResolver } from '../auth/WorkerResolver';
export const test = base.extend<{}, { workerStorageState: string }>({
workerStorageState: [async ({}, use, testInfo) => {
const resolver = new WorkerResolver(new IdentityProvider());
const { storagePath } = resolver.resolve(testInfo.parallelIndex);
await use(storagePath);
}, { scope: 'worker' }],
});
That { scope: 'worker' } tag is the whole trick. It tells Playwright: build this once per worker, not once per test. The resolver is only asked once per shift, just like the badge.
Modify this file: fixtures/baseFixtures.ts, the same file you already have. One line changes at the top, where test gets imported from:
import { test as base } from './parallelAuth'
import { expect, Page, BrowserContext } from '@playwright/test'
That single import line is what connects the two files. baseFixtures.ts no longer builds its test object directly from @playwright/test; it builds on top of parallelAuth.ts's version instead, the one that already knows how to resolve workerStorageState. Everything else in baseFixtures.ts stays exactly as it was, loggedInPage just gains access to workerStorageState as a parameter it can now ask for.
Here's that parameter actually being used, loggedInPage, barely touched, still building a fresh workspace for every single task:
typescript
loggedInPage: async ({ browser, workerStorageState }, use, testInfo) => {
const context: BrowserContext = await browser.newContext({
storageState: workerStorageState,
recordVideo: { dir: testInfo.outputDir }
})
const page: Page = await context.newPage()
await use(page)
await context.close()
// video and teardown logic, unchanged
}storageState: authFile became storageState: workerStorageState. One line. Everything else in this fixture, logging, video, teardown- is identical to what you already had. loggedInPage carries no scope tag, so it stays at Playwright's default: built fresh, every test.
pages/LoginPage.ts is untouched. It never knew about workers before, and it doesn't need to now.
Two fixtures now, sitting right next to each other in the same file, behaving completely differently. workerStorageState built once, reused all shift. loggedInPage rebuilt every single test. Worth naming precisely what that difference actually is.
What scope actually means
Worth being precise here, since the word "scope" gets used loosely, and you've just seen both sides of it. In Playwright, every fixture has exactly one of two lifetimes.
Test scope is the default, and it's what loggedInPage just demonstrated. No tag needed. A test-scoped fixture is built fresh before a test starts, and torn down after that test ends. The next test gets its own brand new copy, no memory of the last one.
Worker scope is the exception, opted into explicitly with { scope: 'worker' }, exactly what workerStorageState did above it. A worker-scoped fixture is built once, the first time some test on that worker asks for it, then reused, unchanged, by every test that worker runs afterward, until the worker itself shuts down.

Test scope is the safe default for a reason worth stating plainly: most things a test touches, the page, form input, whatever a previous test left behind, should not survive into the next test. Sharing that would mean every test after the first quietly inherits the last one's leftovers. That's exactly what would go wrong if loggedInPage were worker-scoped, cart contents from one test bleeding into the next. Test scope exists precisely to prevent that, by default, without anyone having to think about it.
Worker scope is only safe for something that's genuinely true for the whole shift, not just true right now. An identity qualifies. A worker's account doesn't change test to test; that's the entire design goal from Post 4. Reusing the resolved value isn't a shortcut; it's a statement that this value's truth doesn't expire until the shift does.
The reason the two fixtures land on opposite defaults is simple once you say it out loud: a badge describes who you are for the whole shift; that's genuinely stable. A workspace holds whatever mess one task leaves behind: cart contents, a half-filled form, a page mid-navigation. Worker scope is safe when applied to the badge. It would be actively dangerous applied to the workspace.
Getting it backwards, both directions
Worth naming both failure modes directly, since scope mistakes go two ways, not one.
Worker-scoping something that should be test-scoped recreates Post 3's bug, one level down. Instead of workers colliding on one shared account, tests on the same worker would collide on one shared page; one worker's second test would open to whatever state the first test left the cart in. Same category of bug, smaller blast radius, still worth avoiding.
Test-scoping something that should be worker-scoped is the quieter mistake. It wouldn't break anything today, resolve()'s math is deterministic, so a test-scoped workerStorageState would still return the correct answer every time, just recomputed unnecessarily. The cost shows up later: the moment identity resolution becomes anything less trivial, load-based assignment, reserved accounts, a test-scoped version could silently hand the same worker a different identity mid-shift, several tests in, with loggedInPage still assuming identity stays constant. Worker scope isn't protecting against a bug that exists today. It's protecting against one that would only appear the first time the resolver's logic changed.

One more subtlety: workerIndex isn't parallelIndex
Look at the two log lines you just added, Worker Index and Parallel Index, in the same run. If any test in that run retried, or a worker got recycled partway through, worth checking now: does Worker Index show numbers past 1, 3, 4, even though you only ran two workers? That's expected, and it's exactly the subtlety worth understanding before it confuses you later.
testInfo.workerIndex increments across the entire run, every worker process Playwright spins up, including retries and recycled processes, gets the next number. It climbs steadily and never resets.
testInfo.parallelIndex is different, and it's the one WorkerResolver.resolve() actually uses. Check that same output: Parallel Index should stay at 0 or 1 for the whole run, no matter how high Worker Index climbed. Two active workers means parallelIndex is always 0 or 1, bounded to however many workers are running concurrently right now, regardless of what workerIndex says.
That distinction is exactly why resolve() takes testInfo.parallelIndex, not testInfo.workerIndex. Using the wrong one would break the safety constraint from Post 4 silently, the wraparound math assumes a value that stays within the pool size, and only parallelIndex guarantees that.
Seeing it work
npx playwright test --workers=2
Worker 0 resolves to user1, worker 1 resolves to user2, each running the same tests you already had, now authenticated as two separate accounts instead of one shared login. That is the fix for the Post 3 collision, built, not just described.


Growing the pool, the payoff
Go back to Post 4's opening argument, the naive fix, one conditional, one line added per new account, tangled into a fixture that had nothing to do with identity. Here's what adding a third account costs now.
In .env:
TEST_USER_3_EMAIL=your-third-account@example.com
TEST_USER_3_PASSWORD=your-chosen-passwordIn auth/IdentityProvider.ts, one line added to the array:
{ id: 'user3', email: process.env.TEST_USER_3_EMAIL!, password: process.env.TEST_USER_3_PASSWORD! },That's it. AuthenticationManager doesn't change; it already loops over whatever the list contains. WorkerResolver doesn't change; workerIndex % identityPool.length automatically becomes % 3 the moment the list has three entries. workerStorageState, loggedInPage, the tests- none of it touches this at all.
The cost of scaling didn't disappear. It moved from scattered across a fixture that had nothing to do with identity, to one file whose only job is being a list. That's the entire argument across both posts, proven, not just claimed.
The whole thing, end to end

Every piece from both posts, in one picture. Setup and workers stay separate the entire time they're each deciding who they are, that's the wall down the middle. Storage files are the one thing genuinely shared between them, sitting on disk, outside either process's memory. Everything below that point is the worker's own chain, resolve an identity, hold it for the shift, rebuild the workspace every task.
The problem this closes, for good
Worth stating plainly what changed, start to finish across both posts. The framework's own CI config, workers: 1, was a genuine decision, made honestly, because concurrency wasn't safe yet. Nothing in this design argues that decision was wrong. It argues the constraint that justified it is gone.
A framework that could only run one worker without corrupting itself can now run as many workers as it has accounts, correctly, provably, with a clear rule for the one boundary that still matters, workers can't exceed the pool. Growing that pool costs a config line, not a rewrite. That's not a small fix. It's the difference between a framework that merely passes tests and one that's actually ready to scale.
What's next
Concurrency, opened back in Post 3 as a diagnosis, closed here as a working, tested, provably scalable piece of the framework. The next post moves to a different kind of isolation problem, one that shows up even after identity is solved: cart and coupon state leaking between tests running sequentially on the same worker.
This is Post 5 of the Phase 3: Enterprise Scalability series. Post 1 · Post 2 · Post 3 ·· Post 4 · Repo link · Course link




Comments