# 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.

- Author: [Shashank Rawlani](https://shashank.rawlani.com)

- Published: 2026-10-06T13:30:00.000Z

- Updated: 2026-10-14T11:09:52.936Z

- Category: Automation Tutorials

- Canonical source: https://automationtester.in/blog/automation-tutorials/debug-vitest-module-mocking-cache-leaks

- Tags: vitest, typescript, mocking, test-isolation, flaky-tests, javascript-testing

**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](https://automationtester.in/tools/test-failure-triage#p=eyJ2IjoxLCJpbnB1dCI6IkZBSUwgIHRlc3QvY29uZmlnLnRlc3QudHMgPiByZWFkcyBQQVlNRU5UU19VUkwgZnJvbSB0aGUgZW52aXJvbm1lbnRcbkFzc2VydGlvbkVycm9yOiBleHBlY3RlZCAnaHR0cDovL2xvY2FsaG9zdDo0MDEwJyB0byBiZSAnaHR0cHM6Ly9zYW5kYm94LnBheW1lbnRzLnRlc3QnIC8vIE9iamVjdC5pcyBlcXVhbGl0eSJ9)

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](https://vitest.dev/config/sequence) 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](https://vitest.dev/guide/improving-performance#test-isolation) 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](https://vitest.dev/guide/lifecycle) 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](https://vitest.dev/api/vi#vi-hoisted) 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](https://vitest.dev/api/vi#vi-usefaketimers) 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](https://vitest.dev/api/vi#vi-resetmodules) 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](/tools/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](https://vitest.dev/api/vi). 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](https://vitest.dev/guide/migration). `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](https://v4.vitest.dev/guide/migration). 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](https://v3.vitest.dev/guide/migration). That `spy.mockReset` resets the implementation to the original instead of a no-op.

- [Vitest — Improving Performance](https://vitest.dev/guide/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](https://vitest.dev/guide/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](https://vitest.dev/api/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](https://vitest.dev/config/sequence), [isolate](https://vitest.dev/config/isolate) and [pool](https://vitest.dev/config/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](https://vitest.dev/config/clearmocks), [mockReset](https://vitest.dev/config/mockreset), [restoreMocks](https://vitest.dev/config/restoremocks) and [unstubEnvs](https://vitest.dev/config/unstubenvs). The defaults, the `vi` function each calls before every test, and the shared warning about concurrent tests.

- [Vitest — fakeTimers](https://vitest.dev/config/faketimers). The full option list (`now`, `toFake`, `toNotFake`, `loopLimit`, `shouldAdvanceTime`, `advanceTimeDelta`, `shouldClearNativeTimers`), none of which reinstalls real timers between tests.

- [Vitest — Test Filtering](https://vitest.dev/guide/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](https://vitest.dev/guide/cli). `vitest doctor` validating `isolate: false` by running the suite twice with shuffled file order.

- [Vitest — Mocking Modules](https://vitest.dev/guide/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](https://nodejs.org/api/process.html#processenv). 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](https://github.com/vitest-dev/vitest/releases/tag/v5.0.2). The current release as of the check date, published 2026-09-25.

## Continue reading on AutomationTester.in

- [Mock or Integrate? Playwright Network Testing Boundaries](https://automationtester.in/blog/automation-tutorials/playwright-network-mocking-integration-boundaries)
- [Build Deterministic Python API Test Data Factories](https://automationtester.in/blog/automation-tutorials/python-api-test-data-factories-deterministic)
- [Make JUnit Failures Useful in CI](https://automationtester.in/blog/automation-tutorials/junit-failure-reports-useful-ci)
- [Design Playwright Fixtures for Parallel Test Isolation](https://automationtester.in/blog/automation-tutorials/playwright-fixtures-parallel-test-isolation)
- [Debug JUnit Parallel-Execution Flakes](https://automationtester.in/blog/automation-tutorials/debug-junit-parallel-execution-flakes)

Source: [Debug Vitest Module Mocking and Cache Leaks](https://automationtester.in/blog/automation-tutorials/debug-vitest-module-mocking-cache-leaks) by Shashank Rawlani.
