Skip to content

Commit 6b13254

Browse files
test_runner: create flag --check-coverage to enforce code coverage
1 parent 515b007 commit 6b13254

14 files changed

+330
-8
lines changed

doc/api/cli.md

+60
Original file line numberDiff line numberDiff line change
@@ -892,6 +892,66 @@ generated as part of the test runner output. If no tests are run, a coverage
892892
report is not generated. See the documentation on
893893
[collecting code coverage from tests][] for more details.
894894

895+
### `--check-coverage`
896+
897+
<!-- YAML
898+
added:
899+
- REPLACEME
900+
-->
901+
902+
> Stability: 1 - Experimental
903+
904+
The `--check-coverage` CLI flag, used in conjunction with
905+
the `--experimental-test-coverage` commands, enforce
906+
that test coverage thresholds on specified checks
907+
(`--lines`, `--branches`, `--functions`) are respected.
908+
909+
### `--lines`
910+
911+
<!-- YAML
912+
added:
913+
- REPLACEME
914+
-->
915+
916+
> Stability: 1 - Experimental
917+
918+
The `--lines` CLI flag, used in conjunction with the `--check-coverage` flag,
919+
enforces coverage thresholds check on lines of code covered by test.
920+
It is expressed as a numerical value between `0` and `100`,
921+
representing the percentage (e.g., 80 for 80% coverage).
922+
If the coverage falls below the threshold, the test will result in a failure.
923+
924+
### `--branches`
925+
926+
<!-- YAML
927+
added:
928+
- REPLACEME
929+
-->
930+
931+
> Stability: 1 - Experimental
932+
933+
The `--branches` CLI flag, used in conjunction with the `--check-coverage` flag,
934+
enforces coverage thresholds check of branches covered by test.
935+
It is expressed as a numerical value between `0` and `100`,
936+
representing the percentage (e.g., 80 for 80% coverage).
937+
If the coverage falls below the threshold, the test will result in a failure.
938+
939+
### `--functions`
940+
941+
<!-- YAML
942+
added:
943+
- REPLACEME
944+
-->
945+
946+
> Stability: 1 - Experimental
947+
948+
The `--functions` CLI flag, used in conjunction
949+
with the `--check-coverage` flag,
950+
enforces coverage thresholds check of functions covered by test.
951+
It is expressed as a numerical value between `0` and `100`,
952+
representing the percentage (e.g., 80 for 80% coverage).
953+
If the coverage falls below the threshold, the test will result in a failure.
954+
895955
### `--experimental-vm-modules`
896956

897957
<!-- YAML

doc/api/test.md

+16
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,21 @@ if (anAlwaysFalseCondition) {
403403
}
404404
```
405405

406+
By using the CLI flag [`--check-coverage`][]
407+
in conjunction with the `--experimental-test-coverage` command,
408+
it is possible to enforce a specific test coverage threshold checks
409+
(`--lines`, `--branches`, `--functions`).
410+
When enabled, it evaluates the test coverage achieved during
411+
the execution of tests and determines whether it meets
412+
or exceeds the specified coverage threshold checks.
413+
If the coverage checks falls below the specified threshold,
414+
the command will result in a failure,
415+
indicating that the desired test coverage has not been reached.
416+
417+
```bash
418+
node --test --experimental-test-coverage --check-coverage --lines=100 --branches=100 --function=100
419+
```
420+
406421
### Coverage reporters
407422

408423
The tap and spec reporters will print a summary of the coverage statistics.
@@ -2966,6 +2981,7 @@ added:
29662981

29672982
[TAP]: https://testanything.org/
29682983
[TTY]: tty.md
2984+
[`--check-coverage`]: cli.md#--check-coverage
29692985
[`--experimental-test-coverage`]: cli.md#--experimental-test-coverage
29702986
[`--import`]: cli.md#--importmodule
29712987
[`--test-concurrency`]: cli.md#--test-concurrency

doc/node.1

+18
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,24 @@ Allow spawning process when using the permission model.
9191
.It Fl -allow-worker
9292
Allow creating worker threads when using the permission model.
9393
.
94+
.It Fl -check-coverage
95+
Enforce a test coverage thresholds check
96+
when used with the
97+
.Fl -experimental-test-coverage
98+
flag.
99+
.
100+
.It Fl -lines
101+
Enforce a minimum threshold of
102+
lines of code covered by test coverage (0 - 100).
103+
.
104+
.It Fl -branches
105+
Enforce a minimum threshold of
106+
branches covered by test coverage (0 - 100).
107+
.
108+
.It Fl -functions
109+
Enforce a minimum threshold of
110+
functions covered by test coverage (0 - 100).
111+
.
94112
.It Fl -completion-bash
95113
Print source-able bash completion script for Node.js.
96114
.

lib/internal/test_runner/test.js

+24-2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const {
1010
FunctionPrototype,
1111
MathMax,
1212
Number,
13+
NumberPrototypeToFixed,
1314
ObjectSeal,
1415
PromisePrototypeThen,
1516
PromiseResolve,
@@ -58,6 +59,7 @@ const {
5859
const { setTimeout } = require('timers');
5960
const { TIMEOUT_MAX } = require('internal/timers');
6061
const { availableParallelism } = require('os');
62+
const { exitCodes: { kGenericUserError } } = internalBinding('errors');
6163
const { bigint: hrtime } = process.hrtime;
6264
const kCallbackAndPromisePresent = 'callbackAndPromisePresent';
6365
const kCancelledByParent = 'cancelledByParent';
@@ -75,7 +77,7 @@ const kHookNames = ObjectSeal(['before', 'after', 'beforeEach', 'afterEach']);
7577
const kUnwrapErrors = new SafeSet()
7678
.add(kTestCodeFailure).add(kHookFailure)
7779
.add('uncaughtException').add('unhandledRejection');
78-
const { testNamePatterns, testOnlyFlag } = parseCommandLine();
80+
const { testNamePatterns, testOnlyFlag, coverageCheckThresholds } = parseCommandLine();
7981
let kResistStopPropagation;
8082

8183
function stopTest(timeout, signal) {
@@ -753,13 +755,33 @@ class Test extends AsyncResource {
753755
reporter.diagnostic(nesting, loc, `duration_ms ${this.duration()}`);
754756

755757
if (coverage) {
756-
reporter.coverage(nesting, loc, coverage);
758+
if (coverageCheckThresholds) {
759+
const { coveredLinesThreshold,
760+
coveredBranchesThreshold,
761+
coveredFunctionsThreshold } = coverageCheckThresholds;
762+
const { coveredLinePercent, coveredBranchPercent, coveredFunctionPercent } = coverage.totals;
763+
const cb = (msg) => reporter.stderr(loc, msg);
764+
this.checkCoverageThreshold(coveredLinePercent, coveredLinesThreshold, 'Lines', cb);
765+
this.checkCoverageThreshold(coveredBranchPercent, coveredBranchesThreshold, 'Branches', cb);
766+
this.checkCoverageThreshold(coveredFunctionPercent, coveredFunctionsThreshold, 'Functions', cb);
767+
768+
}
769+
reporter.coverage(nesting, loc, coverage, coverageCheckThresholds);
757770
}
758771

759772
reporter.end();
760773
}
761774
}
762775

776+
checkCoverageThreshold(actual, expected, type, cb) {
777+
if (actual < expected) {
778+
const msg = `ERROR: ${type} coverage (${NumberPrototypeToFixed(actual, 2)}%) does not meet expected threshold (${expected}%)\n`;
779+
process.exitCode = kGenericUserError;
780+
cb(msg);
781+
}
782+
}
783+
784+
763785
isClearToSend() {
764786
return this.parent === null ||
765787
(

lib/internal/test_runner/tests_stream.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,12 @@ class TestsStream extends Readable {
114114
this[kEmitMessage]('test:stdout', { __proto__: null, message, ...loc });
115115
}
116116

117-
coverage(nesting, loc, summary) {
117+
coverage(nesting, loc, summary, coverageCheckThresholds) {
118118
this[kEmitMessage]('test:coverage', {
119119
__proto__: null,
120120
nesting,
121121
summary,
122+
coverageCheckThresholds,
122123
...loc,
123124
});
124125
}

lib/internal/test_runner/utils.js

+46-5
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,46 @@ function parseCommandLine() {
193193

194194
const isTestRunner = getOptionValue('--test');
195195
const coverage = getOptionValue('--experimental-test-coverage');
196+
const checkCoverage = getOptionValue('--check-coverage');
197+
let coverageCheckThresholds;
198+
199+
if (checkCoverage) {
200+
const coveredLinesThreshold = getOptionValue('--lines');
201+
202+
if (coveredLinesThreshold < 0 || coveredLinesThreshold > 100) {
203+
throw new ERR_INVALID_ARG_VALUE(
204+
'--lines',
205+
coveredLinesThreshold,
206+
'must be a value between 0 and 100',
207+
);
208+
}
209+
210+
const coveredBranchesThreshold = getOptionValue('--branches');
211+
if (coveredBranchesThreshold < 0 || coveredBranchesThreshold > 100) {
212+
throw new ERR_INVALID_ARG_VALUE(
213+
'--branches',
214+
coveredBranchesThreshold,
215+
'must be a value between 0 and 100',
216+
);
217+
}
218+
219+
const coveredFunctionsThreshold = getOptionValue('--functions');
220+
if (coveredFunctionsThreshold < 0 || coveredFunctionsThreshold > 100) {
221+
throw new ERR_INVALID_ARG_VALUE(
222+
'--functions',
223+
coveredFunctionsThreshold,
224+
'must be a value between 0 and 100',
225+
);
226+
}
227+
228+
coverageCheckThresholds = {
229+
__proto__: null,
230+
coveredLinesThreshold,
231+
coveredBranchesThreshold,
232+
coveredFunctionsThreshold,
233+
};
234+
}
235+
196236
const isChildProcess = process.env.NODE_TEST_CONTEXT === 'child';
197237
const isChildProcessV8 = process.env.NODE_TEST_CONTEXT === 'child-v8';
198238
let destinations;
@@ -244,6 +284,7 @@ function parseCommandLine() {
244284
__proto__: null,
245285
isTestRunner,
246286
coverage,
287+
coverageCheckThresholds,
247288
testOnlyFlag,
248289
testNamePatterns,
249290
reporters,
@@ -386,8 +427,8 @@ function getCoverageReport(pad, summary, symbol, color, table) {
386427
// Head
387428
if (table) report += addTableLine(prefix, tableWidth);
388429
report += `${prefix}${getCell('file', filePadLength, StringPrototypePadEnd, truncateEnd)}${kSeparator}` +
389-
`${ArrayPrototypeJoin(ArrayPrototypeMap(kColumns, (column, i) => getCell(column, columnPadLengths[i], StringPrototypePadStart)), kSeparator)}${kSeparator}` +
390-
`${getCell('uncovered lines', uncoveredLinesPadLength, false, truncateEnd)}\n`;
430+
`${ArrayPrototypeJoin(ArrayPrototypeMap(kColumns, (column, i) => getCell(column, columnPadLengths[i], StringPrototypePadStart)), kSeparator)}${kSeparator}` +
431+
`${getCell('uncovered lines', uncoveredLinesPadLength, false, truncateEnd)}\n`;
391432
if (table) report += addTableLine(prefix, tableWidth);
392433

393434
// Body
@@ -404,14 +445,14 @@ function getCoverageReport(pad, summary, symbol, color, table) {
404445
fileCoverage /= kColumnsKeys.length;
405446

406447
report += `${prefix}${getCell(relativePath, filePadLength, StringPrototypePadEnd, truncateStart, fileCoverage)}${kSeparator}` +
407-
`${ArrayPrototypeJoin(ArrayPrototypeMap(coverages, (coverage, j) => getCell(NumberPrototypeToFixed(coverage, 2), columnPadLengths[j], StringPrototypePadStart, false, coverage)), kSeparator)}${kSeparator}` +
408-
`${getCell(formatUncoveredLines(getUncoveredLines(file.lines), table), uncoveredLinesPadLength, false, truncateEnd)}\n`;
448+
`${ArrayPrototypeJoin(ArrayPrototypeMap(coverages, (coverage, j) => getCell(NumberPrototypeToFixed(coverage, 2), columnPadLengths[j], StringPrototypePadStart, false, coverage)), kSeparator)}${kSeparator}` +
449+
`${getCell(formatUncoveredLines(getUncoveredLines(file.lines), table), uncoveredLinesPadLength, false, truncateEnd)}\n`;
409450
}
410451

411452
// Foot
412453
if (table) report += addTableLine(prefix, tableWidth);
413454
report += `${prefix}${getCell('all files', filePadLength, StringPrototypePadEnd, truncateEnd)}${kSeparator}` +
414-
`${ArrayPrototypeJoin(ArrayPrototypeMap(kColumnsKeys, (columnKey, j) => getCell(NumberPrototypeToFixed(summary.totals[columnKey], 2), columnPadLengths[j], StringPrototypePadStart, false, summary.totals[columnKey])), kSeparator)} |\n`;
455+
`${ArrayPrototypeJoin(ArrayPrototypeMap(kColumnsKeys, (columnKey, j) => getCell(NumberPrototypeToFixed(summary.totals[columnKey], 2), columnPadLengths[j], StringPrototypePadStart, false, summary.totals[columnKey])), kSeparator)} |\n`;
415456
if (table) report += addTableLine(prefix, tableWidth);
416457

417458
report += `${prefix}end of coverage report\n`;

src/node_options.cc

+12
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,18 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
622622
AddOption("--experimental-test-coverage",
623623
"enable code coverage in the test runner",
624624
&EnvironmentOptions::test_runner_coverage);
625+
AddOption("--check-coverage",
626+
"check that coverage falls within the thresholds provided",
627+
&EnvironmentOptions::test_runner_check_coverage);
628+
AddOption("--lines",
629+
"coverage threshold for lines of code",
630+
&EnvironmentOptions::test_runner_check_coverage_lines);
631+
AddOption("--branches",
632+
"coverage threshold for code branches",
633+
&EnvironmentOptions::test_runner_check_coverage_branches);
634+
AddOption("--functions",
635+
"coverage threshold for functions",
636+
&EnvironmentOptions::test_runner_check_coverage_functions);
625637
AddOption("--test-name-pattern",
626638
"run tests whose name matches this regular expression",
627639
&EnvironmentOptions::test_name_pattern);

src/node_options.h

+4
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,10 @@ class EnvironmentOptions : public Options {
166166
uint64_t test_runner_concurrency = 0;
167167
uint64_t test_runner_timeout = 0;
168168
bool test_runner_coverage = false;
169+
bool test_runner_check_coverage = false;
170+
uint64_t test_runner_check_coverage_lines = 0;
171+
uint64_t test_runner_check_coverage_branches = 0;
172+
uint64_t test_runner_check_coverage_functions = 0;
169173
std::vector<std::string> test_name_pattern;
170174
std::vector<std::string> test_reporter;
171175
std::vector<std::string> test_reporter_destination;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Flags: --expose-internals --experimental-test-coverage --check-coverage --lines=100 --branches=100 --functions=100
2+
3+
'use strict';
4+
require('../../../common');
5+
const { TestCoverage } = require('internal/test_runner/coverage');
6+
const { test, mock } = require('node:test');
7+
8+
mock.method(TestCoverage.prototype, 'summary', () => {
9+
return {
10+
files: [],
11+
totals: {
12+
totalLineCount: 100,
13+
totalBranchCount: 100,
14+
totalFunctionCount: 100,
15+
coveredLineCount: 100,
16+
coveredBranchCount: 100,
17+
coveredFunctionCount: 100,
18+
coveredLinePercent: 100,
19+
coveredBranchPercent: 100,
20+
coveredFunctionPercent: 100
21+
}
22+
}
23+
});
24+
25+
test('ok');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
TAP version 13
2+
# Subtest: ok
3+
ok 1 - ok
4+
---
5+
duration_ms: *
6+
...
7+
1..1
8+
# tests 1
9+
# suites 0
10+
# pass 1
11+
# fail 0
12+
# cancelled 0
13+
# skipped 0
14+
# todo 0
15+
# duration_ms *
16+
# start of coverage report
17+
# -----------------------------------------------------
18+
# file | line % | branch % | funcs % | uncovered lines
19+
# -----------------------------------------------------
20+
# -----------------------------------------------------
21+
# all… | 100.00 | 100.00 | 100.00 |
22+
# -----------------------------------------------------
23+
# end of coverage report
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Flags: --expose-internals --experimental-test-coverage --check-coverage --lines=100 --branches=100 --functions=100
2+
3+
'use strict';
4+
require('../../../common');
5+
const { TestCoverage } = require('internal/test_runner/coverage');
6+
const { test, mock } = require('node:test');
7+
8+
mock.method(TestCoverage.prototype, 'summary', () => {
9+
return {
10+
files: [],
11+
totals: {
12+
totalLineCount: 0,
13+
totalBranchCount: 0,
14+
totalFunctionCount: 0,
15+
coveredLineCount: 0,
16+
coveredBranchCount: 0,
17+
coveredFunctionCount: 0,
18+
coveredLinePercent: 0,
19+
coveredBranchPercent: 0,
20+
coveredFunctionPercent: 0
21+
}
22+
}
23+
});
24+
25+
test('ok');

0 commit comments

Comments
 (0)