Debug Vitest Module Mocking and Cache Leaks
When a Vitest test passes with -t and fails in the full file, a module cached by an earlier test usually holds the state. This guide reproduces the failure with a fixed shuffle seed, shows what per-file isolation covers in each pool (including process.env surviving between files in the vm pools), fixes an import-time environment read with vi.stubEnv, a scoped vi.resetModules and onTestFinished, and separates late mocks, module singletons and leaked fake timers by their symptoms. It also shows why clearMocks, mockReset, restoreMocks and unstubEnvs leave the module cache alone.

process.env at import time keeps whatever value was set when it was first evaluated. Reproduce the failing order with a fixed --sequence.seed, log when the suspect module evaluates, then fix it in the test that changed the state: vi.stubEnv before a dynamic import, vi.resetModules scoped to that test, and an onTestFinished that restores the environment and evicts the module it evaluated. Global reset flags do not evict modules, so they leave this leak in place.clearMocks is now true, so call history no longer carries between tests but mock implementations still do. And vi.mock, vi.unmock and vi.hoisted written inside a function or a test callback now throw instead of warning. Versions checked on 2026-09-30: Vitest 5.0.2 (released 2026-09-25), which requires Node.js 22.12 or later. Every reproduction below was run on Vitest 5.0.2 under Node.js 26.0.0.You run the file and one test fails. You run that test with -t and it passes. Nothing in the failing test changed; something that ran before it did. The reflex is to add vi.resetModules() and every reset flag to the config until the suite is green, and that sometimes works, which is the problem: you end up with a slower suite and no idea which test leaked or what it leaked.
Instead, get the failure to reproduce on demand, find which piece of state crossed the gap, and put the cleanup in the test that created it.
A failure that needs a predecessor
Here is the smallest version of the problem. A configuration module reads the environment while it is being imported, which is a common shape for anything that builds a client or a base URL once:
// src/config.ts
console.log(`[config] evaluated, PAYMENTS_URL=${process.env.PAYMENTS_URL}`)
export const config = Object.freeze({
baseUrl: process.env.PAYMENTS_URL ?? 'http://localhost:4010',
timeoutMs: Number(process.env.PAYMENTS_TIMEOUT_MS ?? '5000'),
})
Two tests import it dynamically, so each looks like it gets its own copy:
// test/config.test.ts
import { expect, test } from 'vitest'
test('falls back to the local stub', async () => {
const { config } = await import('../src/config')
expect(config.baseUrl).toBe('http://localhost:4010')
})
test('reads PAYMENTS_URL from the environment', async () => {
process.env.PAYMENTS_URL = 'https://sandbox.payments.test'
const { config } = await import('../src/config')
expect(config.baseUrl).toBe('https://sandbox.payments.test')
})
Filtered to the second test, it passes. The whole file fails it:
FAIL test/config.test.ts > reads PAYMENTS_URL from the environment
AssertionError: expected 'http://localhost:4010' to be 'https://sandbox.payments.test' // Object.is equality
Try this in Test Failure Triage
Now shuffle the tests and the failure moves. With --sequence.shuffle.tests --sequence.seed=3 the environment test runs first and passes, and the fallback test fails with the values swapped: it expected the local stub and received the sandbox URL. Two victims, depending only on order, means two leaks: the cached module and the environment variable nobody restored.
Pin whichever order fails before touching anything. The sequence options accept a seed on the command line, the default seed is Date.now(), and a shuffled run prints the seed it used (Running tests with seed "3") in its header, so a CI failure from a shuffled run can be replayed locally:
# the victim alone: passes if the failure needs a predecessor
npx vitest run test/config.test.ts -t "reads PAYMENTS_URL"
# the whole file in a fixed shuffled order, with per-test stdout
npx vitest run test/config.test.ts --sequence.shuffle.tests --sequence.seed=3 --reporter=verbose
Two details of filtering matter here. In Vitest 5, -t matches the full name with suites joined by " > ", so a pattern like "payments reads" that spanned a describe boundary in Vitest 4 now needs to be "payments > reads". And a filtered run still executes the file's top-level code; only the other tests' bodies are skipped.
Where the stale value lives
Look at the verbose output of the failing order. Vitest attaches console output to the test that produced it, so the evaluation log tells you which test caused the module to load:
stdout | test/config.test.ts > reads PAYMENTS_URL from the environment
[config] evaluated, PAYMENTS_URL=https://sandbox.payments.test
✓ test/config.test.ts > reads PAYMENTS_URL from the environment 3ms
× test/config.test.ts > falls back to the local stub 3ms
One evaluation line for two imports. The second await import() returned the module the first test had already evaluated, with the environment as it was then. Inside a test file, every test shares one module registry.
Across files it is different. By default the Test Isolation guide says Vitest "runs every test file in an isolated environment based on the pool": the default forks pool gives each file its own child process, threads its own worker, and the vm pools a separate VM context. A failure that follows another file therefore usually means someone turned isolation off, or that the leaked state lives somewhere the per-file boundary does not reach.
| Configuration | Module state between files | process.env between files |
|---|---|---|
forks or threads, isolate: true (default) | Fresh per file | Fresh per file |
forks or threads, isolate: false | Shared: files in one worker reuse the registry | Shared |
vmThreads or vmForks | Fresh VM context per file | Shared, even with isolation on |
The last row is the one people do not expect. The lifecycle guide says workers are reused when isolation is disabled "OR pool is vmThreads or vmForks", and Node documents that process.env changes stay inside the process or worker thread that made them. Put together, a raw assignment to process.env in one file survives into the next file that lands on the same vm-pool worker. Running one file that sets a variable and a second that asserts it is unset, with --maxWorkers=1 and the setter first, the second file passed under forks and threads and failed under both vm pools with isolation left on. If you moved to a vm pool for speed, look here first.
Clean up in the test that made the mess
The fix for the reproduction touches only the test that mutates something. The fallback test is left alone, because it did nothing wrong:
import { expect, onTestFinished, test, vi } from 'vitest'
test('falls back to the local stub', async () => {
const { config } = await import('../src/config')
expect(config.baseUrl).toBe('http://localhost:4010')
})
test('reads PAYMENTS_URL from the environment', async () => {
onTestFinished(() => {
vi.unstubAllEnvs() // put process.env back
vi.resetModules() // drop the copy of config evaluated under the stub
})
vi.stubEnv('PAYMENTS_URL', 'https://sandbox.payments.test')
vi.resetModules() // ignore any copy evaluated before the stub
const { config } = await import('../src/config')
expect(config.baseUrl).toBe('https://sandbox.payments.test')
})
That version passed under all eight shuffled seeds tried, and the verbose log now shows two evaluation lines, one per environment.
vi.stubEnv records the original value on first use, and vi.unstubAllEnvs restores "all import.meta.env and process.env values that were changed with vi.stubEnv". The hand-written alternative has a trap in it. Saving the old value and assigning it back looks right, but when the variable was unset the saved value is undefined, and Node converts anything assigned to process.env to a string. The restore leaves the literal text 'undefined' behind, which is truthy and will be read as a URL by the next module that checks it:
AssertionError: expected 'undefined' to be undefined
Node's own documentation shows the conversion and says to use delete instead. vi.stubEnv(name, undefined) removes the variable, which is the behaviour you wanted.
The first vi.resetModules() matters when this test runs second: without it, the dynamic import returns the fallback test's copy. The one inside onTestFinished matters when it runs first: it evicts the sandbox-evaluated copy so the next test evaluates its own. onTestFinished rather than a trailing line in the test body, because the hooks reference points out that cleanup written at the end of a test "won't be restored if one of the assertions fails". Registering it at the top means it runs on failure too.
There is a cost. vi.resetModules is not per-module: it "resets modules registry by clearing the cache of all modules", so every module the next test imports dynamically is evaluated again. The vi API has no call that evicts a single module. Keeping the call inside the one test that needs it bounds that cost to that test's neighbours. When the module is yours, the cheaper fix is to stop reading the environment at import time:
// src/config.ts
export function loadConfig(env: NodeJS.ProcessEnv = process.env) {
return Object.freeze({
baseUrl: env.PAYMENTS_URL ?? 'http://localhost:4010',
timeoutMs: Number(env.PAYMENTS_TIMEOUT_MS ?? '5000'),
})
}
export const config = loadConfig()
// test: no module reset, no environment mutation
expect(loadConfig({ PAYMENTS_URL: 'https://sandbox.payments.test' }).baseUrl)
.toBe('https://sandbox.payments.test')
For a file where every test needs the same environment, vi.hoisted(() => { vi.stubEnv('PAYMENTS_URL', '…') }) above a plain static import also works. The vi.hoisted reference explains why: Vitest rewrites the file's static imports into dynamic ones, so the hoisted callback runs before config.ts is evaluated.
Three leaks with different fingerprints
Most order-dependent Vitest failures come down to three kinds of state, and each needs a different fix.
The mock that arrived after the module
"Declared after the import" is the usual explanation and it is mostly wrong for vi.mock. The API reference says the call "is hoisted, so it doesn't matter where you call it. It will always be executed before all imports." A vi.mock written below the import line at the top level works, and in a test run it did. What actually goes wrong is one of these:
import { quote } from '../src/quote' // already bound to the real ./rates
test('uses a fixed rate', async () => {
// wrong: doMock is not hoisted and only affects the NEXT import
vi.doMock('../src/rates', () => ({ fetchRate: async () => 2 }))
expect(await quote(10, 'EUR')).toBe(20) // received 10.8
})
test('uses a fixed rate, correctly', async () => {
vi.doMock('../src/rates', () => ({ fetchRate: async () => 2 }))
const { quote } = await import('../src/quote') // first import after the mock
expect(await quote(10, 'EUR')).toBe(20)
})
The second trap is a module that was loaded before the test file ran. The API reference warns that Vitest "will not mock modules that were imported inside a setup file because they are cached by the time a test file is running". Adding one import of quote to a setup file turned a passing hoisted vi.mock into the same 10.8 failure. The same happens across files under isolate: false, where an earlier file can be the one that loaded the real module. The third, a vi.mock inside a test or describe, is now a hard error in Vitest 5 that names the line:
Error: 1 call in "test/nested-mock.test.ts" was defined outside of the module's top level scope:
- vi.mock("../src/rates") at test/nested-mock.test.ts:5:3
The discriminating check: import the mocked dependency into the test and assert vi.isMockFunction(fetchRate) for a factory that returns vi.fn(). False means the module your code holds is the real one, which is a timing problem, and no amount of resetting mock implementations will change it. Remember too that a partial mock built with importOriginal still runs the real module: the modules guide describes it as a function "that will execute the original module". A partial mock of config.ts therefore still reads the environment at that moment.
Module-scope state outlives each case
Any const cache = new Map() or let token at the top of a module lives as long as the registry entry, which is the whole file:
// src/idempotency.ts
const processed = new Set<string>()
export function acceptOnce(key: string): boolean {
if (processed.has(key)) return false
processed.add(key)
return true
}
One test accepts order-1; a later test expecting a first-time accept of order-1 gets false. The check that separates this from the environment case: in the failing test only, call vi.resetModules() and import the module dynamically. If that alone turns it green while the environment is untouched, the state lives in module scope. It is a diagnostic, not a fix. The fix belongs in the module: export a createIdempotencyGuard() that returns a fresh set, let the application create one instance at startup, and let each test create its own.
A fake clock nobody put back
A test calls vi.useFakeTimers(), an assertion fails before its closing vi.useRealTimers(), and the next test that awaits a real setTimeout never resumes:
× test/timers.test.ts > waits for a real delay 506ms
→ Test timed out in 500ms.
The timeout is reported on the innocent test. The API reference says fake timers wrap timer calls "until vi.useRealTimers() is called", and none of the fakeTimers config options restores them between tests. Logging vi.isFakeTimers() at the start of the victim printed true, which settles it. Register the restore at the point of installation, with onTestFinished(() => vi.useRealTimers()) on the line after vi.useFakeTimers(). To make the next leak fail on the test that caused it, add a guard to a setup file:
// test/leak-guard.ts, listed in setupFiles
import { beforeEach, onTestFinished, vi } from 'vitest'
beforeEach(() => {
// registered before the test body runs, so it is called after the
// test's own onTestFinished callbacks, which run in reverse order
onTestFinished(() => {
if (vi.isFakeTimers()) {
vi.useRealTimers()
throw new Error('fake timers were still installed when this test finished')
}
})
})
The ordering is the subtle part. afterEach runs before any onTestFinished callback, so an afterEach guard would flag tests that restore their clock correctly in onTestFinished. Registered this way, a test that restores its own timers passed, the one that did not was marked failed with that message, and the test after it ran on real timers.
What the reset flags actually reset
Each global flag calls one vi function before every test, and each covers less than its name suggests:
clearMocks(defaulttruesince 5.0) clears call history and leaves implementations. In a test file where one case setmockResolvedValue(0), the next case saw zero calls and still got0back.mockResetclears history and resets implementations. For avi.fn(impl)that means back toimpl, a behaviour Vitest 3 introduced for spies; that same test passed with it on.restoreMocksonly restores spies created withvi.spyOn. Since Vitest 4 it no longer resets spy state and does not touch automocked modules at all.unstubEnvsandunstubGlobalsundo only what went throughvi.stubEnvandvi.stubGlobal. A raw assignment is invisible to them.
None of them evicts a module or restores the clock. With all four switched on and the environment test using vi.stubEnv but no eviction in its cleanup, seed 3 still failed exactly as before, and the verbose log still showed a single evaluation line. The flag restored the variable; config.ts kept the value it had read. Each of those options also carries the same documented warning for concurrent tests: the completion of one test resets state "including those currently being used by other tests in progress".
The broader fix people reach for next, beforeEach(() => vi.resetModules()) in a setup file, trades the leak for a different bug. The reference is explicit that "top-level imports cannot be re-evaluated", so after a reset the test file's static imports and anything imported dynamically come from different evaluations. Here a test imports an error class statically and the client dynamically:
AssertionError: expected error to be instance of PaymentDeclined
+ Received:
PaymentDeclined {
"message": "over limit",
}
A PaymentDeclined that is not an instance of PaymentDeclined: two copies of errors.ts, one per evaluation. Blanket resets also hide who leaked, because the cleanup runs at the start of the next test, and they leave the mock registry alone: the API reference notes vi.resetModules "does not reset mocks registry", so a registered mock stays registered until vi.unmock or vi.doUnmock. The suite goes green and the import-time read stays in the code for the next person to find.
Before you close the flaky-test ticket
Capture the failing seed and the verbose output of that run, then the same command on your fix. Pasting both outputs into the diff checker makes the moved or duplicated evaluation line obvious. That pair of logs is the evidence that the leak was named and closed, rather than shuffled somewhere else. An assistant can list every vi.mock, vi.doMock and top-level import in the file quickly, but only the reproduction can say which piece of state crossed from one test to the next.
Then add --sequence.shuffle to one CI job, keep the timer guard in your setup files, and run vitest doctor before changing pools or isolation. According to the CLI reference, doctor validates the isolate: false candidate "by running the suite twice with a shuffled file order" and reports it as failed if any test depends on isolation. That is the cheapest way to learn whether your suite has cross-file leaks before you turn isolation off.
Questions about order-dependent Vitest tests
Why does the failure appear only in CI?
Vitest sorts files using cached timings so long-running ones start first, and a CI runner with a cold cache and a different core count assigns files to workers differently. Under isolate: false or a vm pool, that changes which files share a worker. Test order inside one file does not vary unless shuffling is on, so a within-file leak seen only in CI points to shuffling or a setup file that differs there.
Should the reset flags be on at all?
As a backstop after each leak has been fixed where it started, yes; restoreMocks: true catches forgotten vi.spyOn calls. A green run with them on is not proof of a clean suite, because they cover mocks, spies and stubs only and run before the next test, so a failure never points at its source.
Does turning isolation off break vi.mock?
It can. The lifecycle guide says that with isolation disabled, setup files still rerun before each file "but imported modules are cached". A module that an earlier file loaded for real is already in the registry when a later file's hoisted vi.mock runs, which is the setup-file trap spread across files. If some files need hard isolation, the performance guide shows splitting them into a separate project with isolate: true.
Is vi.stubEnv safe in concurrent tests?
No. process.env belongs to the whole worker, so two concurrent tests stubbing the same variable see each other's values, and the unstubEnvs page warns that one test finishing restores values other tests are still using. Pass configuration in as an argument, as loadConfig(env) does, and keep environment stubbing to sequential tests.
Primary references
- Vitest — Vi utility API. That
vi.mock"is hoisted, so it doesn't matter where you call it" and runs before all imports; that modules imported in a setup file are not mocked "because they are cached"; thatvi.doMockis not hoisted and "the next dynamic import of the module will be mocked"; thatvi.resetModulesclears the cache of all modules, cannot re-evaluate top-level imports and "does not reset mocks registry";vi.stubEnvandvi.unstubAllEnvsrestoring only stubbed values;vi.restoreAllMocksaffecting onlyvi.spyOnspies and not automocks;vi.useFakeTimerslasting "untilvi.useRealTimers()is called", andvi.isFakeTimers;vi.hoistedconverting static imports to dynamic ones. - Vitest migration guide, Vitest 5.0 section.
clearMocksenabled by default; hoistedvi.mock,vi.unmockandvi.hoistedcalls outside the top level now throwing, with the error format quoted above;-tmatching the" > "-joined full name; the Node.js 22.12 minimum. - Vitest — Migrating to Vitest 4.0. That
vi.restoreAllMocks"no longer resets the state of spies and only restores spies created manually withvi.spyOn", that automocks are no longer affected, and that this applies to therestoreMocksoption. - Vitest — Migrating to Vitest 3.0. That
spy.mockResetresets the implementation to the original instead of a no-op. - Vitest — Improving Performance. Per-file isolation for each pool, and that with
isolate: false"files in the same worker share the environment and module state"; splitting isolated and non-isolated files into projects. - Vitest — Test Run Lifecycle. Workers reused when isolation is off or the pool is
vmThreadsorvmForks; setup files rerunning with cached modules under disabled isolation; the per-test order ofafterEach,beforeEachcleanups andonTestFinished. - Vitest — Hooks.
onTestFinishedrunning afterafterEach, always in reverse order, and the warning that cleanup at the end of a test body is skipped when an assertion fails. - Vitest — sequence, isolate and pool.
--sequence.shufflewith itsfilesandtestsvariants,--sequence.seeddefaulting toDate.now();isolatedefaulting totruewith no effect on the vm pools;forksas the default pool. - Vitest — clearMocks, mockReset, restoreMocks and unstubEnvs. The defaults, the
vifunction each calls before every test, and the shared warning about concurrent tests. - Vitest — fakeTimers. The full option list (
now,toFake,toNotFake,loopLimit,shouldAdvanceTime,advanceTimeDelta,shouldClearNativeTimers), none of which reinstalls real timers between tests. - Vitest — Test Filtering.
-tmatching the full name, and that filters are applied per file, so each file still runs to discover matching tests. - Vitest — Command Line Interface.
vitest doctorvalidatingisolate: falseby running the suite twice with shuffled file order. - Vitest — Mocking Modules. How the transform moves
vi.mockabove imports rewritten as dynamic imports, and thatimportOriginalexecutes the original module. - Node.js — process.env. That changes are not reflected in other worker threads unless requested, that assigning a property converts the value to a string (
undefinedbecomes'undefined'), and thatdeleteremoves a variable. - Vitest v5.0.2 release. The current release as of the check date, published 2026-09-25.
TypeScript Quality Engineering
TypeScript Quality Engineering
Get part 4: Test TypeScript Discriminated Unions as State Machines
It is live now: read part 4. The form below subscribes you to The Automation Brief, the site newsletter.
No spam, no sending cadence promised. Unsubscribe any time.