Article · Root Causes of JavaScript Test Flakiness

Resetting Module Mocks and Singletons in Vitest

A module evaluated once keeps its state for the rest of the worker's life, so a memoised API client, a config object read at import time or a spy left installed by an earlier test becomes shared mutable state between every test in the file. This guide builds on Test Isolation & State Leakage by separating the four Vitest reset APIs that are routinely confused — clearAllMocks, resetAllMocks, restoreAllMocks and resetModules — and showing which one actually removes each class of leak.

12 sections URL: /root-causes-of-javascript-test-flakiness/test-isolation-and-state-leakage/resetting-module-mocks-and-singletons-in-vitest/
What each Vitest reset API removes clearAllMocks removes call history, resetAllMocks also removes implementations, restoreAllMocks reinstates originals, and resetModules rebuilds the module registry. clearAllMockscall history onlystub stays installed resetAllMockshistory + implementationnow returns undefined restoreAllMocksoriginal code backspies uninstalled resetModulesregistry rebuiltsingletons recreated leak that survives it: stubbed return value missing real behaviour module-level cache nothing (slowest) pick the leftmost API that removes your leak — each step right costs more execution time
The four APIs are a ladder, not synonyms: each removes strictly more state and costs strictly more time.

Root cause #

ES modules are evaluated once per module graph and cached. Every importer receives the same live binding, so any state created at module scope — a client instance, a Map used as a cache, a counter, a value read from process.env at import time — is created once and mutated by every test that touches it. Nothing in the test lifecycle invalidates that cache: beforeEach runs long after the imports were resolved.

Spies compound the problem from the other direction. vi.spyOn(obj, 'method') replaces a property on a shared object. If the test never restores it, the replacement is still there for the next test — and because vi.fn() without an implementation returns undefined, the symptom is usually not “wrong value” but “cannot read property of undefined” in a completely unrelated file. That is why a spy leak so often surfaces as a crash three tests later rather than as an assertion failure where the spy was installed.

The distinction that matters: resetModules fixes state baked into modules; the mock APIs fix state baked into functions. Neither substitutes for the other, and only restoreAllMocks gives back the original implementation.

Step-by-step fix #

1. Enforce restoration in configuration, not per file #

A per-file afterEach protects only the file that remembered to write it. Configuration protects everything, including the spec someone adds next quarter.

// vitest.config.ts
// Trade-off: restoreMocks makes every spy temporary, so a spec that relied on
// a spy surviving between tests will break — that reliance was the bug.
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    restoreMocks: true,   // spies uninstalled after each test
    unstubEnvs: true,     // vi.stubEnv rolled back after each test
    unstubGlobals: true,  // vi.stubGlobal rolled back after each test
    clearMocks: true,     // call history wiped as well
  },
});

The Jest equivalents are restoreMocks: true and resetModules: true in jest.config.js; the reasoning is identical and is applied in Eliminating Test Order Dependence in Jest.

2. Rebuild the registry when a module holds a singleton #

resetModules clears the module cache so the next dynamic import re-evaluates the file and constructs a fresh instance. The static import at the top of your test file will not pick this up — it was already resolved — so the re-import has to be dynamic.

// Trade-off: dynamic re-import per test is the only way to get a fresh
// singleton, but it re-evaluates the whole dependency subtree each time.
import { beforeEach, expect, test, vi } from 'vitest';

beforeEach(() => {
  vi.resetModules();
});

test('client picks up the current base URL', async () => {
  vi.stubEnv('API_BASE_URL', 'https://staging.example.com');
  const { apiClient } = await import('../src/api-client.js'); // fresh instance
  expect(apiClient.baseUrl).toBe('https://staging.example.com');
});

3. Prefer a factory to a module-level instance #

The durable fix is architectural: export a factory and let the composition root hold the instance. A module with no mutable module-scope state cannot leak, and the tests stop needing registry tricks at all.

// src/api-client.js
// Trade-off: callers must now pass the client around instead of importing a
// ready-made one; that is more wiring, and it is what makes tests isolated.
export function createApiClient({ baseUrl, fetchImpl = fetch }) {
  const cache = new Map();               // per-instance, not per-module
  return {
    baseUrl,
    async get(path) {
      if (!cache.has(path)) cache.set(path, await fetchImpl(`${baseUrl}${path}`));
      return cache.get(path);
    },
  };
}
Module-level instance versus factory A module-level instance is shared by all tests in a worker; a factory gives each test its own object with its own cache. module-level instance test 1 test 2 one clientone cache, shared test 2 reads test 1's cache factory per test test 1 test 2 client A + cache A client B + cache B no shared surface
Removing module-scope mutable state is the only fix that keeps working when someone forgets a hook.

4. Reset mocked modules explicitly when you hoist them #

vi.mock is hoisted above the imports, so the factory runs once per file. If that factory closes over mutable state — a queue of queued responses, for instance — reset it per test rather than re-declaring the mock.

// Trade-off: a mutable mock queue makes multi-response tests readable, but it
// is module-scope state, so it needs an explicit reset like any other.
const responses = [];
vi.mock('../src/http.js', () => ({
  request: vi.fn(async () => responses.shift() ?? { status: 204 }),
}));

beforeEach(() => {
  responses.length = 0;   // the reset the mock factory cannot do for itself
});

5. Watch for side effects that run at import time #

The hardest leaks are not values but actions. A module that starts an interval, registers a global event listener, opens a socket or writes to a shared registry when it is first imported performs that action again on every resetModules, and the previous one is never undone. The result is a worker accumulating timers and listeners until an unrelated test times out.

// src/telemetry.js — an import-time side effect: the interval is created the
// first time any test imports this module, and resetModules creates another.
setInterval(() => flushQueue(), 5_000);   // nothing ever clears it

// Preferred shape: export the lifecycle explicitly so tests can end it.
export function startTelemetry() {
  const handle = setInterval(() => flushQueue(), 5_000);
  return () => clearInterval(handle);      // caller owns teardown
}

Auditing for this is mechanical: search for calls at module scope — setInterval, addEventListener, connect, subscribe — outside a function body. Each one is either an intentional singleton that must never be reset, or a leak waiting for the first test that resets the registry.

6. Confirm the reset actually worked #

Treat isolation as a property with its own test rather than an assumption. A two-test probe proves that state written by one test is invisible to the next, and it fails the day someone removes the configuration.

// Trade-off: a probe spec is redundant while everything works, and it is the
// only test that fails for the right reason when a reset setting is dropped.
import { expect, test, vi } from 'vitest';
import * as clock from '../src/clock.js';

test('installs a spy', () => {
  vi.spyOn(clock, 'now').mockReturnValue(0);
  expect(clock.now()).toBe(0);
});

test('sees the original implementation', () => {
  expect(clock.now()).not.toBe(0);   // red unless restoreMocks is on
});

Pitfalls #

  • Reaching for clearAllMocks when the stub itself is the leak. History is wiped, the fake stays. Mitigation: use restoreAllMocks, or enable restoreMocks globally.
  • Calling resetModules but keeping the static import. The top-level binding was resolved before the reset. Mitigation: re-import dynamically inside the test.
  • resetAllMocks on a spy you still need. The implementation becomes undefined and the next call crashes. Mitigation: restore rather than reset, then re-spy where needed.
  • Stubbing process.env without unstubEnvs. Environment drift crosses tests silently and changes code paths. Mitigation: enable unstubEnvs, or set env only through vi.stubEnv.
  • A mock factory holding an array. vi.mock is hoisted and runs once. Mitigation: clear the array in beforeEach.
  • Using resetModules everywhere for safety. Re-evaluating heavy dependency trees per test can double suite duration. Mitigation: apply it in the files that need it, and remove module-level state elsewhere.
Choosing a reset by symptom A decision path from the observed symptom to the correct reset API. call count wrong fake still answering stale cached value clearAllMocks restoreAllMocks resetModules + re-import cheapest default costliest
Symptom to API: the crash-three-tests-later signature almost always means a spy that was never restored.

Reliability targets #

Metric Target Notes
Files relying on manual afterEach restores 0 Enforced by restoreMocks in config
Modules exporting mutable state Trending to 0 Counted in review; replaced by factories
Suite time added by resetModules < 15% Measure before adopting it file-wide
Order-dependent unit failures 0 across 20 shuffle seeds --sequence.shuffle in CI
Spy-leak crashes per month 0 Signature: undefined return in an unrelated file
Module-isolation scorecard Targets for manual restores, mutable module state, reset overhead and shuffled-seed stability. confignot per-file hooks 0module-level state < 15%reset overhead 20 seedssame result
The goal is a suite where no test needs to know what the previous test mocked.

Frequently Asked Questions #

Q: What is the difference between resetAllMocks and restoreAllMocks? A: Reset keeps the mock installed but empties its implementation, so calls return undefined. Restore removes the mock entirely and puts the original function back. For spies created with vi.spyOn, restore is almost always what you want; reset is for a vi.fn() you intend to re-program in the next test.

Q: Why does vi.resetModules() not give me a fresh singleton? A: Because your test still holds the binding from the static import at the top of the file, which was resolved before the reset ran. Re-import the module dynamically inside the test after resetting, and use the returned object rather than the top-level one.

Q: My mocked module is not being mocked at all. What happened? A: vi.mock is hoisted to the top of the file, above the imports, so its factory cannot reference variables declared later in the module scope — doing so throws or silently yields undefined. Move the values the factory needs inside the factory, or create them with vi.hoisted() so they exist by the time the hoisted mock runs.

Q: Does resetModules clear my mocks too? A: It clears the registry, so the next dynamic import re-runs the module and re-applies any vi.mock declared for it — but it does not restore spies you installed with spyOn on an already-imported object. The two mechanisms are independent: reset the registry for module-level state, restore mocks for function-level state, and enable both in configuration so you never have to decide per file.

Q: Is restoreMocks: true safe to switch on for an existing suite? A: It will fail any test that depended on a spy installed by an earlier test — which is exactly the coupling you are trying to remove. Turn it on, read the failures as a list of leaks, and fix them by installing the spy where it is used. The alternative is leaving latent order dependence in place.