Skip to content

vi.doMock: last doMock for a path intermittently loses when two doMocks are flushed by the same import (registration race in resolveMocks) #10706

Description

@majed-afk

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.doMockqueueMock() 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions