Skip to content

unit-test (vitest): isolate:false default silently breaks cross-file vi.mock() of shared modules on low-CPU workers #33570

Description

@rommelandrea

Which @angular/* package(s) are the source of the bug?

build

Is this a regression?

No

Description

@angular/build:unit-test (Vitest runner) sets test.isolate: false by default (to match the historical Karma/Jasmine experience — src/builders/unit-test/runners/vitest/plugins.js, and again in executor.js). Combined with the builder's pre-bundled test entry points that share esbuild chunks, this silently breaks vi.mock() of a bare (node_modules) module across spec files that land on the same worker.

Mechanism

  1. Each spec is pre-bundled by esbuild. A component/service imported by more than one spec is hoisted into a shared chunk (chunk-*.js), and third-party packages are kept external (externalPackages: true).
  2. With isolate: false, Vitest reuses a worker (and its module registry) across spec files.
  3. Spec A and spec B both vi.mock('some-pkg', factory) (different factories) and both import the same shared component that imports some-pkg. Whichever spec runs first on a worker instantiates the shared chunk bound to its mock. When the other spec later runs on the same worker, the cached shared-chunk module is reused, so its own vi.mock('some-pkg') never intercepts the component's import — its mock handles stay empty while the component silently runs against the first spec's mock (or the real module).

Because Vitest's worker count follows os.availableParallelism(), the failure is CPU-count dependent: green on high-core dev machines / CI, but deterministically red on low-core CI runners (e.g. a 2-vCPU Kubernetes/ARC pod), where many spec files share each worker in a stable, size-ordered sequence. This makes it a nasty "works on my machine / fails only on constrained CI" trap.

Minimal reproduction

Conditions (no third-party lib needed — any bare module works):

  • pkg — a trivial node_modules package exporting a factory function, e.g. makeThing().
  • SharedCmp — a standalone component that imports pkg and calls makeThing() in ngAfterViewInit.
  • a.spec.tsvi.mock('pkg', () => ({ makeThing: () => handlesA })), imports SharedCmp (e.g. via a page that renders it), asserts against handlesA.
  • b.spec.tsvi.mock('pkg', () => ({ makeThing: () => handlesB })), creates SharedCmp directly, asserts against handlesB.

Run ng test with the two specs while constraining workers so both files share one worker (e.g. on a 2-CPU machine, or VITEST_MAX_THREADS=1), with a.spec.ts scheduled before b.spec.ts. b.spec.ts's assertions fail because SharedCmp is bound to handlesA (or the real module), and handlesB is never called. Adding a project vitest-base.config.ts with test: { isolate: true } (via "runnerConfig": true) makes both pass.

Exception or Error

No thrown error — the component runs against the wrong (foreign) mock instance.
Symptom: the later spec's own mock handles are never invoked
(e.g. "expected 1, got 0" / "undefined is not a spy"), only on low-CPU workers.

Your Environment

@angular/build: 21.2.12
vitest: 4.1.7
Node: 24.15.0
Runner: vitest (unit-test builder), jsdom environment
Repro trigger: low worker count (2-vCPU CI runner) — deterministic;
high-core machines mask it.

Anything else relevant?

Workaround (works): opt into an external runner config and re-enable isolation —

angular.json:

"test": {
  "builder": "@angular/build:unit-test",
  "options": { "runnerConfig": true }
}

vitest-base.config.ts:

import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { isolate: true } });

Suggested fixes for consideration:

  • Document prominently that vi.mock() of shared/bare modules is unsafe under the default isolate: false (the current in-repo guard only rejects relative vi.mock paths — angular:vitest-mock-patch — but bare-specifier mocks are the ones that break cross-file).
  • Consider detecting vi.mock usage and warning (or auto-isolating) when specs share chunks, or make isolation the default for the Vitest runner.

Happy to package a StackBlitz/minimal repo if that would help triage.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions