Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test_runner: added coverage threshold support for tests #51182

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,27 @@ changes:
Specify the `module` containing exported [module customization hooks][].
`module` may be any string accepted as an [`import` specifier][].

### `--experimental-minimal-test-coverage`

<!-- YAML
added: REPLACEME
-->

> Stability: 1 - Experimental

Set the minimum percentage threshold value for code coverage.
Used with `--experimental-test-coverage` flag. If the minimum threshold
value is not met, the test will exit with status code 1.

**Note:** The specified minimum coverage value will be applicable for
lines, branches, and functions. If you need to set different threshold
values for lines, branches, or functions, you can do so using the
[test run(\[options\])][test runner].

```console
$ node --test --experimental-test-coverage --experimental-minimal-test-coverage=80 tests/
```

### `--experimental-network-imports`

<!-- YAML
Expand Down Expand Up @@ -2994,6 +3015,7 @@ done
[semi-space]: https://www.memorymanagement.org/glossary/s.html#semi.space
[single executable application]: single-executable-applications.md
[test reporters]: test.md#test-reporters
[test runner]: test.md#runoptions
[timezone IDs]: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
[tracking issue for user-land snapshots]: https://github.com/nodejs/node/issues/44014
[ways that `TZ` is handled in other environments]: https://www.gnu.org/software/libc/manual/html_node/TZ-Variable.html
83 changes: 83 additions & 0 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,10 @@ if (anAlwaysFalseCondition) {
}
```

We can also declare minimum percentage threshold values for code coverage
using the [`--experimental-minimal-test-coverage`][] command-line flag followed
by minimum percentage threshold value.

### Coverage reporters

The tap and spec reporters will print a summary of the coverage statistics.
Expand Down Expand Up @@ -1140,6 +1144,15 @@ changes:
that specifies the index of the shard to run. This option is _required_.
* `total` {number} is a positive integer that specifies the total number
of shards to split the test files to. This option is _required_.
* `coverage` {boolean} Enable or disable code coverage. **Default:** `false`.
* `minimumCoverage` {Object} Specify the minimum percentage threshold value
for code coverage.
* `line` {number} percent threshold value for lines coverage.
**Default** `0`.
* `branch` {number} percent threshold value for branch coverage.
**Default** `0`.
* `function` {number} percent threshold value for functions coverage.
**Default** `0`.
* Returns: {TestsStream}

**Note:** `shard` is used to horizontally parallelize test running across
Expand Down Expand Up @@ -1174,6 +1187,52 @@ run({ files: [path.resolve('./tests/test.js')] })
.pipe(process.stdout);
```

Example to run test runner with code coverage while specifying threshold
coverage values

```mjs
import { tap } from 'node:test/reporters';
import { run } from 'node:test';
import process from 'node:process';
import path from 'node:path';

run({
files: [path.resolve('./tests/test.js')],
coverage: true,
minimumCoverage: {
line: 50,
branch: 50,
function: 50,
},
})
.on('test:fail', () => {
process.exitCode = 1;
})
.compose(tap)
.pipe(process.stdout);
```

```cjs
const { tap } = require('node:test/reporters');
const { run } = require('node:test');
const path = require('node:path');

run({
files: [path.resolve('./tests/test.js')],
coverage: true,
minimumCoverage: {
line: 50,
branch: 50,
function: 50,
},
})
.on('test:fail', () => {
process.exitCode = 1;
})
.compose(tap)
.pipe(process.stdout);
```

## `test([name][, options][, fn])`

<!-- YAML
Expand Down Expand Up @@ -2461,6 +2520,29 @@ object, streaming a series of events representing the execution of the tests.
* `coveredLinePercent` {number} The percentage of lines covered.
* `coveredBranchPercent` {number} The percentage of branches covered.
* `coveredFunctionPercent` {number} The percentage of functions covered.
* `minimumCoverage` {Object} An object containing threshold status for
minimum code coverage of lines, functions and branches.
* `line` {Object} An object containing threshold status for minimum
lines coverage.
* `status` {boolean} Indicates whether the code coverage meets the
minimum threshold for lines.
* `actual` {number} Actual percentage of lines covered.
* `expected` {number} Expected minimum percentage of lines to be
covered.
* `branch` {Object} An object containing threshold status for minimum
branch coverage.
* `status` {boolean} Indicates whether the code coverage meets the
minimum threshold for branches.
* `actual` {number} Actual percentage of branches covered.
* `expected` {number} Expected minimum percentage of branches to be
covered.
* `function` {Object} An object containing threshold status for minimum
functions coverage.
* `status` {boolean} Indicates whether the code coverage meets the
minimum threshold for functions.
* `actual` {number} Actual percentage of functions covered.
* `expected` {number} Expected minimum percentage of functions to be
covered.
* `workingDirectory` {string} The working directory when code coverage
began. This is useful for displaying relative path names in case the tests
changed the working directory of the Node.js process.
Expand Down Expand Up @@ -2966,6 +3048,7 @@ added:

[TAP]: https://testanything.org/
[TTY]: tty.md
[`--experimental-minimal-test-coverage`]: cli.md#--experimental-minimal-test-coverage
[`--experimental-test-coverage`]: cli.md#--experimental-test-coverage
[`--import`]: cli.md#--importmodule
[`--test-concurrency`]: cli.md#--test-concurrency
Expand Down
3 changes: 3 additions & 0 deletions doc/node.1
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ Use this flag to enable ShadowRealm support.
.It Fl -experimental-test-coverage
Enable code coverage in the test runner.
.
.It Fl -experimental-minimal-test-coverage
Set minimum percentage threshold value for code coverage.
.
.It Fl -experimental-websocket
Enable experimental support for the WebSocket API.
.
Expand Down
2 changes: 2 additions & 0 deletions lib/internal/main/test_runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ const options = {
setup: setupTestReporters,
timeout,
shard,
coverage: getOptionValue('--experimental-test-coverage'),
minimumCoverage: getOptionValue('--experimental-minimal-test-coverage'),
};
debug('test runner configuration:', options);
run(options).on('test:fail', (data) => {
Expand Down
38 changes: 35 additions & 3 deletions lib/internal/test_runner/coverage.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ const kIgnoreRegex = /\/\* node:coverage ignore next (?<count>\d+ )?\*\//;
const kLineEndingRegex = /\r?\n$/u;
const kLineSplitRegex = /(?<=\r?\n)/u;
const kStatusRegex = /\/\* node:coverage (?<status>enable|disable) \*\//;
const kDefaultMinimumCoverage = {
__proto__: null,
line: 0,
function: 0,
branch: 0,
};

class CoverageLine {
#covered;
Expand Down Expand Up @@ -63,10 +69,11 @@ class CoverageLine {
}

class TestCoverage {
constructor(coverageDirectory, originalCoverageDirectory, workingDirectory) {
constructor(coverageDirectory, originalCoverageDirectory, workingDirectory, minimumCoverage) {
this.coverageDirectory = coverageDirectory;
this.originalCoverageDirectory = originalCoverageDirectory;
this.workingDirectory = workingDirectory;
this.minimumCoverage = minimumCoverage || kDefaultMinimumCoverage;
}

summary() {
Expand Down Expand Up @@ -260,6 +267,27 @@ class TestCoverage {
);
coverageSummary.files.sort(sortCoverageFiles);

coverageSummary.minimumCoverage = {
__proto__: null,
line: {
__proto__: null,
status: doesCoveragePass(this.minimumCoverage.line, coverageSummary.totals.coveredLinePercent),
expected: this.minimumCoverage.line,
actual: coverageSummary.totals.coveredLinePercent,
},
function: {
__proto__: null,
status: doesCoveragePass(this.minimumCoverage.function, coverageSummary.totals.coveredFunctionPercent),
expected: this.minimumCoverage.function,
actual: coverageSummary.totals.coveredFunctionPercent,
},
branch: {
__proto__: null,
status: doesCoveragePass(this.minimumCoverage.branch, coverageSummary.totals.coveredBranchPercent),
expected: this.minimumCoverage.branch,
actual: coverageSummary.totals.coveredBranchPercent,
},
};
return coverageSummary;
}

Expand Down Expand Up @@ -299,7 +327,7 @@ function sortCoverageFiles(a, b) {
return StringPrototypeLocaleCompare(a.path, b.path);
}

function setupCoverage() {
function setupCoverage(minimumCoverage = null) {
let originalCoverageDirectory = process.env.NODE_V8_COVERAGE;
const cwd = process.cwd();

Expand All @@ -323,7 +351,7 @@ function setupCoverage() {
// child processes.
process.env.NODE_V8_COVERAGE = coverageDirectory;

return new TestCoverage(coverageDirectory, originalCoverageDirectory, cwd);
return new TestCoverage(coverageDirectory, originalCoverageDirectory, cwd, minimumCoverage);
}

function mapRangeToLines(range, lines) {
Expand Down Expand Up @@ -538,4 +566,8 @@ function doesRangeContainOtherRange(range, otherRange) {
range.endOffset >= otherRange.endOffset;
}

function doesCoveragePass(compareValue, actualValue) {
return compareValue ? actualValue >= compareValue : true;
}

module.exports = { setupCoverage, TestCoverage };
12 changes: 10 additions & 2 deletions lib/internal/test_runner/harness.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,22 @@ function createProcessEventHandler(eventName, rootTest) {
}

function configureCoverage(rootTest, globalOptions) {
if (!globalOptions.coverage) {
if (!rootTest.coverage && !globalOptions.coverage) {
return null;
}

let minimumCoverage = { __proto__: null, line: 0, function: 0, branch: 0 };
if (rootTest.minimumCoverage) {
minimumCoverage = rootTest.minimumCoverage;
} else if (globalOptions.minimumCoverage) {
const threshold = globalOptions.minimumCoverage;
minimumCoverage = { __proto__: null, line: threshold, function: threshold, branch: threshold };
}

const { setupCoverage } = require('internal/test_runner/coverage');

try {
return setupCoverage();
return setupCoverage(minimumCoverage);
} catch (err) {
const msg = `Warning: Code coverage could not be enabled. ${err}`;

Expand Down
17 changes: 14 additions & 3 deletions lib/internal/test_runner/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -439,8 +439,8 @@ function watchFiles(testFiles, opts) {
function run(options = kEmptyObject) {
validateObject(options, 'options');

let { testNamePatterns, shard } = options;
const { concurrency, timeout, signal, files, inspectPort, watch, setup, only } = options;
let { testNamePatterns, shard, minimumCoverage } = options;
const { concurrency, timeout, signal, files, inspectPort, watch, setup, only, coverage } = options;

if (files != null) {
validateArray(files, 'options.files');
Expand Down Expand Up @@ -487,7 +487,18 @@ function run(options = kEmptyObject) {
});
}

const root = createTestTree({ __proto__: null, concurrency, timeout, signal });
if (coverage != null) {
validateBoolean(coverage, 'options.coverage');
}

if (minimumCoverage != null) {
if (typeof minimumCoverage === 'number') {
minimumCoverage = { __proto__: null, branch: minimumCoverage, function: minimumCoverage, line: minimumCoverage };
}
validateObject(minimumCoverage, 'options.minimumCoverage');
}

const root = createTestTree({ __proto__: null, concurrency, timeout, signal, coverage, minimumCoverage });
root.harness.shouldColorizeTestFiles ||= shouldColorizeTestFiles(root);

if (process.env.NODE_TEST_CONTEXT !== undefined) {
Expand Down
15 changes: 13 additions & 2 deletions lib/internal/test_runner/test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use strict';
const {
ArrayPrototypeFilter,
ArrayPrototypePush,
ArrayPrototypePushApply,
ArrayPrototypeReduce,
Expand All @@ -10,6 +11,8 @@ const {
FunctionPrototype,
MathMax,
Number,
ObjectDefineProperty,
ObjectKeys,
ObjectSeal,
PromisePrototypeThen,
PromiseResolve,
Expand All @@ -21,7 +24,6 @@ const {
SafePromiseAll,
SafePromiseRace,
SymbolDispose,
ObjectDefineProperty,
Symbol,
} = primordials;
const { getCallerLocation } = internalBinding('util');
Expand Down Expand Up @@ -209,7 +211,7 @@ class Test extends AsyncResource {
super('Test');

let { fn, name, parent, skip } = options;
const { concurrency, loc, only, timeout, todo, signal } = options;
const { concurrency, loc, only, timeout, todo, signal, coverage, minimumCoverage } = options;

if (typeof fn !== 'function') {
fn = noop;
Expand Down Expand Up @@ -239,6 +241,8 @@ class Test extends AsyncResource {
beforeEach: [],
afterEach: [],
};
this.coverage = coverage;
this.minimumCoverage = minimumCoverage;
} else {
const nesting = parent.parent === null ? parent.nesting :
parent.nesting + 1;
Expand All @@ -258,6 +262,8 @@ class Test extends AsyncResource {
beforeEach: ArrayPrototypeSlice(parent.hooks.beforeEach),
afterEach: ArrayPrototypeSlice(parent.hooks.afterEach),
};
this.coverage = parent.coverage;
this.minimumCoverage = parent.minimumCoverage;
}

switch (typeof concurrency) {
Expand Down Expand Up @@ -754,6 +760,11 @@ class Test extends AsyncResource {

if (coverage) {
reporter.coverage(nesting, loc, coverage);
const failedCoverage = ArrayPrototypeFilter(
ObjectKeys(coverage.minimumCoverage), (key) => !coverage.minimumCoverage[key].status);
if (failedCoverage.length > 0) {
process.exitCode = 1;
}
}

reporter.end();
Expand Down
Loading