Skip to content

Commit d71f1c5

Browse files
committed
fix(@angular/build): correct Vitest coverage reporting for test files
Test coverage reporting within the Vitest runner was inaccurate because the test files themselves were being included in the coverage analysis. This was caused by how the in-memory provider directly loaded the test content. This commit resolves the issue by introducing an intermediate virtual module for each test. The test entry point now only imports this module, which in turn contains the bundled test code. This extra layer of indirection prevents Vitest from including the test files in the coverage results, leading to an accurate analysis. To support this change, the `getTestEntrypoints` function now removes the `.spec` or `.test` extension from bundle names, ensuring cleaner module identifiers. The Vitest runner has also been updated to better handle coverage exclusion options and resolve relative paths more reliably.
1 parent 26127bd commit d71f1c5

File tree

5 files changed

+61
-20
lines changed

5 files changed

+61
-20
lines changed

packages/angular/build/src/builders/karma/find-tests.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,13 @@ export async function findTests(
3030
interface TestEntrypointsOptions {
3131
projectSourceRoot: string;
3232
workspaceRoot: string;
33+
removeTestExtension?: boolean;
3334
}
3435

3536
/** Generate unique bundle names for a set of test files. */
3637
export function getTestEntrypoints(
3738
testFiles: string[],
38-
{ projectSourceRoot, workspaceRoot }: TestEntrypointsOptions,
39+
{ projectSourceRoot, workspaceRoot, removeTestExtension }: TestEntrypointsOptions,
3940
): Map<string, string> {
4041
const seen = new Set<string>();
4142

@@ -46,7 +47,13 @@ export function getTestEntrypoints(
4647
.replace(/^[./\\]+/, '')
4748
// Replace any path separators with dashes.
4849
.replace(/[/\\]/g, '-');
49-
const baseName = `spec-${basename(relativePath, extname(relativePath))}`;
50+
51+
let fileName = basename(relativePath, extname(relativePath));
52+
if (removeTestExtension) {
53+
fileName = fileName.replace(/\.(spec|test)$/, '');
54+
}
55+
56+
const baseName = `spec-${fileName}`;
5057
let uniqueName = baseName;
5158
let suffix = 2;
5259
while (seen.has(uniqueName)) {

packages/angular/build/src/builders/karma/find-tests_spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,36 @@ describe('getTestEntrypoints', () => {
6262
]),
6363
);
6464
});
65+
66+
describe('with removeTestExtension enabled', () => {
67+
function getEntrypoints(workspaceRelative: string[], sourceRootRelative: string[] = []) {
68+
return getTestEntrypoints(
69+
[
70+
...workspaceRelative.map((p) => joinWithSeparator(options.workspaceRoot, p)),
71+
...sourceRootRelative.map((p) => joinWithSeparator(options.projectSourceRoot, p)),
72+
],
73+
{ ...options, removeTestExtension: true },
74+
);
75+
}
76+
77+
it('removes .spec extension', () => {
78+
expect(getEntrypoints(['a/b.spec.js'], ['c/d.spec.js'])).toEqual(
79+
new Map<string, string>([
80+
['spec-a-b', joinWithSeparator(options.workspaceRoot, 'a/b.spec.js')],
81+
['spec-c-d', joinWithSeparator(options.projectSourceRoot, 'c/d.spec.js')],
82+
]),
83+
);
84+
});
85+
86+
it('removes .test extension', () => {
87+
expect(getEntrypoints(['a/b.test.js'], ['c/d.test.js'])).toEqual(
88+
new Map<string, string>([
89+
['spec-a-b', joinWithSeparator(options.workspaceRoot, 'a/b.test.js')],
90+
['spec-c-d', joinWithSeparator(options.projectSourceRoot, 'c/d.test.js')],
91+
]),
92+
);
93+
});
94+
});
6595
});
6696
}
6797
});

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,11 @@ export async function getVitestBuildOptions(
8282
);
8383
}
8484

85-
const entryPoints = getTestEntrypoints(testFiles, { projectSourceRoot, workspaceRoot });
85+
const entryPoints = getTestEntrypoints(testFiles, {
86+
projectSourceRoot,
87+
workspaceRoot,
88+
removeTestExtension: true,
89+
});
8690
entryPoints.set('init-testbed', 'angular:test-bed-init');
8791

8892
const buildOptions: Partial<ApplicationBuilderInternalOptions> = {

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

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,11 @@ export class VitestExecutor implements TestExecutor {
6868
if (this.testFileToEntryPoint.size === 0) {
6969
const { include, exclude = [], workspaceRoot, projectSourceRoot } = this.options;
7070
const testFiles = await findTests(include, exclude, workspaceRoot, projectSourceRoot);
71-
const entryPoints = getTestEntrypoints(testFiles, { projectSourceRoot, workspaceRoot });
71+
const entryPoints = getTestEntrypoints(testFiles, {
72+
projectSourceRoot,
73+
workspaceRoot,
74+
removeTestExtension: true,
75+
});
7276
for (const [entryPoint, testFile] of entryPoints) {
7377
this.testFileToEntryPoint.set(testFile, entryPoint);
7478
this.entryPointToTestFile.set(entryPoint + '.js', testFile);
@@ -162,16 +166,15 @@ export class VitestExecutor implements TestExecutor {
162166
name: 'angular:test-in-memory-provider',
163167
enforce: 'pre',
164168
resolveId: (id, importer) => {
165-
if (importer && id.startsWith('.')) {
169+
if (importer && (id[0] === '.' || id[0] === '/')) {
166170
let fullPath;
167-
let relativePath;
168171
if (this.testFileToEntryPoint.has(importer)) {
169172
fullPath = toPosixPath(path.join(this.options.workspaceRoot, id));
170-
relativePath = path.normalize(id);
171173
} else {
172174
fullPath = toPosixPath(path.join(path.dirname(importer), id));
173-
relativePath = path.relative(this.options.workspaceRoot, fullPath);
174175
}
176+
177+
const relativePath = path.relative(this.options.workspaceRoot, fullPath);
175178
if (this.buildResultFiles.has(toPosixPath(relativePath))) {
176179
return fullPath;
177180
}
@@ -201,6 +204,12 @@ export class VitestExecutor implements TestExecutor {
201204
let outputPath;
202205
if (entryPoint) {
203206
outputPath = entryPoint + '.js';
207+
208+
// To support coverage exclusion of the actual test file, the virtual
209+
// test entry point only references the built and bundled intermediate file.
210+
return {
211+
code: `import "./${outputPath}";`,
212+
};
204213
} else {
205214
// Attempt to load as a built artifact.
206215
const relativePath = path.relative(this.options.workspaceRoot, id);
@@ -247,15 +256,6 @@ export class VitestExecutor implements TestExecutor {
247256
},
248257
],
249258
});
250-
251-
// Adjust coverage excludes to not include the otherwise automatically inserted included unit tests.
252-
// Vite does this as a convenience but is problematic for the bundling strategy employed by the
253-
// builder's test setup. To workaround this, the excludes are adjusted here to only automatically
254-
// exclude the TypeScript source test files.
255-
project.config.coverage.exclude = [
256-
...(codeCoverage?.exclude ?? []),
257-
'**/*.{test,spec}.?(c|m)ts',
258-
];
259259
},
260260
},
261261
];
@@ -343,7 +343,8 @@ function generateCoverageOption(
343343
return {
344344
enabled: true,
345345
excludeAfterRemap: true,
346-
// Special handling for `reporter` due to an undefined value causing upstream failures
346+
// Special handling for `exclude`/`reporters` due to an undefined value causing upstream failures
347+
...(codeCoverage.exclude ? { exclude: codeCoverage.exclude } : {}),
347348
...(codeCoverage.reporters
348349
? ({ reporter: codeCoverage.reporters } satisfies VitestCoverageOption)
349350
: {}),

packages/angular/build/src/builders/unit-test/schema.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,7 @@
6060
"description": "Globs to exclude from code coverage.",
6161
"items": {
6262
"type": "string"
63-
},
64-
"default": []
63+
}
6564
},
6665
"codeCoverageReporters": {
6766
"type": "array",

0 commit comments

Comments
 (0)