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.
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();
});
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
beforeEachteardown. 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.
Common pitfalls & mitigation strategies #
- Clearing storage after the app has already booted. The bootstrap read a stale token before your
clear()ran. Mitigation: useaddInitScriptin Playwright orcy.clearLocalStorage()beforecy.visit(), never after. clearAllMockswhererestoreAllMockswas needed. Clearing wipes call history but leaves the stub installed, so the next test still sees a fake. Mitigation: enablerestoreMocksglobally and treat manual clearing as an exception.- Disabling
testIsolationfor 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
beforeand mutating it in tests. The second test sees the first test’s mutations. Mitigation: seed inbeforeEach, 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.
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.