diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts index fc64e432e19f..55003b4fc82a 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts @@ -333,6 +333,7 @@ export class VitestExecutor implements TestExecutor { projectName, buildResultFiles: this.buildResultFiles, testFileToEntryPoint: this.testFileToEntryPoint, + setupFiles: testSetupFiles, }); const debugOptions = debug diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts index ee81e417bb5d..6be4c6e8ebe3 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts @@ -37,6 +37,7 @@ interface PluginOptions { projectName: string; buildResultFiles: ReadonlyMap; testFileToEntryPoint: ReadonlyMap; + setupFiles: readonly string[]; } type VitestCoverageOption = Exclude; @@ -313,8 +314,13 @@ async function loadResultFile(file: ResultFile): Promise { } export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[] { - const { workspaceRoot, buildResultFiles, testFileToEntryPoint } = pluginOptions; + const { workspaceRoot, buildResultFiles, testFileToEntryPoint, setupFiles } = pluginOptions; const isWindows = platform() === 'win32'; + const setupFileSet = new Set( + setupFiles.map((file) => + toPosixPath(path.isAbsolute(file) ? file : path.join(workspaceRoot, file)), + ), + ); let vitestConfig: ResolvedConfig; return [ @@ -387,7 +393,11 @@ export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[] if (entryPoint) { outputPath = entryPoint + '.js'; - if (vitestConfig?.coverage?.enabled) { + // Setup files must not be wrapped in a virtual import stub because Vitest only invalidates + // the setup file itself between test files; wrapping it would cause the underlying bundle + // to be cached, preventing per-test hooks from running on subsequent test files. + const isSetupFile = setupFileSet.has(id); + if (vitestConfig?.coverage?.enabled && !isSetupFile) { // 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. diff --git a/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts index 65d0dadf17b8..44241bbcfd8a 100644 --- a/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts @@ -54,5 +54,68 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBeTrue(); }); + + it('should run setup file hooks for each spec file when coverage is enabled', async () => { + await harness.writeFiles({ + 'custom-vitest.config.mts': ` + import { defineConfig } from 'vitest/config'; + + export default defineConfig({ + test: { + fileParallelism: false, + }, + }); + `, + 'src/setup.ts': ` + import { afterEach, beforeEach, expect } from 'vitest'; + const global = globalThis as typeof globalThis & { + setupHookCalls?: string[]; + }; + const setupHookCalls = (global.setupHookCalls ??= []); + beforeEach(() => { + const testName = expect.getState().currentTestName ?? ''; + setupHookCalls.push('beforeEach:' + testName); + }); + afterEach(() => { + const testName = expect.getState().currentTestName ?? ''; + setupHookCalls.push('afterEach:' + testName); + }); + `, + 'src/app/app.component.spec.ts': ` + import { expect, it } from 'vitest'; + it('runs setup hooks for first test in app.component.spec', () => { + const global = globalThis as typeof globalThis & { setupHookCalls?: string[] }; + expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for first test in app.component.spec'); + }); + it('runs setup hooks for second test in app.component.spec', () => { + const global = globalThis as typeof globalThis & { setupHookCalls?: string[] }; + expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for second test in app.component.spec'); + expect(global.setupHookCalls).toContain('afterEach:runs setup hooks for first test in app.component.spec'); + }); + `, + 'src/app/second.spec.ts': ` + import { expect, it } from 'vitest'; + it('runs setup hooks for first test in second.spec', () => { + const global = globalThis as typeof globalThis & { setupHookCalls?: string[] }; + expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for first test in second.spec'); + }); + it('runs setup hooks for second test in second.spec', () => { + const global = globalThis as typeof globalThis & { setupHookCalls?: string[] }; + expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for second test in second.spec'); + expect(global.setupHookCalls).toContain('afterEach:runs setup hooks for first test in second.spec'); + }); + `, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + runnerConfig: 'custom-vitest.config.mts', + setupFiles: ['src/setup.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); }); });