Which @angular/* package(s) are the source of the bug?
@angular/build (the @angular/build:unit-test builder, Vitest runner)
Is this a regression?
Not known. The wrapping has been present since coverage was added to the Vitest runner. It was
observed in 21.2.24 and the code is unchanged in 22.1.8.
Description
Vitest runs every setupFiles entry before each test file. To make that happen, it
invalidates the setup module in the worker's module graph before it imports it again (see
"Root cause"). The Angular unit-test builder keeps this contract without coverage.
With --coverage, the builder serves each angular.json setupFiles entry as a one-line
module, import "./<bundle>.js";, around the bundle esbuild produced. Specs are served the same
way. Vitest invalidates that wrapper, which is the module it knows by the setup file's id, and
imports it again. The wrapper re-runs, but the bundle it imports is still cached in the worker's
module graph, so the setup code itself does not run again. It runs once for the first spec
file each worker takes, and never again in that worker.
Nothing reports this. Any beforeEach / afterEach hook the setup file registers, and any
per-file state it resets, applies only to the first spec file of each worker. With the builder's
default isolate: false, later files inherit whatever state the previous file left behind. We
saw this in a real workspace of 560 spec files: the setup file ran 11 times, once per worker,
under --coverage, and 560 times without coverage. Our storage-reset and leak-detection hooks were
therefore off for most files. The coverage run failed in 14-15 files each time, a different set on
every run, while the same suite without coverage was green.
Minimal reproduction
Built and run on 2026-09-21. It reproduces, 2 runs out of 2 in each mode (logs below). The
workspace is 10 small files. There is no Angular component, because the bug needs none.
Files
package.json
{
"name": "setupfiles-coverage-repro",
"private": true,
"scripts": {
"test": "ng test --watch=false --reporters=verbose",
"test:coverage": "ng test --watch=false --coverage --reporters=verbose"
},
"devDependencies": {
"@angular/build": "21.2.24",
"@angular/cli": "21.2.24",
"@angular/common": "21.2.23",
"@angular/compiler": "21.2.23",
"@angular/compiler-cli": "21.2.23",
"@angular/core": "21.2.23",
"@angular/platform-browser": "21.2.23",
"@vitest/coverage-v8": "4.1.11",
"jsdom": "28.1.0",
"rxjs": "7.8.2",
"tslib": "2.8.1",
"typescript": "5.9.3",
"vitest": "4.1.11"
}
}
angular.json: a test target whose setupFiles lists src/setup.ts.
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": { "analytics": false, "packageManager": "pnpm" },
"newProjectRoot": "projects",
"projects": {
"repro": {
"projectType": "application",
"root": "",
"sourceRoot": "src",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": { "browser": "src/main.ts", "tsConfig": "tsconfig.app.json" }
},
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "tsconfig.spec.json",
"buildTarget": "repro:build",
"runnerConfig": "vitest.config.ts",
"setupFiles": ["src/setup.ts"]
}
}
}
}
}
}
vitest.config.ts: this file pins one worker, so the expected count is exactly one setup run per
spec file. Without it, each of the 3 files may get its own worker and the bug hides. It needs more
spec files than workers to show.
import { defineConfig } from 'vitest/config';
// One worker, so the expected count is exactly one setup run per spec file (3).
// With the default pool the setup runs once per worker instead.
export default defineConfig({ test: { maxWorkers: 1 } });
tsconfig.json
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "preserve",
"moduleResolution": "bundler",
"skipLibCheck": true,
"isolatedModules": true
}
}
tsconfig.app.json: { "extends": "./tsconfig.json", "files": ["src/main.ts"] }
tsconfig.spec.json
{
"extends": "./tsconfig.json",
"compilerOptions": { "types": ["vitest/globals"] },
"include": ["src/**/*.spec.ts", "src/setup.ts"]
}
src/main.ts: export {};
src/setup.ts: counts its own executions.
export {};
// Counts its own executions. Vitest documents that setup files run before each test file.
const g = globalThis as { __setupRuns?: number; __setupRanForThisFile?: boolean };
g.__setupRuns = (g.__setupRuns ?? 0) + 1;
g.__setupRanForThisFile = true;
console.log(`[setup] run #${g.__setupRuns}`);
src/a.spec.ts, src/b.spec.ts and src/c.spec.ts are identical except for the letter:
export {};
const g = globalThis as { __setupRuns?: number; __setupRanForThisFile?: boolean };
describe('a', () => {
afterAll(() => {
g.__setupRanForThisFile = false;
});
it('ran after the setup file was evaluated for this spec file', () => {
console.log('[a] setup runs so far: ' + g.__setupRuns);
expect(g.__setupRanForThisFile).toBe(true);
});
});
Steps
pnpm install
pnpm test # ng test --watch=false
pnpm test:coverage # ng test --watch=false --coverage
Observed
Without --coverage, the setup runs once per spec file:
[c] setup runs so far: 1
[a] setup runs so far: 2
[b] setup runs so far: 3
Tests 3 passed (3)
With --coverage, the setup runs once for the whole worker:
[c] setup runs so far: 1
[a] setup runs so far: 1
[b] setup runs so far: 1
FAIL |repro| src/a.spec.ts > a > ran after the setup file was evaluated for this spec file
AssertionError: expected false to be true // Object.is equality
FAIL |repro| src/b.spec.ts > b > ran after the setup file was evaluated for this spec file
AssertionError: expected false to be true // Object.is equality
Tests 2 failed | 1 passed (3)
A diagnostic transform hook in vitest.config.ts shows what the builder serves for the id
src/setup.ts in each mode:
without --coverage: src/setup.ts: var g = globalThis; (the bundled setup code)
with --coverage: src/setup.ts: import "./setup.js"; (a wrapper around the bundle)
If src/setup.ts moves from angular.json setupFiles to vitest.config.ts test.setupFiles,
the builder does not wrap it, and the coverage run passes (Tests 3 passed (3)).
Expected
A file in the builder's setupFiles runs before every spec file, as it does without coverage and
as Vitest documents for setupFiles. In the reproduction that means 3 runs and 3 passing tests,
with or without --coverage.
Root cause
@angular/build 21.2.24, src/builders/unit-test/runners/vitest/plugins.js, plugin
angular:test-in-memory-provider, load(id) hook (lines 237-273 of the installed file; the same
code is at lines 295-310 of 22.1.8):
const entryPoint = testFileToEntryPoint.get(id);
let outputPath;
if (entryPoint) {
outputPath = entryPoint + '.js';
if (vitestConfig?.coverage?.enabled) {
// To support coverage exclusion of the actual test file, the virtual
// test entry point only references the built and bundled intermediate file.
// If vitest supported an "excludeOnlyAfterRemap" option, this could be removed completely.
return {
code: `import "./${outputPath}";`,
};
}
}
testFileToEntryPoint holds the spec files and the setup files:
runners/vitest/build-options.js (getVitestBuildOptions) adds each options.setupFiles entry
to the build's entryPoints, using getTestEntrypoints(options.setupFiles, { prefix: 'setup' }).
runners/vitest/executor.js (constructor) turns those mappings into testFileToEntryPoint
(source path to entry point).
executor.js prepareSetupFiles() passes the same source paths to Vitest as setupFiles,
after init-testbed.js and vitest-mock-patch.js.
On the Vitest side (4.1.11, dist/chunks/test.*.js, TestRunner.importFile):
importFile(filepath, source) {
if (source === "setup") {
const moduleNode = this.workerState.evaluatedModules.getModuleById(filepath);
if (moduleNode) this.workerState.evaluatedModules.invalidateModule(moduleNode);
}
...
return this.moduleRunner.import(filepath);
}
Vitest invalidates only the module whose id is the setup file's path. Under coverage that module
is the one-line wrapper. The bundle it imports (./setup-….js) is a different module that nobody
invalidates, so importing the wrapper again finds it already evaluated. For spec files this does
no harm, because each spec file is imported only once per run. For setup files it breaks the "run
before every test file" contract.
Only setupFiles is affected. providersFile is compiled into the init-testbed bundle, and
init-testbed.js, vitest-mock-patch.js and polyfills.js reach Vitest as build artefacts by
relative path, not through testFileToEntryPoint, so the builder serves them directly and Vitest
re-runs them correctly.
Suggested fix
Either of these fixes the problem. The first is the smaller change.
-
Don't wrap setup files. The wrapper exists so coverage can exclude the spec source file
(see the comment above), and a setup file does not need that. Keep a set of the setup-file
entry points and return the bundle directly for them, as the non-coverage path does:
if (entryPoint) {
outputPath = entryPoint + '.js';
if (vitestConfig?.coverage?.enabled && !setupEntryPoints.has(entryPoint)) {
return { code: `import "./${outputPath}";` };
}
}
(setupEntryPoints would come from getVitestBuildOptions / the executor, or from the
setup prefix that getTestEntrypoints already applies.)
-
Or make the wrapped bundle stale too. When a setup file is wrapped, the wrapper's
dependency also has to be re-evaluated. For example, the wrapper could import the bundle with
a per-evaluation query (import "./setup-x.js?t=" + <counter>, which needs a dynamic import()
and top-level await). Or the provider could hook Vitest's setup import and invalidate
./setup-x.js as well. This is more complicated, and it gives each run a new module id, so
option 1 is preferred.
Whichever fix is chosen, a regression test should run the builder with --coverage, a single
worker, 2 or more spec files and a counting setup file, and assert one evaluation per spec file.
Environment
| Package |
Version |
| @angular/build |
21.2.24 (code unchanged in 22.1.8, read from the published tarball) |
| @angular/cli |
21.2.24 |
| @angular/core et al. |
21.2.23 |
| vitest |
4.1.11 |
| @vitest/coverage-v8 |
4.1.11 |
| jsdom |
28.1.0 |
| typescript |
5.9.3 |
| Node.js |
24.15.0 |
| pnpm |
10.34.5 |
| OS |
macOS 27.0 (darwin arm64) |
Workaround
List setup files in the Vitest runnerConfig (test.setupFiles) instead of in angular.json's
setupFiles. The builder merges them in after its own init-testbed.js, and serves them as plain
Vite-transformed modules that Vitest re-runs before every file in both modes.
Which @angular/* package(s) are the source of the bug?
@angular/build(the@angular/build:unit-testbuilder, Vitest runner)Is this a regression?
Not known. The wrapping has been present since coverage was added to the Vitest runner. It was
observed in 21.2.24 and the code is unchanged in 22.1.8.
Description
Vitest runs every
setupFilesentry before each test file. To make that happen, itinvalidates the setup module in the worker's module graph before it imports it again (see
"Root cause"). The Angular unit-test builder keeps this contract without coverage.
With
--coverage, the builder serves each angular.jsonsetupFilesentry as a one-linemodule,
import "./<bundle>.js";, around the bundle esbuild produced. Specs are served the sameway. Vitest invalidates that wrapper, which is the module it knows by the setup file's id, and
imports it again. The wrapper re-runs, but the bundle it imports is still cached in the worker's
module graph, so the setup code itself does not run again. It runs once for the first spec
file each worker takes, and never again in that worker.
Nothing reports this. Any
beforeEach/afterEachhook the setup file registers, and anyper-file state it resets, applies only to the first spec file of each worker. With the builder's
default
isolate: false, later files inherit whatever state the previous file left behind. Wesaw this in a real workspace of 560 spec files: the setup file ran 11 times, once per worker,
under
--coverage, and 560 times without coverage. Our storage-reset and leak-detection hooks weretherefore off for most files. The coverage run failed in 14-15 files each time, a different set on
every run, while the same suite without coverage was green.
Minimal reproduction
Built and run on 2026-09-21. It reproduces, 2 runs out of 2 in each mode (logs below). The
workspace is 10 small files. There is no Angular component, because the bug needs none.
Files
package.json{ "name": "setupfiles-coverage-repro", "private": true, "scripts": { "test": "ng test --watch=false --reporters=verbose", "test:coverage": "ng test --watch=false --coverage --reporters=verbose" }, "devDependencies": { "@angular/build": "21.2.24", "@angular/cli": "21.2.24", "@angular/common": "21.2.23", "@angular/compiler": "21.2.23", "@angular/compiler-cli": "21.2.23", "@angular/core": "21.2.23", "@angular/platform-browser": "21.2.23", "@vitest/coverage-v8": "4.1.11", "jsdom": "28.1.0", "rxjs": "7.8.2", "tslib": "2.8.1", "typescript": "5.9.3", "vitest": "4.1.11" } }angular.json: a test target whosesetupFileslistssrc/setup.ts.{ "$schema": "./node_modules/@angular/cli/lib/config/schema.json", "version": 1, "cli": { "analytics": false, "packageManager": "pnpm" }, "newProjectRoot": "projects", "projects": { "repro": { "projectType": "application", "root": "", "sourceRoot": "src", "architect": { "build": { "builder": "@angular/build:application", "options": { "browser": "src/main.ts", "tsConfig": "tsconfig.app.json" } }, "test": { "builder": "@angular/build:unit-test", "options": { "tsConfig": "tsconfig.spec.json", "buildTarget": "repro:build", "runnerConfig": "vitest.config.ts", "setupFiles": ["src/setup.ts"] } } } } } }vitest.config.ts: this file pins one worker, so the expected count is exactly one setup run perspec file. Without it, each of the 3 files may get its own worker and the bug hides. It needs more
spec files than workers to show.
tsconfig.json{ "compilerOptions": { "strict": true, "target": "ES2022", "module": "preserve", "moduleResolution": "bundler", "skipLibCheck": true, "isolatedModules": true } }tsconfig.app.json:{ "extends": "./tsconfig.json", "files": ["src/main.ts"] }tsconfig.spec.json{ "extends": "./tsconfig.json", "compilerOptions": { "types": ["vitest/globals"] }, "include": ["src/**/*.spec.ts", "src/setup.ts"] }src/main.ts:export {};src/setup.ts: counts its own executions.src/a.spec.ts,src/b.spec.tsandsrc/c.spec.tsare identical except for the letter:Steps
Observed
Without
--coverage, the setup runs once per spec file:With
--coverage, the setup runs once for the whole worker:A diagnostic
transformhook invitest.config.tsshows what the builder serves for the idsrc/setup.tsin each mode:If
src/setup.tsmoves from angular.jsonsetupFilestovitest.config.tstest.setupFiles,the builder does not wrap it, and the coverage run passes (
Tests 3 passed (3)).Expected
A file in the builder's
setupFilesruns before every spec file, as it does without coverage andas Vitest documents for
setupFiles. In the reproduction that means 3 runs and 3 passing tests,with or without
--coverage.Root cause
@angular/build21.2.24,src/builders/unit-test/runners/vitest/plugins.js, pluginangular:test-in-memory-provider,load(id)hook (lines 237-273 of the installed file; the samecode is at lines 295-310 of 22.1.8):
testFileToEntryPointholds the spec files and the setup files:runners/vitest/build-options.js(getVitestBuildOptions) adds eachoptions.setupFilesentryto the build's
entryPoints, usinggetTestEntrypoints(options.setupFiles, { prefix: 'setup' }).runners/vitest/executor.js(constructor) turns those mappings intotestFileToEntryPoint(source path to entry point).
executor.jsprepareSetupFiles()passes the same source paths to Vitest assetupFiles,after
init-testbed.jsandvitest-mock-patch.js.On the Vitest side (4.1.11,
dist/chunks/test.*.js,TestRunner.importFile):Vitest invalidates only the module whose id is the setup file's path. Under coverage that module
is the one-line wrapper. The bundle it imports (
./setup-….js) is a different module that nobodyinvalidates, so importing the wrapper again finds it already evaluated. For spec files this does
no harm, because each spec file is imported only once per run. For setup files it breaks the "run
before every test file" contract.
Only
setupFilesis affected.providersFileis compiled into theinit-testbedbundle, andinit-testbed.js,vitest-mock-patch.jsandpolyfills.jsreach Vitest as build artefacts byrelative path, not through
testFileToEntryPoint, so the builder serves them directly and Vitestre-runs them correctly.
Suggested fix
Either of these fixes the problem. The first is the smaller change.
Don't wrap setup files. The wrapper exists so coverage can exclude the spec source file
(see the comment above), and a setup file does not need that. Keep a set of the setup-file
entry points and return the bundle directly for them, as the non-coverage path does:
(
setupEntryPointswould come fromgetVitestBuildOptions/ the executor, or from thesetupprefix thatgetTestEntrypointsalready applies.)Or make the wrapped bundle stale too. When a setup file is wrapped, the wrapper's
dependency also has to be re-evaluated. For example, the wrapper could import the bundle with
a per-evaluation query (
import "./setup-x.js?t=" + <counter>, which needs a dynamicimport()and top-level
await). Or the provider could hook Vitest's setup import and invalidate./setup-x.jsas well. This is more complicated, and it gives each run a new module id, sooption 1 is preferred.
Whichever fix is chosen, a regression test should run the builder with
--coverage, a singleworker, 2 or more spec files and a counting setup file, and assert one evaluation per spec file.
Environment
Workaround
List setup files in the Vitest
runnerConfig(test.setupFiles) instead of in angular.json'ssetupFiles. The builder merges them in after its owninit-testbed.js, and serves them as plainVite-transformed modules that Vitest re-runs before every file in both modes.