In Playwright, why can't the storageState option be worker-scoped, and what do you do instead?
answer
- Options resolve per test, not per worker
- Tuple form declares the scope
- Test fixtures may depend on worker ones
- Bridge with a second fixture
- Second generic parameter is worker fixtures
basics
~20 sstorageState is a built-in test option, and options are resolved per test so a describe block can change them, which keeps them test-scoped. Add a separate worker-scoped fixture that produces the file path, then override storageState to return it.
solid answer
~40 s`storageState` is not an ordinary fixture but one of the built-in **test options**, the values a suite sets through `use` in the config or `test.use` in a file. Because `test.use` may change an option for a single describe block, options are resolved per test and stay test-scoped; re-declaring `storageState` with `{ scope: 'worker' }` is rejected. The working shape is an indirection: declare your own worker-scoped fixture — `workerStorageState: [async ({ browser }, use) => { ... }, { scope: 'worker' }]` — that signs in once and yields a path, then override the test-scoped option to hand that value through, `storageState: ({ workerStorageState }, use) => use(workerStorageState)`. The direction is legal because a test-scoped fixture may depend on a worker-scoped one; the reverse never is.
code
typescript · 15 linesimport { test as base } from '@playwright/test';
export const test = base.extend<{}, { workerStorageState: string }>({
workerStorageState: [async ({ browser }, use) => {
const file = `playwright/.auth/worker-${test.info().parallelIndex}.json`;
const page = await browser.newPage({ storageState: undefined });
await page.goto('/login');
// ...sign in this worker's account...
await page.context().storageState({ path: file });
await page.close();
await use(file);
}, { scope: 'worker' }],
storageState: ({ workerStorageState }, use) => use(workerStorageState),
});go deeper
Know that storageState is an option set through use or test.use, not a fixture you write yourself, and that fixtures come in test and worker scope.
Explain why options stay test-scoped, and reproduce the two-fixture bridge: a worker fixture yields the path and the storageState override forwards it.
Justify the indirection by cost — one sign-in per worker instead of per test — and know the silent failure where the override is missing and the config file is used anyway.
Decide where session setup lives across a suite: a per-run capture, a per-worker fixture, or a per-test login, and be able to defend the run-time and account-pool cost of the choice.
## Two scopes, one option Every Playwright fixture is either **test-scoped** — built and torn down around each test — or **worker-scoped**, built once per worker process and reused by every test that worker runs. Scope is declared with the tuple form, `[fn, { scope: 'worker' }]`; the default is test scope. `storageState` looks like a fixture you could re-scope, but it is one of the built-in **test options**: the values a suite supplies through `use` in the config, `use` on a project, or `test.use` in a file or describe block. Since `test.use` is allowed to change an option for a subset of the tests a worker will run, the runner has to resolve options per test. Test scope is therefore not a default someone forgot to change; it is what the option means. ## Why re-declaring it fails Overriding a fixture keeps the original's scope, and registering a name at worker scope that already exists at test scope is refused outright. The rule underneath is the dependency direction: - a **test-scoped** fixture may depend on **worker-scoped** ones — the worker value is already there when the test starts, and it outlives the test; - a **worker-scoped** fixture may **not** depend on test-scoped ones — those are created and destroyed many times inside the worker's lifetime, so there is no single value to capture. If `storageState` were worker-scoped, a `test.use({ storageState })` inside one describe block would have nothing to act on for the other tests running in the same worker. ## The indirection that works Declare a fixture of your own at worker scope that produces the path, and let the test-scoped option read it: ```ts export const test = base.extend<{}, { workerStorageState: string }>({ workerStorageState: [async ({ browser }, use) => { const file = `playwright/.auth/worker-${test.info().parallelIndex}.json`; // sign in once here, write `file` await use(file); }, { scope: 'worker' }], storageState: ({ workerStorageState }, use) => use(workerStorageState), }); ``` Read the generics carefully: in `base.extend<TestFixtures, WorkerFixtures>` the **second** type argument declares worker fixtures. Omitting it is the usual reason TypeScript rejects the tuple form. The override itself is deliberately trivial — it exists only to bridge the two scopes, and it stays a plain function rather than an async one because there is nothing to await. ## What the worker fixture should hand back - **A path, not a parsed object.** The option accepts either, but a path lets a restarted worker detect the file already exists and skip the sign-in. - **An already-verified session.** Assert a role-specific landmark before writing the file; a failed sign-in otherwise produces a valid-looking file full of nothing. - **Its own identity.** Two workers writing the same path race each other; key the file off the worker's own index. - **Nothing that needs per-test teardown.** Worker fixtures are torn down when the worker shuts down, so anything that must be cleaned between tests does not belong here. ## The cost model | Sign-in happens | Where it lives | 200 tests over 4 workers | |---|---|---| | Per test | a beforeEach hook | 200 sign-ins | | Per worker | a worker-scoped fixture | 4 sign-ins | | Once per run | a setup step writing one file | 1 sign-in, one shared identity | The middle row is the reason the indirection is worth the ceremony: it buys per-worker identities at roughly per-run cost. ## Failure modes 1. Declaring the worker fixture and forgetting the `storageState` override — every test quietly keeps using the config-level file, and no error is raised. 2. Writing the sign-in into the fixture but omitting `{ scope: 'worker' }` — it silently becomes a per-test sign-in, which is what you were trying to avoid. 3. Depending on `page` inside the worker fixture — `page` is test-scoped and the runner refuses. Use `browser`, which is worker-scoped.
- Why is the storageState override written as a plain function rather than an async one?It has nothing to await. The fixture body only forwards a value that the worker fixture already resolved, so `({ workerStorageState }, use) => use(workerStorageState)` returns the promise from `use` directly. Making it async works too and simply adds a redundant wrapper.
- Where does a worker-scoped auth fixture get torn down?At worker shutdown, not after each test. Everything after the `await use(...)` line runs once, when the worker finishes its last test or the run ends. That makes worker scope wrong for anything needing per-test cleanup, and right for a session that all of the worker's tests share.
- Can the worker fixture depend on the page fixture to perform the sign-in?No. `page` is test-scoped, and a worker fixture may only depend on other worker fixtures, so the runner rejects it. Depend on `browser` — which is worker-scoped — and open a page from it yourself, closing it before handing the path to `use`.
saying these in an interview costs you the question
- Declaring storageState itself with worker scope
- Letting a worker fixture depend on page
- Forgetting the tuple form, so setup runs per test
- Putting worker fixtures in the first generic parameter
- Expecting worker fixture teardown after every test