In Playwright, how does a fixture declared { scope: 'worker' } differ from the default test scope?
answer
- Lifetime, not merely how the value is built
- One instance per worker process
- Tuple form carries the options object
- Teardown waits for worker shutdown
- Third argument is worker info
basics
~20 sA worker-scoped Playwright fixture is created once per worker process and reused by every test that worker runs, with its teardown deferred to worker shutdown. The default test scope rebuilds the value for each test.
solid answer
~50 sScope is chosen in the fixture declaration, not in the config. Writing the fixture as a plain function gives the default `scope: 'test'`: a fresh value per test, torn down when that test ends. Writing it as a tuple -- `[fn, { scope: 'worker' }]` -- makes it worker-scoped, so Playwright builds it lazily the first time a test in that worker needs it, hands the same instance to every later test in that worker, and runs the teardown only when the worker shuts down. Worker fixtures are declared in the second type parameter of `extend`, receive `workerInfo` rather than `testInfo` as the third argument, and may depend on other worker-scoped fixtures only -- asking for a test-scoped fixture such as `page` is rejected. Worker scope buys setup reuse and pays with shared mutable state.
code
typescript · 15 linesimport { test as base } from '@playwright/test';
type WorkerFixtures = { reportRenderer: { port: number } };
export const test = base.extend<{}, WorkerFixtures>({
reportRenderer: [
async ({}, use, workerInfo) => {
const port = 4300 + workerInfo.parallelIndex;
const renderer = await startRenderer(port); // boots once per worker
await use({ port });
await renderer.close(); // runs at worker shutdown, not between tests
},
{ scope: 'worker' },
],
});go deeper
Remember the shape: a fixture written as a plain function is rebuilt for every test, and a fixture written as a tuple ending in scope worker is built once per worker and shared.
Be able to explain the lifecycle: lazy setup on first use in the worker, the same instance for every later test there, and teardown deferred until the worker shuts down.
Show that you know what worker scope costs. Name the shared mutable state it creates, the widened blast radius when its setup fails, and the split into a worker-scoped immutable part plus a test-scoped wrapper.
Own the policy. Decide when a measured setup cost justifies giving up per-test isolation, and make worker scope a reviewed decision with a rule that shared values stay read-only.
Playwright's fixture system has exactly two lifetimes, and the fixture's own declaration -- not a config flag -- picks between them. Scope is therefore a property of the fixture, decided where it is written. ## The default: test scope A fixture declared as a plain function is **test-scoped**. Playwright creates a fresh value for every test that names it, and destroys that value when the test finishes. This is why isolation is the default: nothing a test does to a test-scoped value can reach the next test, because the next test gets a different value. The cost is that the setup runs once per test, so an expensive boot is paid for on every case. ## The tuple form that switches scope To change the lifetime you replace the function with a **two-element tuple**: the fixture function first, an options object second. `{ scope: 'worker' }` is the switch. ```typescript export const test = base.extend<{}, { flags: FlagSnapshot }>({ flags: [async ({}, use) => { await use(await loadFlagSnapshot()); }, { scope: 'worker' }], }); ``` Two details are easy to miss: - The **second type parameter** of `extend` is where worker fixtures are declared; the first holds test-scoped ones. Putting a worker fixture in the test slot is a type error, not a silent downgrade. - The **third callback argument** is `workerInfo` for a worker fixture, where a test fixture receives `testInfo`. `workerInfo` carries `workerIndex`, `parallelIndex`, `project` and `config` -- the things that are stable for the whole worker. ## When setup and teardown actually run A worker-scoped fixture is built **lazily, once per worker process**: the first test in that worker that needs it triggers the setup, and every later test in the same worker gets the value that already exists. The teardown -- the code after `await use(...)` -- does **not** run between tests. It runs when the worker itself shuts down, after the last test that worker was given. That deferral is the single most misunderstood part of worker scope: 1. Test A runs, the fixture is built. 2. Tests B, C and D run, all receiving the same value; no setup, no teardown. 3. The worker finishes its last test and exits; only now does the teardown execute, in reverse order of setup. So anything that must be released promptly -- a lock, a licence seat, a row you do not want held for minutes -- is a poor fit for a worker fixture, because "promptly" here means "whenever this worker happens to run out of tests". ## What a worker fixture may depend on Dependencies flow one way. A worker-scoped fixture may depend on other **worker-scoped** fixtures only. Asking for a test-scoped one -- including the built-in `page` -- is rejected by the runner when it builds the fixture graph, with an error saying a worker fixture cannot depend on a test fixture. The rule follows from the lifetimes: the worker value outlives the test value, so it cannot hold a reference to something that is destroyed while it is still alive. The reverse is fine and is the normal pattern: a test-scoped fixture takes the worker-scoped one and derives fresh per-test state from it. ## Test scope versus worker scope | | `scope: 'test'` (default) | `scope: 'worker'` | |---|---|---| | Instances per run | one per test that uses it | one per worker process that uses it | | Setup runs | before each such test | before the first such test in the worker | | Teardown runs | after each such test | at worker shutdown | | Third callback argument | `testInfo` | `workerInfo` | | May depend on | test- and worker-scoped fixtures | worker-scoped fixtures only | | Mutation by one test | invisible to the next test | visible to every later test in the worker | ## Picking a scope in practice On an internal admin console suite, the values that belong at worker scope are the ones that are expensive and effectively read-only afterwards: - A parsed feature-flag or permissions snapshot fetched once from the backend. - A long-running child process such as a report renderer or a stub webhook receiver. - A connection to an external service that is safe to share sequentially. The ones that belong at test scope are anything a test mutates: a working copy of a record, an in-memory cache, a client whose session a test might invalidate. The useful default is **test scope until an expensive setup is measured**, then promote only the immutable part and keep a test-scoped wrapper around anything a test writes to.
- Why can a worker-scoped fixture not depend on the built-in page fixture?Because `page` is test-scoped and is destroyed at the end of each test, while the worker fixture outlives it. Holding a reference to a shorter-lived value would leave the worker fixture pointing at something already closed, so Playwright rejects the dependency when it builds the fixture graph. The supported direction is the reverse: a test-scoped fixture takes the worker-scoped value and derives fresh per-test state from it.
- A worker-scoped fixture throws while setting up. What happens to the tests in that worker?The test that triggered the setup fails with the fixture's error rather than reaching its body, and Playwright treats the worker as unusable, so it stops it and continues the remaining tests in a replacement worker. That makes a flaky worker fixture unusually expensive: one bad setup can affect a whole batch of tests rather than a single case.
- How would you keep the cost saving of worker scope while still giving each test a clean value?Split the fixture in two. Keep the expensive, read-only part at worker scope -- a booted process, a fetched snapshot -- and add a thin test-scoped fixture that depends on it and produces the per-test state, such as a fresh client or a copy of the data. Tests use the test-scoped one, so mutations die with the test while the expensive boot is still paid once per worker.
saying these in an interview costs you the question
- Says worker scope makes a fixture global for the entire run
- Thinks the code after use() runs between tests in a worker fixture
- Assumes a worker fixture can take page or another test-scoped fixture
- Believes scope is set in the config file rather than the fixture declaration
- Reaches for test-level info in a worker fixture instead of the workerInfo argument
- Treats worker scope as free, ignoring the state now shared between tests