Skip to content

Repository files navigation

A setup file reaches only one spec file when @angular/build:unit-test runs with --coverage

Minimal reproduction for angular/angular-cli.

Vitest re-imports every setup file before every spec file, and invalidates the setup module first, so that the hooks the setup file registers are registered again for the file about to run. The Angular unit-test builder's vitest runner rewrites every test entry point — and setup files are among its entry points — into a one-line stub that imports the built bundle, whenever coverage is enabled. Invalidating the stub does not invalidate what it imports, so the module that holds the setup code is evaluated once per worker and its hooks are registered onto the first spec file that worker runs, and onto no other.

src/setup.ts is declared in angular.json under the builder's own setupFiles option. It counts how often its module body is evaluated and which spec files its afterEach actually runs for, and appends one line per event to hook-log.txt. There are three trivial spec files.

vitest.config.ts is passed as the builder's runnerConfig and does one thing: it pins the run to a single worker, so that all three spec files share one environment. With one file per worker both runs below would look identical.

Reproducing

bun install                                    # or: npm install

rm -f hook-log.txt && bunx ng test --no-watch --coverage && cat hook-log.txt
rm -f hook-log.txt && bunx ng test --no-watch              && cat hook-log.txt

Expected

The setup module is evaluated once per spec file, and its afterEach runs for all three, in both runs.

Actual, measured 2026-09-21

With --coverage — 1 evaluation, and the hook reaches 1 of the 3 spec files:

evaluation 1
hook three.spec.ts

Without coverage — 3 evaluations, and the hook reaches all 3 spec files:

evaluation 1
hook three.spec.ts
evaluation 2
hook two.spec.ts
evaluation 3
hook one.spec.ts

Both runs report Test Files 3 passed (3). The numbers were stable over three runs each way; the spec file the hook reaches is whichever the worker runs first.

Workaround, measured the same way

A setup file declared in the runner config's own test.setupFiles rather than in the builder's setupFiles option is not an entry point, so it is served as itself and behaves correctly. Adding a second counting setup file that way and running with --coverage gives, in one run:

evaluation 1             <- the builder's setup file, once
runner-evaluation        <- the runner config's setup file
runner-hook three.spec.ts
hook three.spec.ts
runner-evaluation
runner-hook two.spec.ts
runner-evaluation
runner-hook one.spec.ts

That file is not part of this repository; it is described here because it is the only way found to get a per-file hook out of a setup file while coverage is on.

Versions

Measured on Linux (x86_64, WSL2 kernel 6.18):

@angular/build 22.1.8
@angular/cli 22.1.8
@angular/core, @angular/common, @angular/compiler, @angular/platform-browser 22.1.6
@angular/compiler-cli 22.1.6
vitest 4.1.11
@vitest/coverage-v8 4.1.11
jsdom 30.0.1
typescript 6.0.3
Node.js 26.8.2
bun 1.4.2

Every dependency is pinned to an exact version, so npm install resolves the same tree; the numbers above were taken with bun.

The two mechanisms

@angular/build/src/builders/unit-test/runners/vitest/build-options.js puts the setup files among the build's entry points:

if (options.setupFiles?.length) {
  const setupEntryPoints = getTestEntrypoints(options.setupFiles, {
    projectSourceRoot,
    workspaceRoot,
    removeTestExtension: false,
    prefix: 'setup',
  });
  for (const [entryPoint, setupFile] of setupEntryPoints) {
    entryPoints.set(entryPoint, setupFile);
  }
}

@angular/build/src/builders/unit-test/runners/vitest/plugins.js then serves every entry point as a stub when coverage is on:

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}";`,
  };
}

@vitest/runner re-runs the setup files for each spec file — clearCollectorContext(file, runner) then await runSetupFiles(config, setupFiles, runner) — and vitest's TestRunner.importFile invalidates the setup module so that the re-import re-evaluates it:

importFile(filepath, source) {
  if (source === "setup") {
    const moduleNode = this.workerState.evaluatedModules.getModuleById(filepath);
    if (moduleNode) this.workerState.evaluatedModules.invalidateModule(moduleNode);
  }
  ...
}

The module invalidated is the stub. The module behind it, which holds the code, is not invalidated and so is never evaluated again.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages