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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions