Resetting the Module Registry Between Tests
Every module in a Vitest run is evaluated once and cached. That is usually what you want — it is why importing a large library in twenty test files costs almost nothing after the first — but it means anything a module computes at load time is computed once and shared. A configuration object built from environment variables, a singleton client, a memoised lookup table, a counter: all of them survive from one test to the next within a file. This guide covers when that sharing causes problems, how vi.resetModules and dynamic imports give each test a fresh copy, how the isolate setting differs, and — most usefully — how to design modules so that resetting is rarely needed. It sits under module and dependency mocking.
Root Cause Analysis
Module-level state is the most common cause of order-dependent failures within a single test file. The pattern is simple: a module holds something at its top level, the first test that imports it initialises that something with the conditions of the first test, and every later test sees the result. Change an environment variable in test two, and the module still holds test one’s configuration; mock a dependency with vi.doMock in test three, and the module already has the real dependency.
The confusion comes from two settings that sound similar. Vitest’s isolate option, on by default, gives each test file its own module registry, so state does not leak between files. It does nothing within a file. vi.resetModules() clears the registry within a file, so the next import evaluates the module again — but only for imports that happen after the reset, which rules out static import statements at the top of the file.
The third ingredient is mocking. vi.mock is hoisted and applies to the whole file, but vi.doMock is not hoisted and applies only to imports that happen after it. A test that calls doMock and then uses a statically imported module is using the unmocked version, because that import was resolved before the test ran. The fix is always the same pair: reset, then import dynamically.
Reproducible Setup
A module that captures configuration and a client at load time — the shape that makes resets necessary.
// src/billing/client.ts
import { createHttpClient } from '../net/http';
const baseUrl = process.env.BILLING_URL ?? 'https://billing.internal';
const timeoutMs = Number(process.env.BILLING_TIMEOUT_MS ?? 5000);
export const billing = createHttpClient({ baseUrl, timeoutMs }); // built once, at import
let calls = 0;
export const callCount = () => calls;
export async function charge(amountPence: number) {
calls++;
return billing.post('/charges', { amountPence });
}
Implementation
Step 1 — Reset and import dynamically when a test changes load-time inputs. Stub first, reset second, import third — in that order.
// src/billing/client.test.ts
import { test, expect, vi, beforeEach } from 'vitest';
beforeEach(() => vi.resetModules());
test('uses the configured base URL', async () => {
vi.stubEnv('BILLING_URL', 'https://billing.test');
const { billing } = await import('./client');
expect(billing.baseUrl).toBe('https://billing.test');
});
test('falls back to the default URL', async () => {
const { billing } = await import('./client');
expect(billing.baseUrl).toBe('https://billing.internal');
});
Without the reset, the second test would receive the module evaluated in the first, still pointing at billing.test.
Step 2 — Pair vi.doMock with a fresh import. doMock affects only imports that occur after it, so the module under test must be imported inside the test.
test('surfaces a timeout from the HTTP layer', async () => {
vi.doMock('../net/http', () => ({
createHttpClient: () => ({ baseUrl: 'x', post: vi.fn().mockRejectedValue(new Error('timeout')) }),
}));
const { charge } = await import('./client');
await expect(charge(100)).rejects.toThrow('timeout');
});
Step 3 — Reset counters and caches by re-importing, not by exporting reset functions. A __resetForTests export is a test concern leaking into production code; a fresh import achieves the same with no production change.
test('counts charges from zero in each test', async () => {
vi.doMock('../net/http', () => ({ createHttpClient: () => ({ post: vi.fn().mockResolvedValue({}) }) }));
const { charge, callCount } = await import('./client');
await charge(100);
expect(callCount()).toBe(1); // not 1 + whatever earlier tests did
});
It is worth being explicit about why the reset-export pattern is worse, since it is common. A function called __resetCountForTests ships to production, where nothing stops it being called; it has to be maintained in step with every piece of state the module grows; and its existence tells every reader that the module’s state was a problem nobody solved. Re-importing in tests achieves the same isolation with none of those costs, and it keeps the module’s public surface limited to what production actually uses.
Step 4 — Know the cost and scope the reset. Resetting forces every subsequently imported module to evaluate again, including heavy ones. Reset only in files that need it, and import the heavy dependencies statically so they stay cached — resetModules does not affect modules already imported at the top of the file for the file’s own use.
Step 5 — Prefer factories to module-level instances. The design that removes the need for resets is a module that exports a function to build the thing, rather than the thing itself.
// src/billing/client.ts — no load-time state
export function createBilling(env = process.env, http = createHttpClient) {
let calls = 0;
const client = http({ baseUrl: env.BILLING_URL ?? 'https://billing.internal', timeoutMs: Number(env.BILLING_TIMEOUT_MS ?? 5000) });
return {
charge: (amountPence: number) => { calls++; return client.post('/charges', { amountPence }); },
callCount: () => calls,
};
}
test('each instance has its own count', async () => {
const billing = createBilling({ BILLING_URL: 'https://b.test' }, () => ({ post: vi.fn().mockResolvedValue({}) }) as never);
await billing.charge(100);
expect(billing.callCount()).toBe(1); // no resetModules, no dynamic import
});
The factory takes its inputs as parameters with production defaults, which is what makes every test able to build exactly the instance it needs. It also documents the module’s dependencies in its signature: a reader sees immediately that billing depends on configuration and an HTTP client, where the module-level version hid both behind import-time side effects. Tests become shorter and more honest at the same time, which is the usual result of removing load-time state.
Step 6 — Keep one production instance at the composition root. Applications still want a single client; create it where the application is wired together, not at module scope in the library, and pass it where needed.
Verification
Confirm the reset is doing work by removing it. Run the configuration tests without resetModules; the second test should now see the first test’s environment, which proves the reset was preventing a real leak.
npx vitest run src/billing/client.test.ts --sequence.shuffle --sequence.seed=3
# with the reset removed:
# FAIL falls back to the default URL — expected 'https://billing.internal', received 'https://billing.test'
Then check the cost. A file that resets and re-imports a large dependency graph in every test can become the slowest file in the suite; the per-file timing report points it out.
npx vitest run --reporter=verbose | sort -t'(' -k2 -rn | head -5
Troubleshooting
Symptom: the test still gets the old module after resetModules. Diagnosis: the module was imported statically at the top of the file. Fix: move the import inside the test as await import(...), after the reset.
Symptom: doMock seems to be ignored. Diagnosis: the module under test was already imported, statically or earlier in the test. Fix: register the mock, reset, then import — in that order.
Symptom: two copies of a library appear and instanceof checks fail. Diagnosis: the test holds a reference from before the reset, and the code under test was re-imported with a fresh copy, so their classes differ. Fix: import error classes and helpers from the same fresh import as the code under test, not from a static import.
Symptom: resetting makes the file very slow. Diagnosis: every test re-evaluates a large dependency graph. Fix: reset only where needed, keep heavy libraries statically imported, and prefer refactoring the offending module into a factory.
FAQ
Is vi.resetModules the same as Jest’s jest.resetModules?
Conceptually yes: both clear the registry so subsequent imports re-evaluate. The same ordering rules apply, and the same advice about factories holds. Differences lie mainly in how ESM is handled, where Vitest’s dynamic import support is more natural.
Should I enable isolate: false for speed?
Only if the suite is free of module-level state, because turning isolation off lets state leak between files as well as within them. Measure first; the gain is real for large suites of pure tests and dangerous for everything else, as eliminating test order dependence explains.
Does resetting modules also reset mocks?
No. resetModules clears the module cache; mocks registered with vi.mock remain, and spies need vi.restoreAllMocks. Treat them as separate cleanups, each with its own configuration option.
When is module-level state acceptable?
When it is immutable and derived from nothing the tests vary — a constant lookup table, a compiled regular expression. The trouble starts only when the state depends on the environment, on other modules that tests mock, or when it changes over time.
Related
- Back to Module & Dependency Mocking
- Avoiding vi.mock hoisting pitfalls — the hoisting rules that make doMock necessary.
- Isolating environment variables per test — the most common load-time input.
- Mocking Node built-in modules in Vitest — doMock and fresh imports in practice.