Skip to content

unit-test builder (Vitest): with --coverage, setupFiles run once per worker instead of before each spec file #34142

Description

@stephen-prabhu

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.

  1. 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.)

  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions