Repository navigation
unit-test (vitest): isolate:false default silently breaks cross-file vi.mock() of shared modules on low-CPU workers #33570
Description
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 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 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 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). - Consider detecting
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.
Activity
clydin commentedon Aug 13, 2026
Resolved via #33729