Skip to content

Commit 3b7dabb

Browse files
committed
feat(@angular/build): add advanced coverage options to unit-test builder
The `unit-test` builder is enhanced with several new coverage features to provide a more robust and configurable testing experience. This change refactors the existing `codeCoverage` options to a more concise `coverage` prefix for a cleaner API. Since the builder is experimental, this is the ideal time for such an improvement. The following new options have been added: - `coverageAll`: Includes all files matching `coverageInclude` in the report, ensuring untested files are visible. - `coverageInclude`: Specifies which files to include in the report, providing more accurate metrics. - `coverageThresholds`: Allows setting minimum coverage percentages for statements, branches, functions, and lines. If thresholds are not met, the builder will exit with an error, enabling automated quality gates in CI. - `coverageWatermarks`: Allows customization of coverage watermarks for the HTML reporter. The Karma runner has been updated to support the `thresholds` and `watermarks` options, providing a better experience for users on that runner. Warnings remain for options that are still unsupported.
1 parent 7761180 commit 3b7dabb

File tree

10 files changed

+159
-38
lines changed

10 files changed

+159
-38
lines changed

goldens/public-api/angular/build/index.api.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,9 +217,13 @@ export type NgPackagrBuilderOptions = {
217217
export type UnitTestBuilderOptions = {
218218
browsers?: string[];
219219
buildTarget: string;
220-
codeCoverage?: boolean;
221-
codeCoverageExclude?: string[];
222-
codeCoverageReporters?: SchemaCodeCoverageReporter[];
220+
coverage?: boolean;
221+
coverageAll?: boolean;
222+
coverageExclude?: string[];
223+
coverageInclude?: string[];
224+
coverageReporters?: SchemaCoverageReporter[];
225+
coverageThresholds?: CoverageThresholds;
226+
coverageWatermarks?: CoverageWatermarks;
223227
debug?: boolean;
224228
dumpVirtualFiles?: boolean;
225229
exclude?: string[];

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,21 @@ export async function normalizeOptions(
8282
exclude: options.exclude,
8383
filter,
8484
runnerName: runner,
85-
codeCoverage: options.codeCoverage
85+
coverage: options.coverage
8686
? {
87-
exclude: options.codeCoverageExclude,
88-
reporters: normalizeReporterOption(options.codeCoverageReporters),
87+
all: options.coverageAll,
88+
exclude: options.coverageExclude,
89+
include: options.coverageInclude,
90+
reporters: normalizeReporterOption(options.coverageReporters),
91+
thresholds: options.coverageThresholds,
92+
// The schema generation tool doesn't support tuple types for items, but the schema validation
93+
// does ensure that the array has exactly two numbers.
94+
watermarks: options.coverageWatermarks as {
95+
statements?: [number, number];
96+
branches?: [number, number];
97+
functions?: [number, number];
98+
lines?: [number, number];
99+
},
89100
}
90101
: undefined,
91102
tsConfig,

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

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ export class KarmaExecutor implements TestExecutor {
3333
);
3434
}
3535

36+
if (unitTestOptions.coverage?.all) {
37+
context.logger.warn(
38+
'The "karma" test runner does not support the "coverageAll" option. The option will be ignored.',
39+
);
40+
}
41+
42+
if (unitTestOptions.coverage?.include) {
43+
context.logger.warn(
44+
'The "karma" test runner does not support the "coverageInclude" option. The option will be ignored.',
45+
);
46+
}
47+
3648
const buildTargetOptions = (await context.validateOptions(
3749
await context.getTargetOptions(unitTestOptions.buildTarget),
3850
await context.getBuilderNameForTarget(unitTestOptions.buildTarget),
@@ -57,8 +69,8 @@ export class KarmaExecutor implements TestExecutor {
5769
poll: buildTargetOptions.poll,
5870
preserveSymlinks: buildTargetOptions.preserveSymlinks,
5971
browsers: unitTestOptions.browsers?.join(','),
60-
codeCoverage: !!unitTestOptions.codeCoverage,
61-
codeCoverageExclude: unitTestOptions.codeCoverage?.exclude,
72+
codeCoverage: !!unitTestOptions.coverage,
73+
codeCoverageExclude: unitTestOptions.coverage?.exclude,
6274
fileReplacements: buildTargetOptions.fileReplacements,
6375
reporters: unitTestOptions.reporters?.map((reporter) => {
6476
// Karma only supports string reporters.
@@ -92,6 +104,23 @@ export class KarmaExecutor implements TestExecutor {
92104
options.client.args.push('--grep', filter);
93105
}
94106

107+
// Add coverage options
108+
if (unitTestOptions.coverage) {
109+
const { thresholds, watermarks } = unitTestOptions.coverage;
110+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
111+
const coverageReporter = ((options as any).coverageReporter ??= {});
112+
113+
if (thresholds) {
114+
coverageReporter.check = thresholds.perFile
115+
? { each: thresholds }
116+
: { global: thresholds };
117+
}
118+
119+
if (watermarks) {
120+
coverageReporter.watermarks = watermarks;
121+
}
122+
}
123+
95124
return options;
96125
},
97126
} satisfies KarmaBuilderTransformsOptions;

packages/angular/build/src/builders/unit-test/runners/karma/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const KarmaTestRunner: TestRunner = {
3030
}
3131
}
3232

33-
if (options.codeCoverage) {
33+
if (options.coverage) {
3434
checker.check('karma-coverage');
3535
}
3636

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

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,7 @@ export class VitestExecutor implements TestExecutor {
126126
}
127127

128128
private async initializeVitest(): Promise<Vitest> {
129-
const { codeCoverage, reporters, outputFile, workspaceRoot, browsers, debug, watch } =
130-
this.options;
129+
const { coverage, reporters, outputFile, workspaceRoot, browsers, debug, watch } = this.options;
131130
let vitestNodeModule;
132131
try {
133132
vitestNodeModule = await loadEsmModule<typeof import('vitest/node')>('vitest/node');
@@ -190,7 +189,7 @@ export class VitestExecutor implements TestExecutor {
190189
reporters: reporters ?? ['default'],
191190
outputFile,
192191
watch,
193-
coverage: generateCoverageOption(codeCoverage, this.projectName),
192+
coverage: generateCoverageOption(coverage, this.projectName),
194193
...debugOptions,
195194
},
196195
{
@@ -206,23 +205,27 @@ export class VitestExecutor implements TestExecutor {
206205
}
207206

208207
function generateCoverageOption(
209-
codeCoverage: NormalizedUnitTestBuilderOptions['codeCoverage'],
208+
coverage: NormalizedUnitTestBuilderOptions['coverage'],
210209
projectName: string,
211210
): VitestCoverageOption {
212-
if (!codeCoverage) {
211+
if (!coverage) {
213212
return {
214213
enabled: false,
215214
};
216215
}
217216

218217
return {
219218
enabled: true,
219+
all: coverage.all,
220220
excludeAfterRemap: true,
221+
include: coverage.include,
221222
reportsDirectory: toPosixPath(path.join('coverage', projectName)),
223+
thresholds: coverage.thresholds,
224+
watermarks: coverage.watermarks,
222225
// Special handling for `exclude`/`reporters` due to an undefined value causing upstream failures
223-
...(codeCoverage.exclude ? { exclude: codeCoverage.exclude } : {}),
224-
...(codeCoverage.reporters
225-
? ({ reporter: codeCoverage.reporters } satisfies VitestCoverageOption)
226+
...(coverage.exclude ? { exclude: coverage.exclude } : {}),
227+
...(coverage.reporters
228+
? ({ reporter: coverage.reporters } satisfies VitestCoverageOption)
226229
: {}),
227230
};
228231
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const VitestTestRunner: TestRunner = {
3333
checker.check('jsdom');
3434
}
3535

36-
if (options.codeCoverage) {
36+
if (options.coverage) {
3737
checker.check('@vitest/coverage-v8');
3838
}
3939

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

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,21 +54,33 @@
5454
"description": "Enables debugging mode for tests, allowing the use of the Node Inspector.",
5555
"default": false
5656
},
57-
"codeCoverage": {
57+
"coverage": {
5858
"type": "boolean",
59-
"description": "Enables code coverage reporting for tests.",
59+
"description": "Enables coverage reporting for tests.",
6060
"default": false
6161
},
62-
"codeCoverageExclude": {
62+
"coverageAll": {
63+
"type": "boolean",
64+
"description": "Includes all files that match the `coverageInclude` pattern in the coverage report, not just those touched by tests.",
65+
"default": true
66+
},
67+
"coverageInclude": {
6368
"type": "array",
64-
"description": "Specifies glob patterns of files to exclude from the code coverage report.",
69+
"description": "Specifies glob patterns of files to include in the coverage report.",
6570
"items": {
6671
"type": "string"
6772
}
6873
},
69-
"codeCoverageReporters": {
74+
"coverageExclude": {
7075
"type": "array",
71-
"description": "Specifies the reporters to use for code coverage results. Each reporter can be a string representing its name, or a tuple containing the name and an options object. Built-in reporters include 'html', 'lcov', 'lcovonly', 'text', 'text-summary', 'cobertura', 'json', and 'json-summary'.",
76+
"description": "Specifies glob patterns of files to exclude from the coverage report.",
77+
"items": {
78+
"type": "string"
79+
}
80+
},
81+
"coverageReporters": {
82+
"type": "array",
83+
"description": "Specifies the reporters to use for coverage results. Each reporter can be a string representing its name, or a tuple containing the name and an options object. Built-in reporters include 'html', 'lcov', 'lcovonly', 'text', 'text-summary', 'cobertura', 'json', and 'json-summary'.",
7284
"items": {
7385
"oneOf": [
7486
{
@@ -108,6 +120,68 @@
108120
]
109121
}
110122
},
123+
"coverageThresholds": {
124+
"type": "object",
125+
"description": "Specifies minimum coverage thresholds that must be met. If thresholds are not met, the builder will exit with an error.",
126+
"properties": {
127+
"perFile": {
128+
"type": "boolean",
129+
"description": "When true, thresholds are enforced for each file individually."
130+
},
131+
"statements": {
132+
"type": "number",
133+
"description": "Minimum percentage of statements covered."
134+
},
135+
"branches": {
136+
"type": "number",
137+
"description": "Minimum percentage of branches covered."
138+
},
139+
"functions": {
140+
"type": "number",
141+
"description": "Minimum percentage of functions covered."
142+
},
143+
"lines": {
144+
"type": "number",
145+
"description": "Minimum percentage of lines covered."
146+
}
147+
},
148+
"additionalProperties": false
149+
},
150+
"coverageWatermarks": {
151+
"type": "object",
152+
"description": "Specifies coverage watermarks for the HTML reporter. These determine the color coding for high, medium, and low coverage.",
153+
"properties": {
154+
"statements": {
155+
"type": "array",
156+
"description": "The high and low watermarks for statements coverage. `[low, high]`",
157+
"items": { "type": "number" },
158+
"minItems": 2,
159+
"maxItems": 2
160+
},
161+
"branches": {
162+
"type": "array",
163+
"description": "The high and low watermarks for branches coverage. `[low, high]`",
164+
"items": { "type": "number" },
165+
"minItems": 2,
166+
"maxItems": 2
167+
},
168+
"functions": {
169+
"type": "array",
170+
"description": "The high and low watermarks for functions coverage. `[low, high]`",
171+
"items": { "type": "number" },
172+
"minItems": 2,
173+
"maxItems": 2
174+
},
175+
"lines": {
176+
"type": "array",
177+
"description": "The high and low watermarks for lines coverage. `[low, high]`",
178+
"items": { "type": "number" },
179+
"minItems": 2,
180+
"maxItems": 2
181+
}
182+
},
183+
"additionalProperties": false
184+
},
111185
"reporters": {
112186
"type": "array",
113187
"description": "Specifies the reporters to use during test execution. Each reporter can be a string representing its name, or a tuple containing the name and an options object. Built-in reporters include 'default', 'verbose', 'dots', 'json', 'junit', 'tap', 'tap-flat', and 'html'. You can also provide a path to a custom reporter.",

packages/angular/build/src/builders/unit-test/tests/options/code-coverage-exclude_spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
} from '../setup';
1616

1717
describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
18-
describe('Option: "codeCoverageExclude"', () => {
18+
describe('Option: "coverageExclude"', () => {
1919
beforeEach(async () => {
2020
setupApplicationTarget(harness);
2121
await harness.writeFiles({
@@ -26,7 +26,7 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
2626
it('should not exclude any files from coverage when not provided', async () => {
2727
harness.useTarget('test', {
2828
...BASE_OPTIONS,
29-
codeCoverage: true,
29+
coverage: true,
3030
});
3131

3232
const { result } = await harness.executeOnce();
@@ -38,8 +38,8 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
3838
it('should exclude files from coverage that match the glob pattern', async () => {
3939
harness.useTarget('test', {
4040
...BASE_OPTIONS,
41-
codeCoverage: true,
42-
codeCoverageExclude: ['**/error.ts'],
41+
coverage: true,
42+
coverageExclude: ['**/error.ts'],
4343
});
4444

4545
const { result } = await harness.executeOnce();

packages/angular/build/src/builders/unit-test/tests/options/code-coverage-reporters_spec.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,16 @@ import {
1515
} from '../setup';
1616

1717
describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
18-
describe('Option: "codeCoverageReporters"', () => {
18+
describe('Option: "coverageReporters"', () => {
1919
beforeEach(async () => {
2020
setupApplicationTarget(harness);
2121
});
2222

2323
it('should generate a json summary report when specified', async () => {
2424
harness.useTarget('test', {
2525
...BASE_OPTIONS,
26-
codeCoverage: true,
27-
codeCoverageReporters: ['json-summary'] as any,
26+
coverage: true,
27+
coverageReporters: ['json-summary'] as any,
2828
});
2929

3030
const { result } = await harness.executeOnce();
@@ -35,8 +35,8 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
3535
it('should generate multiple reports when specified', async () => {
3636
harness.useTarget('test', {
3737
...BASE_OPTIONS,
38-
codeCoverage: true,
39-
codeCoverageReporters: ['json-summary', 'lcov'] as any,
38+
coverage: true,
39+
coverageReporters: ['json-summary', 'lcov'] as any,
4040
});
4141

4242
const { result } = await harness.executeOnce();

packages/angular/build/src/builders/unit-test/tests/options/code-coverage_spec.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,26 +15,26 @@ import {
1515
} from '../setup';
1616

1717
describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
18-
describe('Option: "codeCoverage"', () => {
18+
describe('Option: "coverage"', () => {
1919
beforeEach(async () => {
2020
setupApplicationTarget(harness);
2121
});
2222

23-
it('should not generate a code coverage report when codeCoverage is false', async () => {
23+
it('should not generate a code coverage report when coverage is false', async () => {
2424
harness.useTarget('test', {
2525
...BASE_OPTIONS,
26-
codeCoverage: false,
26+
coverage: false,
2727
});
2828

2929
const { result } = await harness.executeOnce();
3030
expect(result?.success).toBeTrue();
3131
expect(harness.hasFile('coverage/test/index.html')).toBeFalse();
3232
});
3333

34-
it('should generate a code coverage report when codeCoverage is true', async () => {
34+
it('should generate a code coverage report when coverage is true', async () => {
3535
harness.useTarget('test', {
3636
...BASE_OPTIONS,
37-
codeCoverage: true,
37+
coverage: true,
3838
});
3939

4040
const { result } = await harness.executeOnce();

0 commit comments

Comments
 (0)