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
- 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).
- With
isolate: false, Vitest reuses a worker (and its module registry) across spec files.
- 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.ts — vi.mock('pkg', () => ({ makeThing: () => handlesA })), imports SharedCmp (e.g. via a page that renders it), asserts against handlesA.
b.spec.ts — vi.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.
Which @angular/* package(s) are the source of the bug?
build
Is this a regression?
No
Description
@angular/build:unit-test(Vitest runner) setstest.isolate: falseby default (to match the historical Karma/Jasmine experience —src/builders/unit-test/runners/vitest/plugins.js, and again inexecutor.js). Combined with the builder's pre-bundled test entry points that share esbuild chunks, this silently breaksvi.mock()of a bare (node_modules) module across spec files that land on the same worker.Mechanism
chunk-*.js), and third-party packages are kept external (externalPackages: true).isolate: false, Vitest reuses a worker (and its module registry) across spec files.vi.mock('some-pkg', factory)(different factories) and both import the same shared component that importssome-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 ownvi.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 importspkgand callsmakeThing()inngAfterViewInit.a.spec.ts—vi.mock('pkg', () => ({ makeThing: () => handlesA })), importsSharedCmp(e.g. via a page that renders it), asserts againsthandlesA.b.spec.ts—vi.mock('pkg', () => ({ makeThing: () => handlesB })), createsSharedCmpdirectly, asserts againsthandlesB.Run
ng testwith the two specs while constraining workers so both files share one worker (e.g. on a 2-CPU machine, orVITEST_MAX_THREADS=1), witha.spec.tsscheduled beforeb.spec.ts.b.spec.ts's assertions fail becauseSharedCmpis bound tohandlesA(or the real module), andhandlesBis never called. Adding a projectvitest-base.config.tswithtest: { isolate: true }(via"runnerConfig": true) makes both pass.Exception or Error
Your Environment
Anything else relevant?
Workaround (works): opt into an external runner config and re-enable isolation —
angular.json:vitest-base.config.ts:Suggested fixes for consideration:
vi.mock()of shared/bare modules is unsafe under the defaultisolate: false(the current in-repo guard only rejects relativevi.mockpaths —angular:vitest-mock-patch— but bare-specifier mocks are the ones that break cross-file).vi.mockusage 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.