Skip to content

Commit a75ea7d

Browse files
committed
fix(@angular/build): execute setup file hooks for each spec file when coverage is enabled
When code coverage is enabled with the Vitest runner, test entry points are served as a virtual one-line import stub (`import "./${outputPath}";`) so that the test files themselves can be excluded from coverage reports after sourcemap remapping. However, setup files configured via `setupFiles` were also treated as test entry points and served as virtual stubs. Because Vitest only invalidates the top-level setup module between spec files, the imported intermediate bundle was cached in `vite-node` after its initial evaluation and never re-evaluated on subsequent spec files. This caused setup file hooks (e.g. `beforeEach` / `afterEach`) to silently only run for the first spec file of each worker. This commit excludes setup files from being wrapped in the virtual coverage stub, allowing them to be directly evaluated and re-evaluated by Vitest before each spec file. Fixes #34137
1 parent 69acf5b commit a75ea7d

3 files changed

Lines changed: 76 additions & 2 deletions

File tree

‎packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ export class VitestExecutor implements TestExecutor {
333333
projectName,
334334
buildResultFiles: this.buildResultFiles,
335335
testFileToEntryPoint: this.testFileToEntryPoint,
336+
setupFiles: testSetupFiles,
336337
});
337338

338339
const debugOptions = debug

‎packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ interface PluginOptions {
3737
projectName: string;
3838
buildResultFiles: ReadonlyMap<string, ResultFile>;
3939
testFileToEntryPoint: ReadonlyMap<string, string>;
40+
setupFiles: readonly string[];
4041
}
4142

4243
type VitestCoverageOption = Exclude<InlineConfig['coverage'], undefined>;
@@ -313,8 +314,13 @@ async function loadResultFile(file: ResultFile): Promise<string> {
313314
}
314315

315316
export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[] {
316-
const { workspaceRoot, buildResultFiles, testFileToEntryPoint } = pluginOptions;
317+
const { workspaceRoot, buildResultFiles, testFileToEntryPoint, setupFiles } = pluginOptions;
317318
const isWindows = platform() === 'win32';
319+
const setupFileSet = new Set(
320+
setupFiles.map((file) =>
321+
toPosixPath(path.isAbsolute(file) ? file : path.join(workspaceRoot, file)),
322+
),
323+
);
318324
let vitestConfig: ResolvedConfig;
319325

320326
return [
@@ -387,7 +393,11 @@ export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[]
387393
if (entryPoint) {
388394
outputPath = entryPoint + '.js';
389395

390-
if (vitestConfig?.coverage?.enabled) {
396+
// Setup files must not be wrapped in a virtual import stub because Vitest only invalidates
397+
// the setup file itself between test files; wrapping it would cause the underlying bundle
398+
// to be cached, preventing per-test hooks from running on subsequent test files.
399+
const isSetupFile = setupFileSet.has(id);
400+
if (vitestConfig?.coverage?.enabled && !isSetupFile) {
391401
// To support coverage exclusion of the actual test file, the virtual
392402
// test entry point only references the built and bundled intermediate file.
393403
// If vitest supported an "excludeOnlyAfterRemap" option, this could be removed completely.

‎packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,68 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
5454
const { result } = await harness.executeOnce();
5555
expect(result?.success).toBeTrue();
5656
});
57+
58+
it('should run setup file hooks for each spec file when coverage is enabled', async () => {
59+
await harness.writeFiles({
60+
'custom-vitest.config.mts': `
61+
import { defineConfig } from 'vitest/config';
62+
63+
export default defineConfig({
64+
test: {
65+
fileParallelism: false,
66+
},
67+
});
68+
`,
69+
'src/setup.ts': `
70+
import { afterEach, beforeEach, expect } from 'vitest';
71+
const global = globalThis as typeof globalThis & {
72+
setupHookCalls?: string[];
73+
};
74+
const setupHookCalls = (global.setupHookCalls ??= []);
75+
beforeEach(() => {
76+
const testName = expect.getState().currentTestName ?? '';
77+
setupHookCalls.push('beforeEach:' + testName);
78+
});
79+
afterEach(() => {
80+
const testName = expect.getState().currentTestName ?? '';
81+
setupHookCalls.push('afterEach:' + testName);
82+
});
83+
`,
84+
'src/app/app.component.spec.ts': `
85+
import { expect, it } from 'vitest';
86+
it('runs setup hooks for first test in app.component.spec', () => {
87+
const global = globalThis as typeof globalThis & { setupHookCalls?: string[] };
88+
expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for first test in app.component.spec');
89+
});
90+
it('runs setup hooks for second test in app.component.spec', () => {
91+
const global = globalThis as typeof globalThis & { setupHookCalls?: string[] };
92+
expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for second test in app.component.spec');
93+
expect(global.setupHookCalls).toContain('afterEach:runs setup hooks for first test in app.component.spec');
94+
});
95+
`,
96+
'src/app/second.spec.ts': `
97+
import { expect, it } from 'vitest';
98+
it('runs setup hooks for first test in second.spec', () => {
99+
const global = globalThis as typeof globalThis & { setupHookCalls?: string[] };
100+
expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for first test in second.spec');
101+
});
102+
it('runs setup hooks for second test in second.spec', () => {
103+
const global = globalThis as typeof globalThis & { setupHookCalls?: string[] };
104+
expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for second test in second.spec');
105+
expect(global.setupHookCalls).toContain('afterEach:runs setup hooks for first test in second.spec');
106+
});
107+
`,
108+
});
109+
110+
harness.useTarget('test', {
111+
...BASE_OPTIONS,
112+
coverage: true,
113+
runnerConfig: 'custom-vitest.config.mts',
114+
setupFiles: ['src/setup.ts'],
115+
});
116+
117+
const { result } = await harness.executeOnce();
118+
expect(result?.success).toBeTrue();
119+
});
57120
});
58121
});

0 commit comments

Comments
 (0)