Skip to content

Commit db509c1

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

File tree

11 files changed

+160
-2
lines changed

11 files changed

+160
-2
lines changed

doc/api/cli.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,11 +887,28 @@ changes:
887887
description: This option can be used with `--test`.
888888
-->
889889

890+
> Stability: 1 - Experimental
891+
890892
When used in conjunction with the `node:test` module, a code coverage report is
891893
generated as part of the test runner output. If no tests are run, a coverage
892894
report is not generated. See the documentation on
893895
[collecting code coverage from tests][] for more details.
894896

897+
### `--check-coverage=coverage_threshold`
898+
899+
<!-- YAML
900+
added:
901+
- REPLACEME
902+
-->
903+
904+
> Stability: 1 - Experimental
905+
906+
The `--check-coverage` CLI flag, used in conjunction with the `--experimental-test-coverage` commands,
907+
enforce a specific test coverage threshold.
908+
It is expressed as a numerical value between `0` and `100`,
909+
representing the percentage (e.g., 80 for 80% coverage).
910+
If the coverage falls below the threshold, the test will result in a failure.
911+
895912
### `--experimental-vm-modules`
896913

897914
<!-- YAML

doc/api/test.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,20 @@ if (anAlwaysFalseCondition) {
403403
}
404404
```
405405

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

408422
The tap and spec reporters will print a summary of the coverage statistics.
@@ -2966,6 +2980,7 @@ added:
29662980

29672981
[TAP]: https://testanything.org/
29682982
[TTY]: tty.md
2983+
[`--check-coverage=coverage_threshold`]: cli.md#--check-coveragecoverage_threshold
29692984
[`--experimental-test-coverage`]: cli.md#--experimental-test-coverage
29702985
[`--import`]: cli.md#--importmodule
29712986
[`--test-concurrency`]: cli.md#--test-concurrency

doc/node.1

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ 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 minimum test coverage threshold (0 to 100)
96+
when used with the
97+
.Fl -experimental-test-coverage
98+
flag.
99+
The command fails if coverage falls below the specified threshold.
100+
.
94101
.It Fl -completion-bash
95102
Print source-able bash completion script for Node.js.
96103
.

lib/internal/test_runner/test.js

Lines changed: 9 additions & 1 deletion
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, coverageThreshold } = parseCommandLine();
7981
let kResistStopPropagation;
8082

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

755757
if (coverage) {
758+
const actualCoverage = coverage.totals.coveredLinePercent;
759+
if (actualCoverage < coverageThreshold) {
760+
const msg = `ERROR: Global coverage (${NumberPrototypeToFixed(actualCoverage, 2)}%) does not meet expected threshold (${coverageThreshold}%)\n`;
761+
reporter.stderr(loc, msg);
762+
process.exitCode = kGenericUserError;
763+
}
756764
reporter.coverage(nesting, loc, coverage);
757765
}
758766

lib/internal/test_runner/utils.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,15 @@ function parseCommandLine() {
193193

194194
const isTestRunner = getOptionValue('--test');
195195
const coverage = getOptionValue('--experimental-test-coverage');
196+
const coverageThreshold = getOptionValue('--check-coverage');
197+
if (coverageThreshold < 0 || coverageThreshold > 100) {
198+
throw new ERR_INVALID_ARG_VALUE(
199+
'--check-coverage',
200+
coverageThreshold,
201+
'must be a value between 0 and 100',
202+
);
203+
}
204+
196205
const isChildProcess = process.env.NODE_TEST_CONTEXT === 'child';
197206
const isChildProcessV8 = process.env.NODE_TEST_CONTEXT === 'child-v8';
198207
let destinations;
@@ -244,6 +253,7 @@ function parseCommandLine() {
244253
__proto__: null,
245254
isTestRunner,
246255
coverage,
256+
coverageThreshold,
247257
testOnlyFlag,
248258
testNamePatterns,
249259
reporters,
@@ -323,7 +333,7 @@ const kColumns = ['line %', 'branch %', 'funcs %'];
323333
const kColumnsKeys = ['coveredLinePercent', 'coveredBranchPercent', 'coveredFunctionPercent'];
324334
const kSeparator = ' | ';
325335

326-
function getCoverageReport(pad, summary, symbol, color, table) {
336+
function getCoverageReport(pad, summary, symbol, color, table, coverageThreshold) {
327337
const prefix = `${pad}${symbol}`;
328338
let report = `${color}${prefix}start of coverage report\n`;
329339

src/node_options.cc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,9 @@ 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 threshold provided",
627+
&EnvironmentOptions::test_runner_check_coverage);
625628
AddOption("--test-name-pattern",
626629
"run tests whose name matches this regular expression",
627630
&EnvironmentOptions::test_name_pattern);

src/node_options.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ 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+
uint64_t test_runner_check_coverage = 0;
169170
std::vector<std::string> test_name_pattern;
170171
std::vector<std::string> test_reporter;
171172
std::vector<std::string> test_reporter_destination;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Flags: --expose-internals --experimental-test-coverage --check-coverage=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');
Lines changed: 23 additions & 0 deletions
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: 0.423333
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 5.332083
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
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Flags: --expose-internals --experimental-test-coverage --check-coverage=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)