Why do a Playwright suite's tests pass alone yet fail in sequence once their setup moved to a worker-scoped fixture?
answer
- One instance, many tests, one worker
- Alone means always freshly built
- Nothing resets it between tests
- Look at mutability, not at the failure
- Split along the read-only boundary
basics
~20 sA Playwright worker-scoped fixture is built once and the same instance is given to every test that worker runs, so a mutation by one test survives into the next. Run alone, a test always gets a freshly built value.
solid answer
~50 sWorker scope trades isolation for reuse, and this is the bill. The fixture is created lazily once per worker and never rebuilt, and its teardown is deferred to worker shutdown, so nothing resets it between tests. Any test that mutates the shared value -- invalidating a cached session, editing a snapshot in place, reconfiguring a stub process -- leaves it that way for the tests that follow in the same worker, which is why the failure only appears in sequence. Diagnose it from the declarations: list the fixtures declared `{ scope: 'worker' }`, including `auto` ones no test names, and find which of those values is mutable. The fix is to split the fixture, keeping the expensive read-only part at worker scope and deriving the mutable per-test state in a test-scoped fixture that depends on it.
code
typescript · 18 linesimport { test as base } from '@playwright/test';
type Worker = { adminApi: AdminApi };
type Test = { session: AdminSession };
export const test = base.extend<Test, Worker>({
adminApi: [async ({}, use) => {
const api = await connectAdminApi(); // expensive, read-only
await use(api);
await api.dispose();
}, { scope: 'worker' }],
session: async ({ adminApi }, use) => {
const session = await adminApi.openSession(); // mutable, per test
await use(session);
await session.close();
},
});go deeper
Take away the core fact: a worker-scoped fixture is one object shared by many tests, so whatever one test changes on it is still changed for the next test in that worker.
Explain the mechanism end to end, including why the teardown after use cannot clean up between tests and why running the test alone hides the problem.
Show a method rather than a guess: enumerate the worker-scoped declarations, sort them by mutability, confirm by reordering, then split the fixture instead of pinning a test order.
Own the rule that prevents it, such as requiring worker-scoped values to be read-only once built, and weigh the setup time saved against the cost of diagnosing order-dependent failures.
This is the characteristic failure of worker scope, and the diagnosis is usually short once you know where to look: a worker-scoped fixture hands **the same instance** to every test the worker runs, so anything a test changes on it is still changed for the next test in that worker. Alone, a test is the first and only test in its worker and sees a freshly built value; in a suite, it may be the fifth. ## Why the ordering matters A worker fixture is built lazily, once, when the first test in the worker needs it, and is not rebuilt afterwards. Nothing resets it between tests -- the teardown after `await use(...)` is deferred until the worker shuts down, so it cannot serve as a per-test cleanup either. The result is an order dependency: 1. The worker builds the fixture for its first test. 2. That test mutates the value: it logs the shared client out, appends to a cached list, changes a setting on a running stub process. 3. The next test receives the mutated value and fails on a precondition it never set up. 4. Run that test on its own and it passes, because it is now the test that builds the value. On an internal admin console suite the usual culprits are concrete: a signed-in API client whose session one test deliberately invalidates, a cached permissions snapshot a test edits in place, a stub service whose configuration a test changes to test an error path, a shared temp directory the tests write into. ## Finding it Work from the declarations rather than from the failure: - **List every fixture declared with `{ scope: 'worker' }`.** The shared surface is exactly that set -- worker fixtures plus anything they hold references to. It is normally small. - **Ask which of those values is mutable.** A number, a frozen snapshot or a URL cannot cause this. An object with methods that change its state can. - **Check for `{ auto: true }` too.** An auto worker fixture is shared by tests that never mention it, so it is the easiest one to overlook when reading a failing test file. - **Reorder rather than guess.** Running the suspect test after the one you think dirties the value, and then before it, confirms the direction of the dependency without touching the code. - **Look for the mutation, not the failure.** The failing test is the victim; the fix belongs in whichever test or fixture wrote to the shared value. ## Fixing it: split the fixture The repair is not to abandon worker scope, which is presumably there because the setup is expensive. It is to divide the fixture along the mutability line. | Part | Scope | Example | |---|---|---| | Expensive and read-only | worker | booted stub process, fetched permissions snapshot, opened connection | | Cheap and mutated | test | client built on that connection, a copy of the snapshot, a per-test folder | The test-scoped fixture depends on the worker-scoped one -- that direction is legal -- creates the per-test state from it, and tears that state down at the end of each test. Tests use the test-scoped fixture, so the expensive boot is still paid once per worker while each test gets a clean object. Where splitting is impossible, the reset has to live in a **test-scoped** fixture around each test, not in the worker fixture's teardown, which will not run until the worker exits. An auto test-scoped fixture is a reasonable home for that reset when every test needs it. ## The dial Worker scope is the reuse-against-isolation dial for a suite, and this bug is what the isolation end was buying. - Turned toward reuse, you pay setup once per worker and accept that tests share a surface. - Turned toward isolation, every test rebuilds and nothing leaks, at the cost of the setup time. The defensible middle is to promote only values that are effectively immutable once built, and to treat a mutable worker-scoped fixture as a defect waiting for a long enough run to expose it.
- Why can you not just clean up in the worker fixture's teardown?Because that teardown does not run between tests. The code after `await use(...)` in a worker-scoped fixture is suspended until the worker shuts down, after the last test it was given, so it cannot restore state for the next test. A per-test reset has to live in a test-scoped fixture, which is torn down at the end of every test.
- How do you confirm which test is dirtying the shared value?Reorder rather than guess. Run the failing test immediately after the suspected offender and then on its own; if it fails only in the first arrangement, the dependency is confirmed and the direction is known. Then read the offender for writes to anything reachable from a worker-scoped fixture, including auto fixtures no test names.
- Is there a case where you keep the shared mutable fixture and accept the coupling?Rarely, and only when the resource genuinely cannot be duplicated or reset -- a single external system with one usable connection, for example. Even then the honest treatment is to make the coupling explicit rather than implicit, since a fixture that only works in one test order will fail the moment the suite grows or the ordering changes.
saying these in an interview costs you the question
- Blames flakiness in the application instead of the shared fixture
- Expects the worker fixture teardown to reset state between tests
- Thinks Playwright hands each test its own copy of a worker fixture
- Fixes it by forcing an order the tests must run in
- Reverts to test scope wholesale instead of splitting the fixture
- Overlooks auto worker fixtures because no test names them