Fixture Scope Is a Sharing Decision
Every rung on the ladder is implemented as a fixture, and every fixture declares a scope. That declaration looks like a performance knob and is actually a blast-radius setting. This lesson is about reading it correctly — including the case where a fixture is test-scoped and shares state anyway.
~18 min · after A2 · pairs with A3
1. Four scopes, not two
Playwright documents two fixture scopes. In practice a suite has four levels of lifetime, and the two undocumented ones are where the surprises live.
| Scope | Declared as | Lives for | Blast radius of a mutation |
|---|---|---|---|
| Run | globalSetup, or a setup project named in dependencies | The whole run, before any worker | Every test in every project |
| Worker | { scope: 'worker' } | One worker process; reused across its tests | Every subsequent test in that worker |
| Test | default | One test, set up and torn down around it | That test only — if the value it yields is genuinely its own |
| Ambient | nothing — module top-level, module-level let, imported singletons | The worker process, invisibly | Every test in that worker, with no declaration to read |
Scope is not "how long this value lives". Scope is how many tests can see each other's writes to it. Read { scope: 'worker' } as "mutations here leak forward into every later test in this process" and the declaration stops looking like an optimisation.
The connection to A2: rungs 3 and 5 are cheap precisely because they amortise setup across a worker — and their "still shared" row is precisely because of that same amortisation. Cost and leakage are the same property. There is no scope that is cheap and isolated; you are always trading one for the other.
2. Scope contagion
The most common scope bug is not choosing the wrong scope. It is a test-scoped fixture that yields a worker-scoped or run-scoped thing — so the declaration says "fresh per test" and the value is anything but.
The fixture function re-runs for every test, which is what test scope guarantees. But workerIndex is constant within a process, so the lookup resolves to the same database row each time. Re-running the resolution is not the same as producing a fresh resource.
The test to apply. Not "does this fixture function re-run per test?" but "if a test mutates the value this fixture yields, does the next test in the same worker see the mutation?" For seededProduct the answer is yes: it is declared at test scope, behaves at worker scope, and — because slot assignment is derived from the parallel index, which a replacement worker inherits — persists across retries too. This is in your suite. A rename in one spec is visible to the next spec in that worker, and to all three of its retries.
Contagion has a general form worth naming: a fixture's effective scope is the widest scope among the things it hands out references to. Wrapping a worker-scoped client in a test-scoped fixture does not narrow it. Only creating something new narrows it — which is exactly why rung 4 is create-and-destroy rather than find-and-use.
3. Teardown, and the three ways it fails
A fixture is setup → use() → teardown. The teardown half is where isolation is actually delivered, and it fails in three distinct ways.
1 · No finally
The test throws, use() propagates, teardown never runs. It fails at exactly the moment cleanup matters most, and it leaves you with a fixture that looks like it isolates. A guard that only restores on success is worse than no guard.
2 · Teardown that can itself throw
An API call in finally that fails — the record was already deleted, the token expired, the server is down. That throw replaces the test's real failure with a teardown error, so you lose the diagnosis and the cleanup at once. Teardown that must not fail should tolerate the already-cleaned case.
3 · Teardown that runs too late
Worker-scoped teardown runs when the worker exits, not after each test. A worker-scoped resource created for test 1 is still there for tests 2 through 40. And a worker that dies mid-run — which is how Playwright handles some failures — may not run its worker teardown at all, so anything it was going to clean up survives into the retry.
There is a fourth that is not a bug but an ordering fact worth holding: teardown runs in reverse dependency order. If your test-scoped fixture depends on apiClient, the client is still alive during your teardown. If it depends on page, the page is still alive. Reverse that dependency and your teardown reaches for something already disposed — which surfaces as a confusing "target closed" during cleanup rather than as a scope error.
4. Recall check
From memory: the four scopes, what declares each, and — the part that matters — the blast radius of a mutation at each. Then reveal.
Run (globalSetup / setup project) → every test everywhere · Worker (scope:'worker') → every later test in that process · Test (default) → that test, if the yielded value is genuinely its own · Ambient (module-level state) → every test in that process, undeclared.
Then the contagion test, which is the actual takeaway: if a test mutates the yielded value, does the next test in this worker see it? A "yes" from a test-scoped fixture means the declaration is lying.
5. Bug hunt: scope
Click the line where the scope decision goes wrong. One of these five is correct.
Two are lifted from your suite — one as a bug, one as the reference implementation.
6. Choose the scope
For each fixture, pick the scope. The rule: the narrowest scope you can afford, where "afford" is measured in setup cost × frequency, and "narrowest" is measured in blast radius.
Some of these have a correct answer that is not the cheapest one.
7. Your suite's scope map
Four worker-scoped fixtures, three setup projects, everything else test-scoped. Reading it as a set of sharing decisions:
| Fixture | Scope | Read as a sharing decision |
|---|---|---|
authedRequest | worker | One API context per worker, disposed at worker exit. Correct: it is a connection, and connections are exactly what worker scope is for. Nothing mutable is handed out. The scope is right; its teardown is not — see §5. |
apiClient | worker | Correct, for the same reason — and note it correctly inherits the scope of what it wraps rather than pretending to be narrower. |
companyFolderId | worker | An id, cached per worker. Immutable value, so caching it is free. The folder it names is very much shared — but that is a rung-0 fact about the tenant, not a scope bug. |
frameTypes | worker | A read-only lookup of run-scoped seed data. Correct, and it depends on the seeding being run-scoped — which is the interesting part. |
frame-types-seed | run | A scope bug that was already found and fixed. The comment records it: worker-scoped seeding grew the picker's virtualised grid past its render window, so the earliest workers' tiles became unclickable. Demoting to run scope fixed it. |
products-seed | run | Idempotent top-up to a minimum of 15, so a fresh and a persistent database converge without accumulating. Correct pattern for run scope. Its weakness is that it only runs at seed time — nothing restores the pool if a test consumes from it mid-run. |
seededProduct | test* | Contagion. Declared test-scoped; resolves the same slot for every test in the worker, and the same slot again on retry. Documented as a read-only view, which is what makes it safe today — and what makes it a trap, because the safety is a convention no type enforces. |
uploadFolderFixture | test | Genuinely test-scoped: creates a UUID-named folder, yields it, deletes it in finally. Creation is what makes the scope real. The reference implementation in this suite. |
companyNameGuard | test | Test-scoped snapshot-restore over a worker-and-run-shared singleton. The scope is as narrow as it can be; the resource is as wide as it gets. That mismatch is rung 2's whole nature, and no scope declaration can fix it. |
The pattern across the table. Every worker-scoped fixture here hands out something immutable — a connection, an id, a lookup. That is the discipline that makes worker scope safe, and this suite follows it consistently. The two rows worth acting on are the two where a mutable thing is reachable through a narrow-looking declaration: seededProduct and companyNameGuard. Neither is currently wrong. Both are one careless spec away from being wrong, silently.
The heuristic, stated once
Worker scope is for things that are expensive and immutable. Connections, clients, ids, lookups of read-only seed data.
Test scope is for things that are mutable — and it only delivers on that if the fixture creates the thing rather than looking it up.
Run scope is for seeding, and run-scoped seeding must be idempotent, because the database it runs against may be fresh or may be three months old.
Retain this
- Apply the contagion test to your four worker-scoped fixtures. For each: is what it hands out mutable? All four should come back "no". If one ever comes back "yes", you have found a leak before it bites — which is the only cheap time to find one.
- Enforce the convention
seededProductrelies on. It is safe because every consumer treats it as read-only. Make that structural rather than documentary: return a deeply-readonly type, or give destructive specs their own rung-4 fixture. This is a small change that removes a whole class of future bug. - Retrieval, one week out. Four scopes and their blast radii; the three teardown failures; the contagion test in one sentence. Interleave with A2 by asking, for a fixture you are looking at: which rung is this, and does its scope actually deliver that rung?
You will have the map (rungs), the survey (audit) and the mechanism (scope). What is still locked: serialisation primitives for the cases where isolation is genuinely unaffordable, cleanup contracts under failure, convergent assertions over shared collections, and flake budgets. Say which you want and it becomes the next block.
Sources: Playwright · Fixtures · Playwright · Parallelism · RESOURCES.md · STATUS.html · A1 · A2 · A3