Skip to main content
Back to Blog
Automation TutorialsTypeScript Quality Engineering · Part 3

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.

Shashank Rawlani

Engineering Leader | Builder | Problem Solver | AI Engineer

Published Oct 6, 2026Updated Oct 14, 202616 min read
View AI-ready Markdown
Dark technical illustration of two dashed green rounded containers side by side across the middle of the frame, both hanging by short grey stems from a heavy grey rail that runs the full width near the bottom, with faint grey pills resting along it; fainter grey dashed containers bleed off the left and right edges. In the left container an orange pill feeds a curved orange arrow into a grey-outlined panel filled with four orange pills, and three small green dots with green pills sit below it. A green line runs from the bottom of that panel to a large ringed green dot on the container's right edge. From the panel's right side a dashed orange arc rises over the top of the gap between the containers and ends at a large crossed-out orange ring in the gap, with only a faint dashed trace continuing toward the right container. In the right container a green pill feeds a green arrow into a green-outlined panel filled with four green pills, with green dotted rows below; a green line leaves the panel's bottom, reaches a ringed green dot on the container's right edge and continues as a solid green line to an arrowhead at the right edge of the frame. Thin grey rules and faint pills sit at the very top and bottom over a faint dashed vertical grid.
Quick answer: A Vitest test that passes on its own and fails after a neighbour has inherited state, and inside one file the usual carrier is the module cache: every test in a file shares it, so a module that reads 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.
Vitest 5 changed two defaults this debugging depends on. 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.

ConfigurationModule state between filesprocess.env between files
forks or threads, isolate: true (default)Fresh per fileFresh per file
forks or threads, isolate: falseShared: files in one worker reuse the registryShared
vmThreads or vmForksFresh VM context per fileShared, 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 (default true since 5.0) clears call history and leaves implementations. In a test file where one case set mockResolvedValue(0), the next case saw zero calls and still got 0 back.
  • mockReset clears history and resets implementations. For a vi.fn(impl) that means back to impl, a behaviour Vitest 3 introduced for spies; that same test passed with it on.
  • restoreMocks only restores spies created with vi.spyOn. Since Vitest 4 it no longer resets spy state and does not touch automocked modules at all.
  • unstubEnvs and unstubGlobals undo only what went through vi.stubEnv and vi.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"; that vi.doMock is not hoisted and "the next dynamic import of the module will be mocked"; that vi.resetModules clears the cache of all modules, cannot re-evaluate top-level imports and "does not reset mocks registry"; vi.stubEnv and vi.unstubAllEnvs restoring only stubbed values; vi.restoreAllMocks affecting only vi.spyOn spies and not automocks; vi.useFakeTimers lasting "until vi.useRealTimers() is called", and vi.isFakeTimers; vi.hoisted converting static imports to dynamic ones.
  • Vitest migration guide, Vitest 5.0 section. clearMocks enabled by default; hoisted vi.mock, vi.unmock and vi.hoisted calls outside the top level now throwing, with the error format quoted above; -t matching 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 with vi.spyOn", that automocks are no longer affected, and that this applies to the restoreMocks option.
  • Vitest — Migrating to Vitest 3.0. That spy.mockReset resets 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 vmThreads or vmForks; setup files rerunning with cached modules under disabled isolation; the per-test order of afterEach, beforeEach cleanups and onTestFinished.
  • Vitest — Hooks. onTestFinished running after afterEach, 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.shuffle with its files and tests variants, --sequence.seed defaulting to Date.now(); isolate defaulting to true with no effect on the vm pools; forks as the default pool.
  • Vitest — clearMocks, mockReset, restoreMocks and unstubEnvs. The defaults, the vi function 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. -t matching the full name, and that filters are applied per file, so each file still runs to discover matching tests.
  • Vitest — Command Line Interface. vitest doctor validating isolate: false by running the suite twice with shuffled file order.
  • Vitest — Mocking Modules. How the transform moves vi.mock above imports rewritten as dynamic imports, and that importOriginal executes 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 (undefined becomes 'undefined'), and that delete removes a variable.
  • Vitest v5.0.2 release. The current release as of the check date, published 2026-09-25.

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.