Describe the bug
When two vi.doMock calls target the same module path and are both still queued when the next dynamic import happens — e.g. a beforeEach registers a default factory and the test body overrides it before any import — the factory that ends up registered is nondeterministic. Intermittently the earlier doMock's factory wins, so the import observes the wrong mock. On my machine this hits ~4–5% of samples (see stats below); in the real suite where I found it, it was a ~5–10% flake.
Mechanism
(packages/vitest/src/runtime/moduleRunner/bareModuleMocker.ts, shipped as vitest/dist/chunks/startVitestModuleRunner.*.js in 4.1.5 and 4.1.9)
vi.doMock → queueMock() pushes onto the static pendingIds queue, with no dedupe of same-path entries. The next dynamic import flushes the queue via resolveMocks():
async resolveMocks() {
if (!BareModuleMocker.pendingIds.length) return;
const resolveMock = async (mock) => {
const { id, url, external } = await this.resolveId(mock.id, mock.importer);
if (mock.action === "unmock") this.unmockPath(id);
if (mock.action === "mock") this.mockPath(mock.id, id, url, external, mock.type, mock.factory);
};
// group consecutive mocks of the same action type together,
// resolve in parallel inside each group, but run groups sequentially
// to preserve mock/unmock ordering
const groups = groupByConsecutiveAction(BareModuleMocker.pendingIds);
for (const group of groups) await Promise.all(group.map(resolveMock));
BareModuleMocker.pendingIds = [];
}
The grouping preserves ordering between mock/unmock groups, but within a group each entry registers its factory (mockPath() → registry.register(), which overwrites the previous registration for that id) as soon as its own async resolveId round-trip completes. So for two entries with the same path, the registered factory is decided by RPC completion order, not call order. Usually the RPCs complete in FIFO order and everything looks fine; under CPU load the first entry's resolveId intermittently completes after the second's, and the earlier factory wins.
Proof of inversion
Patching globalThis.__vitest_mocker__.resolveId at the top of the test file to log start/completion order shows, on failing tests only, entry #2 completing before entry #1:
stdout | diag.test.js > diag 2/100
resolveId #1 start: ./dep.js ← beforeEach doMock (factory → { value: 2 })
resolveId #2 start: ./dep.js ← test-body doMock (factory → { value: 3 })
resolveId #2 done: ./dep.js ← test-body factory registers first
resolveId #1 done: ./dep.js ← beforeEach factory registers last and wins
AssertionError: expected 2 to be 3
Related but distinct: #2870 (closed 2023) established that a later doMock for the same path must override an earlier one — that bug was deterministic (module cache across tests); this one is a race inside a single flush. The browser-mode registration issues (#7912, #8052) are a different mechanism.
Suggested fix directions
- Dedupe same-id entries within a group, keeping the last one (last-doMock-wins by construction), or
Promise.all only the resolveId calls, then apply mockPath()/unmockPath() sequentially in queue order, so registration order is decoupled from RPC completion order.
Workaround
Register a single doMock factory that closes over a mutable variable, and mutate the variable instead of calling doMock again for the same path:
let depValue = 2;
beforeEach(() => {
vi.resetModules();
depValue = 2;
vi.doMock("./dep.js", () => ({ value: depValue }));
});
test("override", async () => {
depValue = 3; // instead of a second vi.doMock("./dep.js", ...)
const mod = await import("./dep.js");
expect(mod.value).toBe(3);
});
(This is how I fixed the real-world occurrence, an auth-guard suite flake: majed-afk/athar#417 — private repo, linked for provenance.)
Reproduction
Fully inline — three small files:
// package.json
{
"name": "vitest-domock-same-path-race",
"private": true,
"type": "module",
"devDependencies": { "vitest": "4.1.5" }
}
// dep.js
export const value = 1;
// direct.test.js
import { beforeEach, expect, test, vi } from "vitest";
const SAMPLES = 100;
beforeEach(() => {
vi.resetModules();
vi.doMock("./dep.js", () => ({ value: 2 }));
});
for (let i = 1; i <= SAMPLES; i++) {
test(`direct import: last doMock wins (${i}/${SAMPLES})`, async () => {
vi.doMock("./dep.js", () => ({ value: 3 }));
const mod = await import("./dep.js");
expect(mod.value).toBe(3);
});
}
npm i
npx vitest run --pool=forks # several of the 100 samples usually fail already on this machine
# to amplify / reproduce reliably, add CPU load (one busy loop per core, self-terminating)
# and run vitest in a loop:
for i in $(seq 1 "$(sysctl -n hw.ncpu)"); do
node -e 'const e=Date.now()+15*60e3;let x=0;for(;;){if((++x&0xfffff)===0&&Date.now()>e)break;}' &
done
for i in $(seq 1 200); do
npx vitest run --pool=forks >/dev/null 2>&1 || echo "run $i had failures"
done
The same race reproduces when the import goes through a consumer module instead of importing the mocked path directly (consumer.js doing import { value } from "./dep.js" and the test doing await import("./consumer.js")) — rates below include both shapes.
Observed failure rates (Apple M4, 10 cores, macOS, Node 22.22.1)
| vitest |
pool |
condition |
failed samples |
| 4.1.5 |
forks |
200 runs × 200 samples, full-core CPU load |
1,764 / 40,000 (4.4%) — all 200 runs had ≥1 failure |
| 4.1.9 (latest) |
forks |
200 runs × 200 samples, concurrent load |
1,836 / 40,000 (4.6%) — all 200 runs had ≥1 failure |
| 4.1.5 |
forks |
idle machine, single runs |
9–12 / 200 per run |
| 4.1.5 |
threads |
single run under load |
1 / 100 |
(In the 200-samples-per-run rows, each vitest run executed both shapes: 100 direct-import + 100 via-consumer samples; failures occurred in both.)
Failures are always expected 2 to be 3 — the beforeEach factory winning — never the unmocked value 1, consistent with the registration-order race rather than mocks not applying.
System Info
System:
OS: macOS 26.5.1
CPU: (10) arm64 Apple M4
Memory: 292.61 MB / 16.00 GB
Shell: 5.9 - /bin/zsh
Binaries:
Node: 22.22.1 - ~/.nvm/versions/node/v22.22.1/bin/node
npm: 10.9.4 - ~/.nvm/versions/node/v22.22.1/bin/npm
npmPackages:
vitest: 4.1.5 => 4.1.5
Also reproduced with vitest: 4.1.9 (same machine, same rates — table above).
Used Package Manager
npm
Validations
Describe the bug
When two
vi.doMockcalls target the same module path and are both still queued when the next dynamic import happens — e.g. abeforeEachregisters a default factory and the test body overrides it before any import — the factory that ends up registered is nondeterministic. Intermittently the earlierdoMock's factory wins, so the import observes the wrong mock. On my machine this hits ~4–5% of samples (see stats below); in the real suite where I found it, it was a ~5–10% flake.Mechanism
(
packages/vitest/src/runtime/moduleRunner/bareModuleMocker.ts, shipped asvitest/dist/chunks/startVitestModuleRunner.*.jsin 4.1.5 and 4.1.9)vi.doMock→queueMock()pushes onto the staticpendingIdsqueue, with no dedupe of same-path entries. The next dynamic import flushes the queue viaresolveMocks():The grouping preserves ordering between
mock/unmockgroups, but within a group each entry registers its factory (mockPath()→registry.register(), which overwrites the previous registration for that id) as soon as its own asyncresolveIdround-trip completes. So for two entries with the same path, the registered factory is decided by RPC completion order, not call order. Usually the RPCs complete in FIFO order and everything looks fine; under CPU load the first entry'sresolveIdintermittently completes after the second's, and the earlier factory wins.Proof of inversion
Patching
globalThis.__vitest_mocker__.resolveIdat the top of the test file to log start/completion order shows, on failing tests only, entry #2 completing before entry #1:Related but distinct: #2870 (closed 2023) established that a later
doMockfor the same path must override an earlier one — that bug was deterministic (module cache across tests); this one is a race inside a single flush. The browser-mode registration issues (#7912, #8052) are a different mechanism.Suggested fix directions
Promise.allonly theresolveIdcalls, then applymockPath()/unmockPath()sequentially in queue order, so registration order is decoupled from RPC completion order.Workaround
Register a single
doMockfactory that closes over a mutable variable, and mutate the variable instead of callingdoMockagain for the same path:(This is how I fixed the real-world occurrence, an auth-guard suite flake: majed-afk/athar#417 — private repo, linked for provenance.)
Reproduction
Fully inline — three small files:
The same race reproduces when the import goes through a consumer module instead of importing the mocked path directly (
consumer.jsdoingimport { value } from "./dep.js"and the test doingawait import("./consumer.js")) — rates below include both shapes.Observed failure rates (Apple M4, 10 cores, macOS, Node 22.22.1)
(In the 200-samples-per-run rows, each vitest run executed both shapes: 100 direct-import + 100 via-consumer samples; failures occurred in both.)
Failures are always
expected 2 to be 3— thebeforeEachfactory winning — never the unmocked value1, consistent with the registration-order race rather than mocks not applying.System Info
System: OS: macOS 26.5.1 CPU: (10) arm64 Apple M4 Memory: 292.61 MB / 16.00 GB Shell: 5.9 - /bin/zsh Binaries: Node: 22.22.1 - ~/.nvm/versions/node/v22.22.1/bin/node npm: 10.9.4 - ~/.nvm/versions/node/v22.22.1/bin/npm npmPackages: vitest: 4.1.5 => 4.1.5Also reproduced with
vitest: 4.1.9(same machine, same rates — table above).Used Package Manager
npm
Validations
doMock,resolveMocks,mock order,mock race,pendingIds— closest matches vi.doMock does not override previous mock #2870, Test sequence causes tests to consistently fail or pass #9499, Browser Mode: "Mock ... wasn't registered" error under load with mixed mocked/unmocked dependencies #7912, Mock registration fails in browser mode with headless: true and fileParallelism: true #8052 are all different mechanisms.)