{"title":"Debug Vitest Module Mocking and Cache Leaks","excerpt":"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.","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/debug-vitest-module-mocking-cache-leaks","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["vitest","typescript","mocking","test-isolation","flaky-tests","javascript-testing"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-10-06T13:30:00.000Z","dateModified":"2026-10-14T11:09:52.936Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/debug-vitest-module-mocking-cache-leaks.webp","alt":"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."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> 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 <code class=\"language-text\">process.env</code> at import time keeps whatever value was set when it was first evaluated. Reproduce the failing order with a fixed <code class=\"language-text\">--sequence.seed</code>, log when the suspect module evaluates, then fix it in the test that changed the state: <code class=\"language-text\">vi.stubEnv</code> before a dynamic import, <code class=\"language-text\">vi.resetModules</code> scoped to that test, and an <code class=\"language-text\">onTestFinished</code> that restores the environment and evicts the module it evaluated. Global reset flags do not evict modules, so they leave this leak in place.</div>\n\n<div class=\"callout callout-warning\"><strong>Vitest 5 changed two defaults this debugging depends on.</strong> <code class=\"language-text\">clearMocks</code> is now <code class=\"language-text\">true</code>, so call history no longer carries between tests but mock implementations still do. And <code class=\"language-text\">vi.mock</code>, <code class=\"language-text\">vi.unmock</code> and <code class=\"language-text\">vi.hoisted</code> written inside a function or a <code class=\"language-text\">test</code> 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.</div>\n\n<p>You run the file and one test fails. You run that test with <code class=\"language-text\">-t</code> and it passes. Nothing in the failing test changed; something that ran before it did. The reflex is to add <code class=\"language-text\">vi.resetModules()</code> 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.</p>\n\n<p>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.</p>\n\n<h2 id=\"a-failure-that-needs-a-predecessor\">A failure that needs a predecessor</h2>\n\n<p>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:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// src/config.ts\nconsole.log(`[config] evaluated, PAYMENTS_URL=${process.env.PAYMENTS_URL}`)\n\nexport const config = Object.freeze({\n  baseUrl: process.env.PAYMENTS_URL ?? 'http://localhost:4010',\n  timeoutMs: Number(process.env.PAYMENTS_TIMEOUT_MS ?? '5000'),\n})</code></pre>\n\n<p>Two tests import it dynamically, so each looks like it gets its own copy:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// test/config.test.ts\nimport { expect, test } from 'vitest'\n\ntest('falls back to the local stub', async () =&gt; {\n  const { config } = await import('../src/config')\n  expect(config.baseUrl).toBe('http://localhost:4010')\n})\n\ntest('reads PAYMENTS_URL from the environment', async () =&gt; {\n  process.env.PAYMENTS_URL = 'https://sandbox.payments.test'\n  const { config } = await import('../src/config')\n  expect(config.baseUrl).toBe('https://sandbox.payments.test')\n})</code></pre>\n\n<p>Filtered to the second test, it passes. The whole file fails it:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">FAIL  test/config.test.ts &gt; reads PAYMENTS_URL from the environment\nAssertionError: expected 'http://localhost:4010' to be 'https://sandbox.payments.test' // Object.is equality</code></pre>\n<a href=\"https://automationtester.in/tools/test-failure-triage#p=eyJ2IjoxLCJpbnB1dCI6IkZBSUwgIHRlc3QvY29uZmlnLnRlc3QudHMgPiByZWFkcyBQQVlNRU5UU19VUkwgZnJvbSB0aGUgZW52aXJvbm1lbnRcbkFzc2VydGlvbkVycm9yOiBleHBlY3RlZCAnaHR0cDovL2xvY2FsaG9zdDo0MDEwJyB0byBiZSAnaHR0cHM6Ly9zYW5kYm94LnBheW1lbnRzLnRlc3QnIC8vIE9iamVjdC5pcyBlcXVhbGl0eSJ9\">Try this in Test Failure Triage</a>\n\n<p>Now shuffle the tests and the failure moves. With <code class=\"language-text\">--sequence.shuffle.tests --sequence.seed=3</code> 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.</p>\n\n<p>Pin whichever order fails before touching anything. The <a href=\"https://vitest.dev/config/sequence\">sequence options</a> accept a seed on the command line, the default seed is <code class=\"language-text\">Date.now()</code>, and a shuffled run prints the seed it used (<code class=\"language-text\">Running tests with seed \"3\"</code>) in its header, so a CI failure from a shuffled run can be replayed locally:</p>\n\n<pre class=\"language-bash\"><code class=\"language-bash\"># the victim alone: passes if the failure needs a predecessor\nnpx vitest run test/config.test.ts -t \"reads PAYMENTS_URL\"\n\n# the whole file in a fixed shuffled order, with per-test stdout\nnpx vitest run test/config.test.ts --sequence.shuffle.tests --sequence.seed=3 --reporter=verbose</code></pre>\n\n<p>Two details of filtering matter here. In Vitest 5, <code class=\"language-text\">-t</code> matches the full name with suites joined by <code class=\"language-text\">\" &gt; \"</code>, so a pattern like <code class=\"language-text\">\"payments reads\"</code> that spanned a describe boundary in Vitest 4 now needs to be <code class=\"language-text\">\"payments &gt; reads\"</code>. And a filtered run still executes the file's top-level code; only the other tests' bodies are skipped.</p>\n\n<h2 id=\"where-the-stale-value-lives\">Where the stale value lives</h2>\n\n<p>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:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">stdout | test/config.test.ts &gt; reads PAYMENTS_URL from the environment\n[config] evaluated, PAYMENTS_URL=https://sandbox.payments.test\n\n ✓ test/config.test.ts &gt; reads PAYMENTS_URL from the environment 3ms\n × test/config.test.ts &gt; falls back to the local stub 3ms</code></pre>\n\n<p>One evaluation line for two imports. The second <code class=\"language-text\">await import()</code> 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.</p>\n\n<p>Across files it is different. By default the <a href=\"https://vitest.dev/guide/improving-performance#test-isolation\">Test Isolation guide</a> says Vitest \"runs every test file in an isolated environment based on the pool\": the default <code class=\"language-text\">forks</code> pool gives each file its own child process, <code class=\"language-text\">threads</code> its own worker, and the vm pools a separate VM context. A failure that follows <em>another file</em> therefore usually means someone turned isolation off, or that the leaked state lives somewhere the per-file boundary does not reach.</p>\n\n<table>\n<thead><tr><th>Configuration</th><th>Module state between files</th><th>process.env between files</th></tr></thead>\n<tbody>\n<tr><td><code class=\"language-text\">forks</code> or <code class=\"language-text\">threads</code>, <code class=\"language-text\">isolate: true</code> (default)</td><td>Fresh per file</td><td>Fresh per file</td></tr>\n<tr><td><code class=\"language-text\">forks</code> or <code class=\"language-text\">threads</code>, <code class=\"language-text\">isolate: false</code></td><td>Shared: files in one worker reuse the registry</td><td>Shared</td></tr>\n<tr><td><code class=\"language-text\">vmThreads</code> or <code class=\"language-text\">vmForks</code></td><td>Fresh VM context per file</td><td>Shared, even with isolation on</td></tr>\n</tbody>\n</table>\n\n<p>The last row is the one people do not expect. The <a href=\"https://vitest.dev/guide/lifecycle\">lifecycle guide</a> says workers are reused when isolation is disabled \"OR pool is <code class=\"language-text\">vmThreads</code> or <code class=\"language-text\">vmForks</code>\", and Node documents that <code class=\"language-text\">process.env</code> changes stay inside the process or worker thread that made them. Put together, a raw assignment to <code class=\"language-text\">process.env</code> 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 <code class=\"language-text\">--maxWorkers=1</code> and the setter first, the second file passed under <code class=\"language-text\">forks</code> and <code class=\"language-text\">threads</code> and failed under both vm pools with isolation left on. If you moved to a vm pool for speed, look here first.</p>\n\n<h2 id=\"clean-up-in-the-test-that-made-the-mess\">Clean up in the test that made the mess</h2>\n\n<p>The fix for the reproduction touches only the test that mutates something. The fallback test is left alone, because it did nothing wrong:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">import { expect, onTestFinished, test, vi } from 'vitest'\n\ntest('falls back to the local stub', async () =&gt; {\n  const { config } = await import('../src/config')\n  expect(config.baseUrl).toBe('http://localhost:4010')\n})\n\ntest('reads PAYMENTS_URL from the environment', async () =&gt; {\n  onTestFinished(() =&gt; {\n    vi.unstubAllEnvs()  // put process.env back\n    vi.resetModules()   // drop the copy of config evaluated under the stub\n  })\n  vi.stubEnv('PAYMENTS_URL', 'https://sandbox.payments.test')\n  vi.resetModules()     // ignore any copy evaluated before the stub\n\n  const { config } = await import('../src/config')\n  expect(config.baseUrl).toBe('https://sandbox.payments.test')\n})</code></pre>\n\n<p>That version passed under all eight shuffled seeds tried, and the verbose log now shows two evaluation lines, one per environment.</p>\n\n<p><code class=\"language-text\">vi.stubEnv</code> records the original value on first use, and <code class=\"language-text\">vi.unstubAllEnvs</code> restores \"all <code class=\"language-text\">import.meta.env</code> and <code class=\"language-text\">process.env</code> values that were changed with <code class=\"language-text\">vi.stubEnv</code>\". 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 <code class=\"language-text\">undefined</code>, and Node converts anything assigned to <code class=\"language-text\">process.env</code> to a string. The restore leaves the literal text <code class=\"language-text\">'undefined'</code> behind, which is truthy and will be read as a URL by the next module that checks it:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">AssertionError: expected 'undefined' to be undefined</code></pre>\n\n<p>Node's own documentation shows the conversion and says to use <code class=\"language-text\">delete</code> instead. <code class=\"language-text\">vi.stubEnv(name, undefined)</code> removes the variable, which is the behaviour you wanted.</p>\n\n<p>The first <code class=\"language-text\">vi.resetModules()</code> matters when this test runs second: without it, the dynamic import returns the fallback test's copy. The one inside <code class=\"language-text\">onTestFinished</code> matters when it runs first: it evicts the sandbox-evaluated copy so the next test evaluates its own. <code class=\"language-text\">onTestFinished</code> 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.</p>\n\n<p>There is a cost. <code class=\"language-text\">vi.resetModules</code> 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 <code class=\"language-text\">vi</code> 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:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// src/config.ts\nexport function loadConfig(env: NodeJS.ProcessEnv = process.env) {\n  return Object.freeze({\n    baseUrl: env.PAYMENTS_URL ?? 'http://localhost:4010',\n    timeoutMs: Number(env.PAYMENTS_TIMEOUT_MS ?? '5000'),\n  })\n}\n\nexport const config = loadConfig()\n\n// test: no module reset, no environment mutation\nexpect(loadConfig({ PAYMENTS_URL: 'https://sandbox.payments.test' }).baseUrl)\n  .toBe('https://sandbox.payments.test')</code></pre>\n\n<p>For a file where every test needs the same environment, <code class=\"language-text\">vi.hoisted(() =&gt; { vi.stubEnv('PAYMENTS_URL', '…') })</code> above a plain static import also works. The <a href=\"https://vitest.dev/api/vi#vi-hoisted\"><code class=\"language-text\">vi.hoisted</code> reference</a> explains why: Vitest rewrites the file's static imports into dynamic ones, so the hoisted callback runs before <code class=\"language-text\">config.ts</code> is evaluated.</p>\n\n<h2 id=\"three-leaks-with-different-fingerprints\">Three leaks with different fingerprints</h2>\n\n<p>Most order-dependent Vitest failures come down to three kinds of state, and each needs a different fix.</p>\n\n<h3 id=\"the-mock-that-arrived-after-the-module\">The mock that arrived after the module</h3>\n\n<p>\"Declared after the import\" is the usual explanation and it is mostly wrong for <code class=\"language-text\">vi.mock</code>. 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 <code class=\"language-text\">vi.mock</code> 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:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">import { quote } from '../src/quote'   // already bound to the real ./rates\n\ntest('uses a fixed rate', async () =&gt; {\n  // wrong: doMock is not hoisted and only affects the NEXT import\n  vi.doMock('../src/rates', () =&gt; ({ fetchRate: async () =&gt; 2 }))\n  expect(await quote(10, 'EUR')).toBe(20)   // received 10.8\n})\n\ntest('uses a fixed rate, correctly', async () =&gt; {\n  vi.doMock('../src/rates', () =&gt; ({ fetchRate: async () =&gt; 2 }))\n  const { quote } = await import('../src/quote')   // first import after the mock\n  expect(await quote(10, 'EUR')).toBe(20)\n})</code></pre>\n\n<p>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 <code class=\"language-text\">quote</code> to a setup file turned a passing hoisted <code class=\"language-text\">vi.mock</code> into the same <code class=\"language-text\">10.8</code> failure. The same happens across files under <code class=\"language-text\">isolate: false</code>, where an earlier file can be the one that loaded the real module. The third, a <code class=\"language-text\">vi.mock</code> inside a <code class=\"language-text\">test</code> or <code class=\"language-text\">describe</code>, is now a hard error in Vitest 5 that names the line:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">Error: 1 call in \"test/nested-mock.test.ts\" was defined outside of the module's top level scope:\n\n- vi.mock(\"../src/rates\") at test/nested-mock.test.ts:5:3</code></pre>\n\n<p>The discriminating check: import the mocked dependency into the test and assert <code class=\"language-text\">vi.isMockFunction(fetchRate)</code> for a factory that returns <code class=\"language-text\">vi.fn()</code>. 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 <code class=\"language-text\">importOriginal</code> still runs the real module: the modules guide describes it as a function \"that will execute the original module\". A partial mock of <code class=\"language-text\">config.ts</code> therefore still reads the environment at that moment.</p>\n\n<h3 id=\"module-scope-state-outlives-each-case\">Module-scope state outlives each case</h3>\n\n<p>Any <code class=\"language-text\">const cache = new Map()</code> or <code class=\"language-text\">let token</code> at the top of a module lives as long as the registry entry, which is the whole file:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// src/idempotency.ts\nconst processed = new Set&lt;string&gt;()\n\nexport function acceptOnce(key: string): boolean {\n  if (processed.has(key)) return false\n  processed.add(key)\n  return true\n}</code></pre>\n\n<p>One test accepts <code class=\"language-text\">order-1</code>; a later test expecting a first-time accept of <code class=\"language-text\">order-1</code> gets <code class=\"language-text\">false</code>. The check that separates this from the environment case: in the failing test only, call <code class=\"language-text\">vi.resetModules()</code> 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 <code class=\"language-text\">createIdempotencyGuard()</code> that returns a fresh set, let the application create one instance at startup, and let each test create its own.</p>\n\n<h3 id=\"a-fake-clock-nobody-put-back\">A fake clock nobody put back</h3>\n\n<p>A test calls <code class=\"language-text\">vi.useFakeTimers()</code>, an assertion fails before its closing <code class=\"language-text\">vi.useRealTimers()</code>, and the next test that awaits a real <code class=\"language-text\">setTimeout</code> never resumes:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">× test/timers.test.ts &gt; waits for a real delay 506ms\n  → Test timed out in 500ms.</code></pre>\n\n<p>The timeout is reported on the innocent test. The <a href=\"https://vitest.dev/api/vi#vi-usefaketimers\">API reference</a> says fake timers wrap timer calls \"until <code class=\"language-text\">vi.useRealTimers()</code> is called\", and none of the <code class=\"language-text\">fakeTimers</code> config options restores them between tests. Logging <code class=\"language-text\">vi.isFakeTimers()</code> at the start of the victim printed <code class=\"language-text\">true</code>, which settles it. Register the restore at the point of installation, with <code class=\"language-text\">onTestFinished(() =&gt; vi.useRealTimers())</code> on the line after <code class=\"language-text\">vi.useFakeTimers()</code>. To make the next leak fail on the test that caused it, add a guard to a setup file:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// test/leak-guard.ts, listed in setupFiles\nimport { beforeEach, onTestFinished, vi } from 'vitest'\n\nbeforeEach(() =&gt; {\n  // registered before the test body runs, so it is called after the\n  // test's own onTestFinished callbacks, which run in reverse order\n  onTestFinished(() =&gt; {\n    if (vi.isFakeTimers()) {\n      vi.useRealTimers()\n      throw new Error('fake timers were still installed when this test finished')\n    }\n  })\n})</code></pre>\n\n<p>The ordering is the subtle part. <code class=\"language-text\">afterEach</code> runs before any <code class=\"language-text\">onTestFinished</code> callback, so an <code class=\"language-text\">afterEach</code> guard would flag tests that restore their clock correctly in <code class=\"language-text\">onTestFinished</code>. 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.</p>\n\n<h2 id=\"what-the-reset-flags-actually-reset\">What the reset flags actually reset</h2>\n\n<p>Each global flag calls one <code class=\"language-text\">vi</code> function before every test, and each covers less than its name suggests:</p>\n\n<ul>\n<li><code class=\"language-text\">clearMocks</code> (default <code class=\"language-text\">true</code> since 5.0) clears call history and leaves implementations. In a test file where one case set <code class=\"language-text\">mockResolvedValue(0)</code>, the next case saw zero calls and still got <code class=\"language-text\">0</code> back.</li>\n<li><code class=\"language-text\">mockReset</code> clears history and resets implementations. For a <code class=\"language-text\">vi.fn(impl)</code> that means back to <code class=\"language-text\">impl</code>, a behaviour Vitest 3 introduced for spies; that same test passed with it on.</li>\n<li><code class=\"language-text\">restoreMocks</code> only restores spies created with <code class=\"language-text\">vi.spyOn</code>. Since Vitest 4 it no longer resets spy state and does not touch automocked modules at all.</li>\n<li><code class=\"language-text\">unstubEnvs</code> and <code class=\"language-text\">unstubGlobals</code> undo only what went through <code class=\"language-text\">vi.stubEnv</code> and <code class=\"language-text\">vi.stubGlobal</code>. A raw assignment is invisible to them.</li>\n</ul>\n\n<p>None of them evicts a module or restores the clock. With all four switched on and the environment test using <code class=\"language-text\">vi.stubEnv</code> 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; <code class=\"language-text\">config.ts</code> 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\".</p>\n\n<p>The broader fix people reach for next, <code class=\"language-text\">beforeEach(() =&gt; vi.resetModules())</code> in a setup file, trades the leak for a different bug. The <a href=\"https://vitest.dev/api/vi#vi-resetmodules\">reference</a> 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:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">AssertionError: expected error to be instance of PaymentDeclined\n\n+ Received:\nPaymentDeclined {\n  \"message\": \"over limit\",\n}</code></pre>\n\n<p>A <code class=\"language-text\">PaymentDeclined</code> that is not an instance of <code class=\"language-text\">PaymentDeclined</code>: two copies of <code class=\"language-text\">errors.ts</code>, one per evaluation. Blanket resets also hide who leaked, because the cleanup runs at the start of the <em>next</em> test, and they leave the mock registry alone: the API reference notes <code class=\"language-text\">vi.resetModules</code> \"does not reset mocks registry\", so a registered mock stays registered until <code class=\"language-text\">vi.unmock</code> or <code class=\"language-text\">vi.doUnmock</code>. The suite goes green and the import-time read stays in the code for the next person to find.</p>\n\n<h2 id=\"before-you-close-the-flaky-test-ticket\">Before you close the flaky-test ticket</h2>\n\n<p>Capture the failing seed and the verbose output of that run, then the same command on your fix. Pasting both outputs into the <a href=\"/tools/diff-checker\">diff checker</a> 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 <code class=\"language-text\">vi.mock</code>, <code class=\"language-text\">vi.doMock</code> 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.</p>\n\n<p>Then add <code class=\"language-text\">--sequence.shuffle</code> to one CI job, keep the timer guard in your setup files, and run <code class=\"language-text\">vitest doctor</code> before changing pools or isolation. According to the CLI reference, doctor validates the <code class=\"language-text\">isolate: false</code> 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.</p>\n\n<h2 id=\"questions-about-vitest-order-dependence\">Questions about order-dependent Vitest tests</h2>\n\n<h3 id=\"why-does-the-failure-only-happen-in-ci\">Why does the failure appear only in CI?</h3>\n\n<p>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 <code class=\"language-text\">isolate: false</code> 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.</p>\n\n<h3 id=\"should-restoremocks-be-on-by-default\">Should the reset flags be on at all?</h3>\n\n<p>As a backstop after each leak has been fixed where it started, yes; <code class=\"language-text\">restoreMocks: true</code> catches forgotten <code class=\"language-text\">vi.spyOn</code> 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.</p>\n\n<h3 id=\"does-isolate-false-break-vi-mock\">Does turning isolation off break <code class=\"language-text\">vi.mock</code>?</h3>\n\n<p>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 <code class=\"language-text\">vi.mock</code> 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 <code class=\"language-text\">isolate: true</code>.</p>\n\n<h3 id=\"is-vi-stubenv-safe-with-concurrent-tests\">Is <code class=\"language-text\">vi.stubEnv</code> safe in concurrent tests?</h3>\n\n<p>No. <code class=\"language-text\">process.env</code> belongs to the whole worker, so two concurrent tests stubbing the same variable see each other's values, and the <code class=\"language-text\">unstubEnvs</code> page warns that one test finishing restores values other tests are still using. Pass configuration in as an argument, as <code class=\"language-text\">loadConfig(env)</code> does, and keep environment stubbing to sequential tests.</p>\n\n<h2 id=\"vitest-module-state-references\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://vitest.dev/api/vi\">Vitest — Vi utility API</a>. That <code class=\"language-text\">vi.mock</code> \"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 <code class=\"language-text\">vi.doMock</code> is not hoisted and \"the next dynamic import of the module will be mocked\"; that <code class=\"language-text\">vi.resetModules</code> clears the cache of all modules, cannot re-evaluate top-level imports and \"does not reset mocks registry\"; <code class=\"language-text\">vi.stubEnv</code> and <code class=\"language-text\">vi.unstubAllEnvs</code> restoring only stubbed values; <code class=\"language-text\">vi.restoreAllMocks</code> affecting only <code class=\"language-text\">vi.spyOn</code> spies and not automocks; <code class=\"language-text\">vi.useFakeTimers</code> lasting \"until <code class=\"language-text\">vi.useRealTimers()</code> is called\", and <code class=\"language-text\">vi.isFakeTimers</code>; <code class=\"language-text\">vi.hoisted</code> converting static imports to dynamic ones.</li>\n<li><a href=\"https://vitest.dev/guide/migration\">Vitest migration guide, Vitest 5.0 section</a>. <code class=\"language-text\">clearMocks</code> enabled by default; hoisted <code class=\"language-text\">vi.mock</code>, <code class=\"language-text\">vi.unmock</code> and <code class=\"language-text\">vi.hoisted</code> calls outside the top level now throwing, with the error format quoted above; <code class=\"language-text\">-t</code> matching the <code class=\"language-text\">\" &gt; \"</code>-joined full name; the Node.js 22.12 minimum.</li>\n<li><a href=\"https://v4.vitest.dev/guide/migration\">Vitest — Migrating to Vitest 4.0</a>. That <code class=\"language-text\">vi.restoreAllMocks</code> \"no longer resets the state of spies and only restores spies created manually with <code class=\"language-text\">vi.spyOn</code>\", that automocks are no longer affected, and that this applies to the <code class=\"language-text\">restoreMocks</code> option.</li>\n<li><a href=\"https://v3.vitest.dev/guide/migration\">Vitest — Migrating to Vitest 3.0</a>. That <code class=\"language-text\">spy.mockReset</code> resets the implementation to the original instead of a no-op.</li>\n<li><a href=\"https://vitest.dev/guide/improving-performance\">Vitest — Improving Performance</a>. Per-file isolation for each pool, and that with <code class=\"language-text\">isolate: false</code> \"files in the same worker share the environment and module state\"; splitting isolated and non-isolated files into projects.</li>\n<li><a href=\"https://vitest.dev/guide/lifecycle\">Vitest — Test Run Lifecycle</a>. Workers reused when isolation is off or the pool is <code class=\"language-text\">vmThreads</code> or <code class=\"language-text\">vmForks</code>; setup files rerunning with cached modules under disabled isolation; the per-test order of <code class=\"language-text\">afterEach</code>, <code class=\"language-text\">beforeEach</code> cleanups and <code class=\"language-text\">onTestFinished</code>.</li>\n<li><a href=\"https://vitest.dev/api/hooks\">Vitest — Hooks</a>. <code class=\"language-text\">onTestFinished</code> running after <code class=\"language-text\">afterEach</code>, always in reverse order, and the warning that cleanup at the end of a test body is skipped when an assertion fails.</li>\n<li><a href=\"https://vitest.dev/config/sequence\">Vitest — sequence</a>, <a href=\"https://vitest.dev/config/isolate\">isolate</a> and <a href=\"https://vitest.dev/config/pool\">pool</a>. <code class=\"language-text\">--sequence.shuffle</code> with its <code class=\"language-text\">files</code> and <code class=\"language-text\">tests</code> variants, <code class=\"language-text\">--sequence.seed</code> defaulting to <code class=\"language-text\">Date.now()</code>; <code class=\"language-text\">isolate</code> defaulting to <code class=\"language-text\">true</code> with no effect on the vm pools; <code class=\"language-text\">forks</code> as the default pool.</li>\n<li><a href=\"https://vitest.dev/config/clearmocks\">Vitest — clearMocks</a>, <a href=\"https://vitest.dev/config/mockreset\">mockReset</a>, <a href=\"https://vitest.dev/config/restoremocks\">restoreMocks</a> and <a href=\"https://vitest.dev/config/unstubenvs\">unstubEnvs</a>. The defaults, the <code class=\"language-text\">vi</code> function each calls before every test, and the shared warning about concurrent tests.</li>\n<li><a href=\"https://vitest.dev/config/faketimers\">Vitest — fakeTimers</a>. The full option list (<code class=\"language-text\">now</code>, <code class=\"language-text\">toFake</code>, <code class=\"language-text\">toNotFake</code>, <code class=\"language-text\">loopLimit</code>, <code class=\"language-text\">shouldAdvanceTime</code>, <code class=\"language-text\">advanceTimeDelta</code>, <code class=\"language-text\">shouldClearNativeTimers</code>), none of which reinstalls real timers between tests.</li>\n<li><a href=\"https://vitest.dev/guide/filtering\">Vitest — Test Filtering</a>. <code class=\"language-text\">-t</code> matching the full name, and that filters are applied per file, so each file still runs to discover matching tests.</li>\n<li><a href=\"https://vitest.dev/guide/cli\">Vitest — Command Line Interface</a>. <code class=\"language-text\">vitest doctor</code> validating <code class=\"language-text\">isolate: false</code> by running the suite twice with shuffled file order.</li>\n<li><a href=\"https://vitest.dev/guide/mocking/modules\">Vitest — Mocking Modules</a>. How the transform moves <code class=\"language-text\">vi.mock</code> above imports rewritten as dynamic imports, and that <code class=\"language-text\">importOriginal</code> executes the original module.</li>\n<li><a href=\"https://nodejs.org/api/process.html#processenv\">Node.js — process.env</a>. That changes are not reflected in other worker threads unless requested, that assigning a property converts the value to a string (<code class=\"language-text\">undefined</code> becomes <code class=\"language-text\">'undefined'</code>), and that <code class=\"language-text\">delete</code> removes a variable.</li>\n<li><a href=\"https://github.com/vitest-dev/vitest/releases/tag/v5.0.2\">Vitest v5.0.2 release</a>. The current release as of the check date, published 2026-09-25.</li>\n</ul>\n"}