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);
},
};
}
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
clearAllMockswhen the stub itself is the leak. History is wiped, the fake stays. Mitigation: userestoreAllMocks, or enablerestoreMocksglobally. - Calling
resetModulesbut keeping the static import. The top-level binding was resolved before the reset. Mitigation: re-import dynamically inside the test. resetAllMockson a spy you still need. The implementation becomesundefinedand the next call crashes. Mitigation: restore rather than reset, then re-spy where needed.- Stubbing
process.envwithoutunstubEnvs. Environment drift crosses tests silently and changes code paths. Mitigation: enableunstubEnvs, or set env only throughvi.stubEnv. - A mock factory holding an array.
vi.mockis hoisted and runs once. Mitigation: clear the array inbeforeEach. - Using
resetModuleseverywhere 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.
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 |
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.