Subtopic · Root Causes of JavaScript Test Flakiness

Test Isolation & State Leakage

A test that passes alone and fails in a suite is not a timing bug — it is a state-leakage bug, and it belongs to the family of failure modes catalogued in Root Causes of JavaScript Test Flakiness. Something the previous test wrote survived into this one: a cookie, a localStorage key, a module-level singleton, a spy that was never restored, a database row, a fake timer that was never uninstalled. The failure looks random because test order is not stable — Jest and Vitest schedule files by worker availability, Playwright shards by duration — so the polluting test and the victim only land in the same worker some of the time. This topic covers how to find the leak, how to reset each layer of state, and how to prove the isolation holds under randomised ordering.

14 sections 3 child guides URL: /root-causes-of-javascript-test-flakiness/test-isolation-and-state-leakage/
Layers of state that survive a test Five layers — browser storage, module registry, global stubs, timers and external data stores — each persist beyond a single test unless explicitly reset. test A writes state test B reads it back browser storage — cookies, localStorage, IndexedDB module registry — singletons, caches, config global stubs — spies, mocks, patched fetch timers — fake clocks, pending intervals external stores — database rows, queues, files every layer needs its own teardown — resetting one does not reset the others
State leaks through five independent layers; a suite is only isolated when all five are reset between tests.

Prerequisites #

Requirement Version / setting Why it matters
Jest 29+ restoreMocks, resetModules and --randomize are all stable
Vitest 1.2+ restoreMocks, unstubEnvs, sequence.shuffle
Playwright 1.40+ Per-test browser context isolation is the default; storageState is opt-in
Cypress 13+ testIsolation defaults to true; setting it to false is what reintroduces leakage
CI runner Any parallel-capable runner Ordering must be shuffled per run for leakage to surface reliably

Isolation work assumes you already control the network layer — a shared mock server holding request history is itself leaked state, so pair this with Network & API Mocking for Reliable Tests before hunting for storage leaks.

Step-by-step implementation #

1. Prove leakage exists before fixing anything #

Order dependence is a measurement problem first. Run the suite in a shuffled order several times; if the pass rate changes with the seed, you have leakage rather than a timing race.

# Trade-off: shuffling surfaces order dependence but makes failures harder to
# reproduce — always print the seed so a red run can be replayed exactly.
npx jest --randomize --seed=20260802   # Jest 29+: deterministic shuffle
npx vitest run --sequence.shuffle --sequence.seed=20260802

A suite that is green at seed 1 and red at seed 2 has an ordering dependency. The techniques in Eliminating Test Order Dependence in Jest turn that signal into a named culprit.

2. Reset browser storage per test, not per file #

Browser state is the most common leak in end-to-end suites because it is invisible in the test body. Playwright gives each test a fresh context by default; the leak reappears the moment you reuse storageState or open a shared context.

// playwright.config.ts — Trade-off: a signed-in storageState makes every test
// faster, but shares one user's data; give write-heavy specs their own account.
import { test } from '@playwright/test';

test.beforeEach(async ({ context }) => {
  await context.clearCookies();
  await context.addInitScript(() => {
    // Runs before any page script, so app bootstrap sees empty storage.
    window.localStorage.clear();
    window.sessionStorage.clear();
  });
});

Cypress inverts the default: testIsolation: true clears cookies, storage and the page between tests in a spec. Turning it off for speed is the single fastest way to buy order dependence, as Clearing Browser Storage Between Tests shows in detail.

Per-file versus per-test reset Resetting once per file leaves tests two through four reading state written by test one; resetting per test keeps each independent. per-file reset reset test 1 writes test 2 inherits test 3 inherits test 4 inherits per-test reset reset test 1 reset test 2 reset test 3 reset test 4 cost: one extra reset per test — usually single-digit milliseconds for storage, more for a database
Per-file resets only protect the first test in the file; every later test inherits whatever ran before it.

3. Reset the module registry and its singletons #

Unit and component suites leak through the module registry. A module that memoises a client, a config object or an in-memory cache keeps that value for every test in the file, because import returns the same instance.

// Trade-off: resetModules() is thorough but forces re-evaluation of every
// import in the test — noticeably slower on files with heavy dependency trees.
import { beforeEach, vi } from 'vitest';

beforeEach(() => {
  vi.resetModules();       // fresh module registry, so singletons rebuild
  vi.restoreAllMocks();    // spies return to their original implementations
  vi.unstubAllEnvs();      // process.env changes rolled back
});

Prefer configuration over per-file hooks so a new spec cannot forget: set restoreMocks: true and unstubEnvs: true in vitest.config.ts, or restoreMocks: true and resetModules: true in jest.config.js. Resetting Module Mocks and Singletons in Vitest covers the cases where a reset is not enough — module-level side effects that run at import time.

4. Give each worker its own external data #

Shared external state is leakage across processes rather than across tests, and no in-process hook can fix it. Each worker needs its own namespace — a schema, a database, a key prefix — as described in Isolating Database State in Parallel Jest Workers.

// globalSetup or per-worker setup file
// Trade-off: a schema per worker is fully isolated but multiplies migration
// time at start-up; a row-prefix per worker is cheaper but leaks on bad queries.
const workerId = process.env.JEST_WORKER_ID ?? '1';
process.env.DATABASE_SCHEMA = `test_w${workerId}`;

5. Uninstall fake timers and pending work #

A fake clock installed in one test and not uninstalled makes every subsequent test’s setTimeout never fire — which reads as a mysterious timeout far away from the cause. Always restore in an afterEach, and pair with the guidance in Timer & Animation Flakiness.

// Trade-off: useRealTimers() in afterEach costs nothing, but any interval the
// component left running now runs for real — clear them in component teardown.
afterEach(() => {
  vi.useRealTimers();
  vi.clearAllTimers();
});
Bisecting a suite to name the polluting test Halving the list of tests that run before the failing test locates the polluter in a logarithmic number of runs. 64 tests run before the failure — still red first 32 — still red last 32 — green first 16 — red green polluter ≈ log₂(n) runs to isolate one polluting test out of sixty-four
Bisection turns "some test before this one breaks it" into a named file in six runs rather than sixty-four.

How leakage differs by test level #

The same word covers three quite different engineering problems, and the reset that works at one level is useless at another.

Unit tests leak through the module registry and through spies. Everything lives in one process, so the leak is fast to reproduce and cheap to fix — a restoreMocks setting usually removes an entire class at once. The trap is that unit suites run thousands of tests per worker, so a single unrestored spy can affect hundreds of downstream tests and produce a failure list so broad that it looks like an infrastructure problem rather than one bad afterEach.

Component tests add the DOM. A component mounted in one test and never unmounted keeps its event listeners, its intervals and its portal nodes attached to the same document, so the next test’s query selector can match the previous test’s markup. Testing Library’s automatic cleanup handles the common case; anything mounted outside the library’s control — a modal rendered into document.body, a third-party widget, a canvas that registers a resize observer — has to be torn down by hand. The symptom is distinctive: a query that matches multiple elements when the test expected one.

End-to-end tests leak through everything outside the process: the browser profile, the server, the database, the message queue, the file system. No in-process hook can reach any of it, so isolation has to be designed rather than configured. The two workable strategies are namespacing (each worker gets its own schema, tenant or key prefix) and transactional rollback (each test runs inside a transaction that is never committed). Namespacing scales to full-stack suites where the server owns its own connections; rollback is faster but only works when the test and the application share a connection, which rules it out for most browser-driven suites.

A practical consequence: the cost of isolation rises sharply with the level, so the level at which you place a test should account for how much state it will need to reset. A behaviour that can be verified at the component level rarely justifies the per-test database cost of an end-to-end check.

Enforcing isolation so it does not decay #

Isolation is not a project, it is a property that erodes with every merge unless something defends it. Three mechanisms do the defending, in ascending order of strength.

The weakest is convention: a paragraph in the contributing guide asking people to clean up. It has no enforcement and degrades as soon as the team grows past the people who wrote it. The middle option is configuration — restoreMocks, resetModules, testIsolation, per-worker namespaces — which makes the correct behaviour the default and requires an explicit opt-out to break. That covers the majority of real cases and is where most of the value is.

The strongest is a test that fails when isolation breaks. A nightly shuffled run with a rotating seed is exactly that: it turns “someone might introduce order dependence” into “the pipeline goes red within a day if they do”. Wire it as its own required check rather than folding it into the main test job, so a shuffle-only failure is visibly distinct from an ordinary test failure and does not get retried away by a blanket retry policy — the distinction matters for the retry budgets discussed in Flaky Test Detection & Quarantine Engineering.

// A lint rule is the cheapest of these to add and catches the writer, not the victim.
// Trade-off: it only sees syntactic writes, so a helper that assigns to globalThis
// indirectly still slips through — treat it as a first line, not a guarantee.
module.exports = {
  rules: {
    'no-restricted-globals': ['error', {
      name: 'globalThis',
      message: 'Do not write worker-wide state from a test; use a fixture instead.',
    }],
  },
};

Configuration reference #

Option Runner Accepted values Default Effect on reliability
restoreMocks Jest / Vitest true | false false Restores original implementations after each test; removes spy leakage entirely
resetModules Jest true | false false Rebuilds the module registry per test file, clearing memoised singletons
unstubEnvs Vitest true | false false Rolls back vi.stubEnv writes so env drift cannot cross tests
sequence.shuffle Vitest true | false false Randomises order so order dependence fails fast instead of intermittently
--randomize Jest 29+ flag off Same effect for Jest; combine with --seed for replayable runs
testIsolation Cypress 13+ true | false true false reuses the page, cookies and storage across tests in a spec
storageState Playwright path | undefined undefined Seeds a signed-in session; shared files reintroduce cross-test coupling
fullyParallel Playwright true | false false Runs tests in a file concurrently, exposing shared-fixture assumptions

Data-driven analysis #

Isolation quality is measurable, and the numbers tell you where to spend effort:

  • Seed sensitivity. Run the suite under five different shuffle seeds. Any variation in the failing set is order dependence; a suite with zero variation across twenty seeds is isolated in practice. This is the primary metric — track it per pipeline as part of Historical Flakiness Tracking & Analytics.
  • Solo-versus-suite delta. For each failing test, re-run it alone. A test that is green alone and red in-suite is leakage; a test that is red both ways is a genuine defect or a timing race. This split routes work correctly and stops engineers from “fixing” application code that was never broken.
  • Reset cost. Time the beforeEach teardown. Storage clears cost under 5 ms; a schema truncate costs 20–200 ms. If teardown exceeds roughly 10% of median test duration, move from truncation to a transaction rollback or per-worker schema instead.
  • Leak surface count. Count the modules exporting mutable module-level state. This is the population from which future leaks will come; it should trend down, not up.
Routing a failure by solo-versus-suite result A test green alone but red in suite is leakage; red in both is a real defect or timing race. failing testre-run alone green alone red alone state leakagebisect for the polluter real defect or racedebug the test itself add the missing resetenforce it in config fix or quarantine
The solo re-run is the cheapest triage signal available: it splits leakage from genuine defects in one execution.

Common pitfalls & mitigation strategies #

  • Clearing storage after the app has already booted. The bootstrap read a stale token before your clear() ran. Mitigation: use addInitScript in Playwright or cy.clearLocalStorage() before cy.visit(), never after.
  • clearAllMocks where restoreAllMocks was needed. Clearing wipes call history but leaves the stub installed, so the next test still sees a fake. Mitigation: enable restoreMocks globally and treat manual clearing as an exception.
  • Disabling testIsolation for speed. Cypress’s shared-page mode makes a spec into one long stateful session. Mitigation: keep it on; if a login is slow, cache the session rather than the page state.
  • Seeding data in before and mutating it in tests. The second test sees the first test’s mutations. Mitigation: seed in beforeEach, or seed immutable reference data only and create mutable rows per test.
  • A shared mock server accumulating handlers. Handlers registered in one test still match in the next. Mitigation: reset handlers between tests, as covered in Tearing Down MSW Service Workers Between Tests.
  • Fixing the victim instead of the polluter. Adding a wait to the failing test hides the leak and leaves it for the next unlucky test. Mitigation: bisect to the writer and reset at the source.
Isolation scorecard Targets for seed sensitivity, solo-versus-suite agreement, teardown cost and reset enforcement. 0order-dependent tests 20 seedsidentical results < 10%teardown / test time configresets, not hooks
Enforcing resets through configuration rather than per-file hooks is what keeps the first two numbers at zero as the suite grows.

Frequently Asked Questions #

Q: Where should isolation live — in every test, or in the framework? A: In the framework, as far as it will reach. A reset written in a test file protects that file; a reset expressed as configuration or as a shared fixture protects every file including the ones not yet written. The practical rule is that a test should describe behaviour, not housekeeping: if a spec opens with six lines of clearing and restoring, that setup belongs in the runner configuration or in a fixture the whole suite inherits.

Q: My test passes locally and fails in CI — is that state leakage? A: Only if it also passes when run alone in CI. Shuffle the order with a fixed seed and re-run; if the failure follows the seed rather than the machine, it is leakage. If it follows the machine, look at environment drift instead — different browser builds, time zones or CPU counts, covered in CI Environment & Browser Drift.

Q: Should I turn off parallelism to make the suite stable? A: No. Serial execution hides leakage rather than removing it, and the same bug will resurface the day someone re-enables workers. Fix the isolation and keep the parallelism; the suite gets both faster and more honest.

Q: How is state leakage different from a race condition? A: Leakage is a data dependency between tests — one test’s writes are visible to another. A race is a timing dependency inside a single test. Leakage is order-sensitive and reproducible with a seed; a race usually is not. Race Conditions in Parallel Test Runs covers the timing side.

Q: Is a shared signed-in session worth the coupling it introduces? A: For read-only tests, yes — signing in once and reusing the stored session saves minutes per pipeline and the tests cannot interfere with each other because none of them writes. For tests that mutate account data it is a false economy: two tests editing the same profile race on the server, where no browser-side reset can help. Split by behaviour rather than by convenience, giving write-heavy tests their own account.

Q: We truncate the database between every test and the suite is now too slow. What else is there? A: In rough order of speed: run each test inside a transaction that is rolled back, which costs almost nothing but requires the test and the application to share a connection; give each worker its own schema and truncate only the tables that worker touched; or seed immutable reference data once and have each test create only the rows it needs with unique keys. The last option usually gives the best ratio of isolation to cost in browser-driven suites, because it removes the teardown entirely rather than making it faster.

Q: Should quarantining an order-dependent test count against the flakiness budget? A: Yes, and it should be triaged differently from a timing flake. An order-dependent test is reproducible once you know the seed, so it has a concrete root cause and a definite fix; leaving it quarantined indefinitely hides a real defect in the suite’s structure rather than an unavoidable environmental problem.

Explore next

Child guides in this section