{"title":"Design Playwright Fixtures for Parallel Test Isolation","excerpt":"Going parallel does not break tests; it reveals the ones that were already sharing state. Here is how to choose fixture scope deliberately, and why parallelIndex and workerIndex are not interchangeable.","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/playwright-fixtures-parallel-test-isolation","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["playwright","fixtures","parallel-testing","test-isolation","test-automation"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-09-03T13:30:00.000Z","dateModified":"2026-09-03T20:05:57.098Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v2/playwright-fixtures-parallel-test-isolation.webp","alt":"Abstract diagram of four parallel worker lanes running side by side, each beginning with its own sealed fixture capsule and ending in a teardown marker, with a shared-state channel crossed out."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> Playwright already isolates browser state per test. What it cannot isolate is state that lives outside the browser — database rows, seeded accounts, files, external systems. Fixtures are where you make that state per-test or per-worker on purpose, and <code>parallelIndex</code> versus <code>workerIndex</code> is the distinction that decides which.</div>\n\n<p>Suites usually go parallel in the same order: someone sets <code>workers: 4</code>, a third of the tests start failing, and parallelism gets blamed. Parallelism did not break those tests. It revealed that they were sharing something, and had been getting away with it because they ran one at a time.</p>\n\n<h2 id=\"what-you-already-get-for-free\">What you already get for free</h2>\n\n<p>Every Playwright test runs in its own <code>BrowserContext</code>. Cookies, localStorage, sessionStorage, IndexedDB, permissions, and in-memory page state are already isolated, and the context is discarded at the end of the test. This is genuinely complete — you do not need a fixture to clear cookies between tests.</p>\n\n<p>It is also worth being precise about the default execution model, because people misremember it. Test <em>files</em> run in parallel across worker processes; tests <em>within</em> one file run in order in the same worker. Turning on <code>fullyParallel</code> changes that so all tests in all files run in parallel:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// playwright.config.ts\nexport default defineConfig({\n  fullyParallel: true,\n  workers: process.env.CI ? 4 : undefined,\n});</code></pre>\n\n<p>If a specific file genuinely needs sequential execution, say so locally rather than turning parallelism off globally:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Overrides a fullyParallel project config for this describe block only\ntest.describe.configure({ mode: 'default' });</code></pre>\n\n<p>Everything that breaks under parallelism lives outside that browser context. That is the whole surface area you need to think about.</p>\n\n<h2 id=\"the-two-worker-indexes\">The two worker indexes, and why the difference matters</h2>\n\n<p>This is the detail that most parallel-isolation advice gets wrong, and getting it wrong produces a bug that only appears after a retry.</p>\n\n<p>A worker exposes two identifiers:</p>\n\n<ul>\n<li><strong><code>parallelIndex</code></strong> — a number between <code>0</code> and <code>workers - 1</code>. Workers running at the same time are guaranteed to have different values. A worker that restarts after a failure <em>reuses</em> its old <code>parallelIndex</code>.</li>\n<li><strong><code>workerIndex</code></strong> — a unique index per worker process. A restarted worker gets a <em>new</em> one, so values are never reused.</li>\n</ul>\n\n<p>That produces a clean rule. Use <code>parallelIndex</code> for a <strong>slot</strong>: a reusable, pooled resource where you want the restarted worker to get the same one back — a seeded account, a database schema, a port. Use <code>workerIndex</code> when the value must <strong>never repeat</strong>, such as a directory you write to and expect to own outright.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Slot semantics: the pool has exactly `workers` accounts, and a restarted\n// worker correctly reclaims the same one.\nconst account = ACCOUNT_POOL[test.info().parallelIndex];\n\n// Uniqueness semantics: a restarted worker must not reuse the old directory.\nconst scratchDir = `/tmp/run-${test.info().workerIndex}`;</code></pre>\n\n<p>Use <code>workerIndex</code> where <code>parallelIndex</code> belongs and your account pool needs to be unbounded. Use <code>parallelIndex</code> where <code>workerIndex</code> belongs and a retried worker will collide with the artifacts of the run that just failed. Both are available as <code>process.env.TEST_PARALLEL_INDEX</code> and <code>process.env.TEST_WORKER_INDEX</code> for tooling that runs outside the test process.</p>\n\n<h2 id=\"choosing-a-fixture-scope\">Choosing a fixture scope</h2>\n\n<p>A test-scoped fixture is set up and torn down around every test. A worker-scoped fixture is set up lazily before the first test in that worker that needs it, and torn down once when the worker shuts down.</p>\n\n<p>The decision is a cost-versus-coupling trade, and it has one question at its centre: <strong>can a test mutate this and affect the next test?</strong></p>\n\n<p>If yes, it must be test-scoped, no matter how expensive it is. If no, worker scope is free performance.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">import { test as base } from '@playwright/test';\n\ntype WorkerFixtures = { seededTenant: Tenant };\ntype TestFixtures = { order: Order };\n\nexport const test = base.extend&lt;TestFixtures, WorkerFixtures&gt;({\n  // Worker-scoped: created once per worker. Safe because no test mutates the\n  // tenant itself - they only create records inside it.\n  seededTenant: [async ({}, use, workerInfo) =&gt; {\n    const tenant = await api.createTenant(`tenant-p${workerInfo.parallelIndex}`);\n    await use(tenant);\n    await api.deleteTenant(tenant.id);\n  }, { scope: 'worker' }],\n\n  // Test-scoped: every test mutates its own order, so it cannot be shared.\n  order: async ({ seededTenant }, use) =&gt; {\n    const order = await api.createOrder(seededTenant.id);\n    await use(order);\n    await api.deleteOrder(order.id);\n  },\n});</code></pre>\n\n<p>Note the tuple syntax with <code>{ scope: 'worker' }</code> — that is how worker scope is declared. Worker fixtures each get their own timeout, equal to the test timeout, which matters when your setup is genuinely slow.</p>\n\n<p>One asymmetry worth remembering: automatic worker fixtures are set up for <code>beforeAll</code> hooks, but automatic test fixtures are not. If you rely on a fixture inside <code>beforeAll</code>, it has to be worker-scoped.</p>\n\n<h2 id=\"unique-data-without-a-random-number\">Unique data without reaching for a random number</h2>\n\n<p>The instinct when tests collide on a unique constraint is <code>Date.now()</code> or a random suffix. It works, and it costs you reproducibility — a failing run cannot be re-run against the same data, and the failure message tells you nothing about which test owned the record.</p>\n\n<p>Derive the identifier from the test instead:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">test('rejects a duplicate email', async ({ page }, testInfo) =&gt; {\n  // Stable across runs, unique across tests, and readable in a failure\n  const email = `user-${testInfo.testId}@example.test`;\n  // ...\n});</code></pre>\n\n<p>Apply the same thinking to files. <code>testInfo.outputPath()</code> returns a path scoped to the current test, which removes a whole class of parallel clobbering:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">const csv = testInfo.outputPath('export.csv');\nawait download.saveAs(csv);</code></pre>\n\n<h2 id=\"authenticate-once-per-worker\">Authenticate once per worker</h2>\n\n<p>Logging in through the UI in every test is usually the single largest avoidable cost in a suite. The documented pattern authenticates once per worker by overriding the <code>storageState</code> fixture, keyed on <code>parallelIndex</code> — slot semantics, correctly chosen, because a restarted worker should reclaim the same account:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">export const test = base.extend&lt;{}, { workerStorageState: string }&gt;({\n  // Every test picks up the worker's stored auth state\n  storageState: ({ workerStorageState }, use) =&gt; use(workerStorageState),\n\n  workerStorageState: [async ({ browser }, use) =&gt; {\n    const id = test.info().parallelIndex;\n    const file = path.resolve(test.info().project.outputDir, `.auth/${id}.json`);\n\n    if (fs.existsSync(file)) {\n      await use(file);       // reuse across tests in this worker\n      return;\n    }\n\n    const page = await browser.newPage({ storageState: undefined });\n    await loginAs(page, ACCOUNT_POOL[id]);\n    await page.context().storageState({ path: file });\n    await page.close();\n    await use(file);\n  }, { scope: 'worker' }],\n});</code></pre>\n\n<p>The pool must have at least as many accounts as you have workers, and — this is the part people miss — those accounts must not share mutable state. Two workers signed in as different users of the same tenant, both reordering the same list, are not isolated just because their cookies differ.</p>\n\n<h2 id=\"what-actually-breaks\">What actually breaks, in rough order of frequency</h2>\n\n<p><strong>Shared database records.</strong> A fixture that seeds \"the test product\" and several tests that mutate it. Move the record into a test-scoped fixture, or make the tenant per-worker.</p>\n\n<p><strong>Order dependence hidden by file grouping.</strong> Tests within a file run in order by default, so a test that depends on its predecessor passes — until <code>fullyParallel</code> is enabled. If a test only passes when its neighbours ran first, it is not a parallelism bug.</p>\n\n<p><strong>Global counters and sequences.</strong> Anything that asserts \"there are now three rows\" is asserting about the whole table. Scope the assertion to data the test owns.</p>\n\n<p><strong>Fixed ports and fixed filenames.</strong> A mock server on <code>:3001</code> in every worker. Derive the port from <code>parallelIndex</code>.</p>\n\n<p><strong>External sandboxes with per-account rate limits.</strong> Four workers against one payment sandbox key produces throttling that looks exactly like flakiness.</p>\n\n<h2 id=\"prove-the-isolation\">Prove it, do not assume it</h2>\n\n<p>Isolation is a property you can test for directly. Two commands find most of it:</p>\n\n<pre class=\"language-bash\"><code class=\"language-bash\"># Does the suite depend on file ordering or on running one at a time?\nnpx playwright test --fully-parallel --workers=4 --repeat-each=3\n\n# Does this specific test depend on its neighbours?\nnpx playwright test tests/orders.spec.ts --workers=1 --grep \"reorders the list\"</code></pre>\n\n<p>A test that passes at <code>--workers=1</code> and fails at <code>--workers=4</code> is sharing something. A test that fails under <code>--repeat-each=3</code> in the same worker is leaking state into itself, which is a teardown bug rather than a parallelism one. The two symptoms point at different code, so it is worth running both before you start reading.</p>\n\n<h2 id=\"where-ai-helps-and-where-it-does-not\">Where AI helps, and where it does not</h2>\n\n<p>A model can spot the mechanical tells quickly — fixed ports, shared module-level mutable objects, <code>beforeAll</code> seeding that later tests mutate, <code>Date.now()</code> used as a uniqueness strategy. That is pattern matching over code, and it is a real speed-up on a large suite.</p>\n\n<p>It cannot tell you whether two tests sharing a tenant is safe. That depends on what your application lets one user do to another's data, which is a domain fact that is not in the test file. A model will happily promote a fixture to worker scope because the suite got faster and still passed, and the resulting failure will appear weeks later as a one-in-thirty flake on CI.</p>\n\n<p>Let it find candidates. Make the scope decision yourself, and write the reason in a comment next to the fixture.</p>\n\n<h2 id=\"apply-this-now\">Apply this now</h2>\n\n<p>Run your suite with <code>--fully-parallel --workers=4 --repeat-each=3</code> and collect the failures. For each one, name the resource being shared. Then decide, per resource, whether it belongs in a test-scoped fixture, a worker-scoped fixture keyed on <code>parallelIndex</code>, or a per-worker directory keyed on <code>workerIndex</code>.</p>\n\n<p>Write the reason down next to each fixture. The scope choice is the part of a suite that future contributors are most likely to change without understanding, and a one-line comment prevents most of that.</p>\n\n<h2 id=\"frequently-asked-questions\">Frequently asked questions</h2>\n\n<h3 id=\"faq-clear-cookies\">Do I need to clear cookies between tests?</h3>\n\n<p>No. Each test gets a fresh <code>BrowserContext</code>. If state is surviving between tests, it is living outside the browser.</p>\n\n<h3 id=\"faq-which-index\">Which index should I use for a seeded account pool?</h3>\n\n<p><code>parallelIndex</code>. It is bounded by the worker count and a restarted worker reclaims the same slot, which is exactly what a pooled account needs. <code>workerIndex</code> would grow unbounded across retries.</p>\n\n<h3 id=\"faq-beforeall\">Can I use a fixture inside <code>beforeAll</code>?</h3>\n\n<p>Only if it is worker-scoped. Automatic worker fixtures are set up for <code>beforeAll</code>; automatic test fixtures are not.</p>\n\n<h3 id=\"faq-workers-one\">Is <code>workers: 1</code> a reasonable fix for flakiness?</h3>\n\n<p>It is a reasonable way to confirm the diagnosis and an expensive way to live with it. It converts a correctness problem into a wall-clock problem and hides the shared state until someone raises the worker count again.</p>\n\n<h2 id=\"primary-references\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://playwright.dev/docs/test-fixtures\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Fixtures</a>: fixture scopes, the worker-scope tuple syntax, lazy setup, and the <code>beforeAll</code> asymmetry</li>\n<li><a href=\"https://playwright.dev/docs/test-parallel\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Parallelism</a>: the default file-level model, <code>fullyParallel</code>, and <code>describe.configure</code></li>\n<li><a href=\"https://playwright.dev/docs/api/class-workerinfo\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — WorkerInfo</a>: <code>parallelIndex</code> versus <code>workerIndex</code> and their restart semantics</li>\n<li><a href=\"https://playwright.dev/docs/auth\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Authentication</a>: the per-worker <code>storageState</code> pattern</li>\n<li><a href=\"https://playwright.dev/docs/test-sharding\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — Sharding</a>: how <code>fullyParallel</code> changes shard granularity</li>\n</ul>\n"}