Test Isolation and Parallel Execution

Test isolation means that a test gives the same result whether it runs alone, in any order, several times or at the same moment as other tests, because it builds the state it needs and depends on nothing another test leaves behind. Parallel execution means the test runner runs several tests at once: in separate workers on one machine, or in shards spread across several machines. A checkout suite that takes 24 minutes with one worker can finish in roughly a quarter of that time with four, provided no two tests use the same account, cart or file at the same moment.

Isolation is what makes parallelism safe. The lesson on test data, fixtures and hooks showed how one test sets up and cleans up its own data; this lesson looks at the whole suite: what tests share by accident, how runners divide tests between workers and machines, and how to fix the dependencies a parallel run exposes.

Why it matters

Tests that depend on each other produce results nobody can trust. A test passes in the full run only because an earlier test created the order it reads, then fails when someone runs it alone or adds a worker; two tests edit the same user at the same moment and one of them fails one run in twenty. Such failures look like product defects, cost hours of investigation and teach a team to re-run red builds instead of reading them. Martin Fowler names lack of isolation as a common cause of non-deterministic tests.

Speed is the other half. A suite that takes an hour runs less often and reports after the developer has moved on. Parallel execution keeps a growing suite fast enough to run on every change, and it works only when tests are isolated. Isolation also helps the bug report: a test that fails alone, every time, with its own data, is a ready reproduction for the developer.

Isolation and shared-state problems

An isolated test controls its own starting state: it creates what it needs, or receives it from a fixture, and asserts only on data it created or data no test changes. Playwright's best practices ask for every test to run independently, with its own storage, cookies and data; Cypress lists tests that rely on the state of previous tests as an anti-pattern.

Shared state is anything one test can change that another test can see. Read-only data, such as a product catalogue no test edits, is safe to share; shared mutable state is not. The cure is to give each test, or at least each worker, its own copy of what it changes, and to build that state at the start of the test. Martin Fowler prefers rebuilding the starting state to relying on cleanup: when cleanup fails, a different test fails, far from the bug. Shared mutable state hides in five places:

  • Server data: one account, cart, order or coupon used by many tests. One test empties the cart another is checking; a single-use coupon works only for whichever test gets there first.
  • Order dependencies: test B reads the order that test A created. B passes in the usual order and fails when it runs alone, shuffled or on another worker.
  • In-memory state: a module-level variable, a singleton (one object shared by the whole program), a cache, a mocked clock or a "current user" that one test sets and never resets. Worker processes do not share memory, but tests in the same worker do, and so do the parallel threads of JUnit 5.
  • Browser state: cookies, local storage and a signed-in session. A saved sign-in is fine for tests that only read; a test that signs out or edits the profile needs its own session.
  • Machine and environment resources: a fixed file path such as downloads/report.csv, a fixed port, one shared mailbox, a rate-limited sandbox API, a site-wide feature flag.

Try it: Fake Email Generator

How runners execute tests in parallel

In Playwright Test, Jest and pytest-xdist, a worker is a separate operating-system process that runs tests one after another. With four workers, four tests run at the same moment. Their memory is separate, but the test environment, its database, the files on disk and the network ports are not. Runners differ in what they split by default, and in how you give each worker its own resources:

  • Playwright Test runs test files in parallel; the tests inside one file run in order in the same worker unless you set fullyParallel: true or test.describe.configure({ mode: 'parallel' }). Set the number with workers in the config or --workers. Every test gets a new browser context, an isolated browser profile like a fresh private window, with its own cookies and storage, and after a failure Playwright replaces the worker with a fresh process.
  • pytest needs the pytest-xdist plugin: pytest -n 4, or -n auto for one worker per CPU. The worker_id fixture returns gw0, gw1 and so on.
  • Jest spreads test files across worker processes and runs the tests in one file in order; --runInBand runs everything serially in one process.
  • JUnit 5 runs tests sequentially in a single thread by default. Parallel execution is opt-in: the configuration parameter junit.jupiter.execution.parallel.enabled=true switches it on, but tests still run one after another until you also make them concurrent, for example with junit.jupiter.execution.parallel.mode.default=concurrent. The parallel tests are threads in one JVM, so they share static fields and singletons. @ResourceLock declares that tests use a shared resource that needs synchronised access.
  • Per-worker resources: when something cannot be created per test, give each worker its own. Playwright's testInfo.parallelIndex runs from 0 to workers − 1, so worker 2 can take account 2 from a pool prepared before the run.
  • Serial exceptions: a test that changes something global, such as the VAT rate or a site-wide feature flag, cannot run beside other tests. Move it into a small separate run with --workers=1, scheduled so that it never overlaps with the main run in the same environment, instead of making the whole suite serial.

Sharding: splitting a suite across machines

Workers share one machine's CPU and memory, so adding workers stops helping once the machine is busy. Sharding splits the suite across machines: each CI job runs one shard, a fixed part of the tests, and each shard can still use several workers. npx playwright test --shard=2/4 runs the second of four parts; Jest and Vitest accept the same --shard option.

Balance decides the gain, because the run ends when the slowest shard ends. Without fullyParallel, Playwright assigns whole files to shards, so one long file can keep one shard busy while the others wait; with it, individual tests are distributed. Each shard also reports only its own tests: Playwright writes a blob report per shard, and a final job merges them into one report before anyone judges the run.

Shards add an isolation risk of their own. They run on different machines but usually against the same test environment, and worker indexes repeat on every shard. A name like qa+w0@example.com, or account 0 of a pool, is therefore used by four workers at once; add the shard number, or use a random part such as a UUID.

# Four CI jobs start at the same time, one shard each
npx playwright test --shard=1/4 --reporter=blob
npx playwright test --shard=2/4 --reporter=blob
npx playwright test --shard=3/4 --reporter=blob
npx playwright test --shard=4/4 --reporter=blob

# A final job downloads the four blob reports into one folder and merges them
npx playwright merge-reports --reporter html ./all-blob-reports

Try it: UUID Generator

Worked example: three tests that fail only in parallel

A checkout suite of 120 browser tests takes 24 minutes with one worker. The team sets fullyParallel: true and four workers; the run drops to 7 minutes, but three tests now fail in some runs. The whole suite still passes every time with --workers=1, so the likely cause is interference between tests, not the product.

Every test signs in as qa.shared@example.com. "Cart total" adds items of 150.00 and 80.00 and expects 230.00, but sometimes sees 0.00, because "empty cart", running on another worker, clears the same cart. "Order history" expects the list to show three orders, the ones placed by the three ordering tests in its file; it passed only because it used to run after them in the same worker, and it fails even when run alone. "Download invoice" saves to downloads/invoice.pdf, where another worker's invoice can overwrite it.

Two workers run "cart total" and "empty cart" at the same moment: on one shared account the cart is emptied mid-test and 230.00 becomes 0.00; with an account per worker both tests pass.
  • One shared account

    • Worker 1 test "cart total"
    • Test environment one cart for every test
    • Worker 2 test "empty cart"
    1. 1 From Worker 1 To Test environment: Add items of 150.00 and 80.00 qa.shared@example.com
    2. 2 From Worker 2 To Test environment: Empty the cart, at the same moment Same account, so the same cart qa.shared@example.com
    3. 3 Worker 2 internally: Assert: the cart is empty Pass
    4. 4 From Worker 1 To Test environment: Open the cart and read the total
    5. 5 From Test environment To Worker 1: Shows a total of 0.00 reply
    6. 6 Worker 1 internally: Assert: the total is 230.00 Fail

    Outcome: Fails only when the two tests overlap in time; each passes alone and with one worker. Fail

  • One fresh account per worker

    • Worker 1 test "cart total"
    • Test environment a separate account and cart per worker
    • Worker 2 test "empty cart"
    1. 1 From Worker 1 To Test environment: Add items of 150.00 and 80.00 qa+3f9a…@example.com
    2. 2 From Worker 2 To Test environment: Empty its own cart, at the same moment A different account, so a different cart qa+c41e…@example.com
    3. 3 Worker 2 internally: Assert: the cart is empty Pass
    4. 4 From Worker 1 To Test environment: Open the cart and read the total
    5. 5 From Test environment To Worker 1: Shows a total of 230.00 reply
    6. 6 Worker 1 internally: Assert: the total is 230.00 Pass

    Outcome: Same timing, separate carts: both tests pass however their steps interleave. Pass

  • Each worker creates its own fresh account in a worker-scoped fixture, with a UUID in the email so shards cannot collide either.
  • Tests in one worker still share that account one after another, so a beforeEach hook empties the cart before every test instead of trusting the previous one.
  • "Order history" creates its own three orders through the API and asserts on their ids, not on a total count.
  • The invoice is saved to test.info().outputPath(), a folder unique to each test.
  • The fix is confirmed with --repeat-each=10 at four workers, because a single green run proves little.
// fixtures.ts: one fresh account per worker process
import { test as base } from '@playwright/test';
import { randomUUID } from 'node:crypto';

type Account = { id: string; email: string };

export const test = base.extend<{}, { account: Account }>({
  account: [async ({ playwright }, use) => {
    const api = await playwright.request.newContext({ baseURL: process.env.BASE_URL });
    const email = `qa+${randomUUID()}@example.com`;   // unique in every shard
    const res = await api.post('/api/test-accounts', { data: { email } });
    const account: Account = await res.json();
    await use(account);                                // shared by this worker's tests
    await api.delete(`/api/test-accounts/${account.id}`);
    await api.dispose();
  }, { scope: 'worker' }],
});

// cart.spec.ts
import { test } from './fixtures';

test.beforeEach(async ({ request, account }) => {
  await request.delete(`/api/test-accounts/${account.id}/cart`);   // start empty
});

test('download invoice', async ({ page, account }) => {
  // ...sign in as account.email and place an order...
  const downloading = page.waitForEvent('download');
  await page.getByRole('button', { name: 'Download invoice' }).click();
  await (await downloading).saveAs(test.info().outputPath('invoice.pdf'));
});

Try it: Fake User Generator

Common mistakes

  • Switching on parallel execution without first checking what tests share, then blaming the runner for the failures it exposes.
  • Fixing an order dependency by forcing an order, with serial mode or numbered file names, instead of letting each test build its own state.
  • Using the worker index as the only unique part of a name, so worker 0 of every shard uses the same account.
  • Asserting on global counts such as "the list has three orders" in a shared environment instead of on the records the test created.
  • Judging a sharded run by one shard's result instead of the merged report of all shards.
  • Retrying interference failures until they pass, which hides the shared state instead of removing it.

The QA perspective

Isolation is something a tester can check and ask for. Before a suite goes parallel, and whenever you review an automated test, run four checks: does it pass alone, in a shuffled order, twice in a row and with several workers? Jest has --randomize, the pytest-randomly plugin shuffles pytest runs, and Playwright has --repeat-each. Then ask what each test changes and who else can see that change. Many fixes need the developers or the platform team: an API to create test accounts, a way to reset a cart, a separate tenant per run, an environment that can take the load of four workers.

When a test fails only in parallel, record what separates interference from a product defect: the worker and shard it ran on, the account and record ids it used, what else was running at the same time, and whether it passes alone and with one worker. A confirmed interference is a defect in the test suite with an owner and a fix, not noise to re-run.

Practice exercise

Make a settings suite safe to run in parallel

Your task

The suite below works with one worker. The team wants to run it with four workers and two shards. For each test, say what it shares with other tests, how you would isolate it, and which tests can stay as they are or must run separately. Then list what changes because of the two shards.

Material
account-settings.spec.ts (Playwright)
Today:      workers: 1
Planned:    workers: 4, fullyParallel: true
Planned CI: --shard=1/2 and --shard=2/2, both against the same staging environment
Every test signs in as qa.settings@example.com

1  change name      sets the display name to "Sara A."
2  name in header   expects "Sara A." in the page header
3  enable 2FA       turns on two-factor sign-in for the account
4  export data      saves the export to downloads/export.zip, then opens it
5  default theme    sets the site-wide default theme to dark in the admin panel
6  language list    reads the list of supported languages and changes nothing
Steps
  1. For each test, write down what it reads and what it changes.
  2. Mark every change that another test also reads or changes.
  3. Choose a fix for each: own data, own file, a per-worker resource, or a separate serial run.
  4. Check what the two shards add, given that both use the same staging environment.
Show the model answer
Model answer

Tests 1 and 2: test 2 depends on test 1 having run first on the same account. With fullyParallel it can run before test 1 or on another worker. Fix: each test gets a fresh account from a fixture, and test 2 sets the name itself through the API before checking the header.

Test 3: turning on two-factor sign-in for the shared account breaks the sign-in of every other test, including the read-only test 6. It needs its own fresh account, which it can delete afterwards.

Test 4: two workers can write downloads/export.zip at the same time. Save to test.info().outputPath('export.zip') instead.

Test 5: a site-wide setting cannot be isolated per account. Move it to a separate run with --workers=1 that runs once, before or after the shard jobs and never at the same time, because they all use the same staging environment. It sets the theme it expects at its start and restores the default in teardown. Test 6 only reads shared reference data and can stay as it is once it signs in with its own or a read-only account.

Shards: both shards use the same staging environment, so account names must be unique across shards, for example with a UUID, because the worker index alone repeats. Merge the two shard reports into one before judging the run, and confirm the fixes with several repeated runs at four workers.

Frequently asked questions

Does parallel execution make tests flaky?

Mostly it exposes problems that were already there: shared state and order dependencies that a fixed order and timing kept hidden. It can add one real cause, load: four workers can slow a small test environment until waits time out. If failures disappear with fewer workers and no other test touched the failing test's data, suspect load rather than shared state.

What is the difference between workers and shards?

Workers are parallel processes on one machine (threads, in JUnit 5); shards are parts of the suite run by separate machines or CI jobs. They combine: four shards with four workers each run up to sixteen tests at once. Workers share one machine's CPU and disk; shards share only the test environment, and their reports must be merged.

Must every test have its own account?

A fresh account per test is the safest choice when accounts are cheap to create. One account per worker is a common compromise: tests in one worker run one after another, so it works if each test resets what it changes at its start. Read-only data, such as a catalogue no test edits, can be shared by all tests.

References

  • Eradicating Non-Determinism in Tests (Martin Fowler)martinfowler.com · 2011Why flaky tests destroy a suite's value, their common causes (isolation, asynchrony, remote services, time, resource leaks) and quarantine as a response.
  • Playwright DocumentationMicrosoft (Playwright)Official documentation for Playwright, an end-to-end test framework that drives Chromium, WebKit and Firefox.
  • Best Practices — PlaywrightMicrosoft (Playwright)Test user-visible behaviour, keep tests isolated, prefer resilient locators and web-first assertions, and debug with traces.
  • Best Practices — CypressCypress.ioCypress guidance on selecting elements with dedicated attributes, avoiding fixed waits and keeping tests independent.

Practise with Mutqan tools

Generate UUID v4 (random) and v7 (time-ordered) identifiers in bulk, and validate existing ones.

Utilities

Generate test email addresses with safe reserved domains (example.com, test) and plus-addressing variants.

Test Data

Generate fake user records (name, username, email, password, avatar seed, role) for test accounts.

Test Data

One-stop factory for synthetic test data: users, customers, companies, addresses, numbers, dates and strings in JSON, CSV, SQL or XML.

Test Data

Go deeper

Your progress on this topic

Part of the QA Roadmap