Skip to content

Commit bfcfe72

Browse files
committed
test_runner: feat add coverage threshold for tests
1 parent 14e3444 commit bfcfe72

9 files changed

Lines changed: 197 additions & 55 deletions

File tree

index.mjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { spec } from 'node:test/reporters';
2+
import { run } from 'node:test';
3+
import process from 'node:process';
4+
5+
run({ files: ['./index.test.mjs'], coverage: {
6+
lines: 100,
7+
branches: 100,
8+
functions: 100,
9+
} })
10+
.compose(spec)
11+
.pipe(process.stdout);

index.test.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import assert from 'node:assert';
2+
import test from 'node:test';
3+
4+
test('hello test', () => {
5+
// throw new Error("error");
6+
});

lib/internal/test_runner/coverage.js

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ const kIgnoreRegex = /\/\* node:coverage ignore next (?<count>\d+ )?\*\//;
3131
const kLineEndingRegex = /\r?\n$/u;
3232
const kLineSplitRegex = /(?<=\r?\n)/u;
3333
const kStatusRegex = /\/\* node:coverage (?<status>enable|disable) \*\//;
34+
const kDefaultMinimumThreshold = {
35+
__proto__: null,
36+
lines: 0,
37+
functions: 0,
38+
branches: 0,
39+
};
3440

3541
class CoverageLine {
3642
#covered;
@@ -63,10 +69,11 @@ class CoverageLine {
6369
}
6470

6571
class TestCoverage {
66-
constructor(coverageDirectory, originalCoverageDirectory, workingDirectory) {
72+
constructor(coverageDirectory, originalCoverageDirectory, workingDirectory, minimumThreshold) {
6773
this.coverageDirectory = coverageDirectory;
6874
this.originalCoverageDirectory = originalCoverageDirectory;
6975
this.workingDirectory = workingDirectory;
76+
this.minimumThreshold = minimumThreshold || kDefaultMinimumThreshold;
7077
}
7178

7279
summary() {
@@ -260,6 +267,12 @@ class TestCoverage {
260267
);
261268
coverageSummary.files.sort(sortCoverageFiles);
262269

270+
coverageSummary.threshold = {
271+
__proto__: null,
272+
lines: doesThresholdPass(this.minimumThreshold.lines, coverageSummary.totals.coveredLinePercent),
273+
functions: doesThresholdPass(this.minimumThreshold.functions, coverageSummary.totals.coveredFunctionPercent),
274+
branches: doesThresholdPass(this.minimumThreshold.branches, coverageSummary.totals.coveredBranchPercent),
275+
};
263276
return coverageSummary;
264277
}
265278

@@ -299,7 +312,7 @@ function sortCoverageFiles(a, b) {
299312
return StringPrototypeLocaleCompare(a.path, b.path);
300313
}
301314

302-
function setupCoverage() {
315+
function setupCoverage(minimumThreshold = null) {
303316
let originalCoverageDirectory = process.env.NODE_V8_COVERAGE;
304317
const cwd = process.cwd();
305318

@@ -323,7 +336,7 @@ function setupCoverage() {
323336
// child processes.
324337
process.env.NODE_V8_COVERAGE = coverageDirectory;
325338

326-
return new TestCoverage(coverageDirectory, originalCoverageDirectory, cwd);
339+
return new TestCoverage(coverageDirectory, originalCoverageDirectory, cwd, minimumThreshold);
327340
}
328341

329342
function mapRangeToLines(range, lines) {
@@ -538,4 +551,8 @@ function doesRangeContainOtherRange(range, otherRange) {
538551
range.endOffset >= otherRange.endOffset;
539552
}
540553

554+
function doesThresholdPass(compareValue, actualValue) {
555+
return compareValue ? actualValue >= compareValue : true;
556+
}
557+
541558
module.exports = { setupCoverage, TestCoverage };

lib/internal/test_runner/harness.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
'use strict';
22
const {
3+
ArrayPrototypeFilter,
34
ArrayPrototypeForEach,
45
FunctionPrototypeBind,
56
PromiseResolve,
67
SafeMap,
8+
StringPrototypeSplit,
9+
StringPrototypeStartsWith,
710
} = primordials;
811
const { getCallerLocation } = internalBinding('util');
912
const {
@@ -78,14 +81,20 @@ function createProcessEventHandler(eventName, rootTest) {
7881
}
7982

8083
function configureCoverage(rootTest, globalOptions) {
81-
if (!globalOptions.coverage) {
82-
return null;
84+
let coverageThreshold = rootTest.coverage;
85+
if (!coverageThreshold) {
86+
if (!globalOptions.coverage) {
87+
return null;
88+
}
89+
const { 1: value } = StringPrototypeSplit(
90+
ArrayPrototypeFilter(process.execArgv, (arg) =>
91+
StringPrototypeStartsWith(arg, '--experimental-test-coverage')), '=');
92+
coverageThreshold = { __proto__: null, lines: value, functions: value, branches: value };
8393
}
84-
8594
const { setupCoverage } = require('internal/test_runner/coverage');
8695

8796
try {
88-
return setupCoverage();
97+
return setupCoverage(coverageThreshold);
8998
} catch (err) {
9099
const msg = `Warning: Code coverage could not be enabled. ${err}`;
91100

lib/internal/test_runner/runner.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,7 @@ function run(options = kEmptyObject) {
439439
validateObject(options, 'options');
440440

441441
let { testNamePatterns, shard } = options;
442-
const { concurrency, timeout, signal, files, inspectPort, watch, setup, only } = options;
442+
const { concurrency, timeout, signal, files, inspectPort, watch, setup, only, coverage } = options;
443443

444444
if (files != null) {
445445
validateArray(files, 'options.files');
@@ -486,7 +486,15 @@ function run(options = kEmptyObject) {
486486
});
487487
}
488488

489-
const root = createTestTree({ __proto__: null, concurrency, timeout, signal });
489+
if (coverage != null) {
490+
if (typeof coverage === 'boolean') {
491+
validateBoolean(coverage, 'options.coverage');
492+
} else {
493+
validateObject(coverage, 'options.coverage');
494+
}
495+
}
496+
497+
const root = createTestTree({ __proto__: null, concurrency, timeout, signal, coverage });
490498
if (process.env.NODE_TEST_CONTEXT !== undefined) {
491499
return root.reporter;
492500
}

lib/internal/test_runner/test.js

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use strict';
22
const {
3+
ArrayPrototypeFilter,
4+
ArrayPrototypeJoin,
35
ArrayPrototypePush,
46
ArrayPrototypePushApply,
57
ArrayPrototypeReduce,
@@ -10,6 +12,8 @@ const {
1012
FunctionPrototype,
1113
MathMax,
1214
Number,
15+
ObjectDefineProperty,
16+
ObjectKeys,
1317
ObjectSeal,
1418
PromisePrototypeThen,
1519
PromiseResolve,
@@ -21,7 +25,6 @@ const {
2125
SafePromiseAll,
2226
SafePromiseRace,
2327
SymbolDispose,
24-
ObjectDefineProperty,
2528
Symbol,
2629
} = primordials;
2730
const { getCallerLocation } = internalBinding('util');
@@ -49,6 +52,7 @@ const {
4952
once: runOnce,
5053
} = require('internal/util');
5154
const { isPromise } = require('internal/util/types');
55+
const console = require('internal/console/global');
5256
const {
5357
validateAbortSignal,
5458
validateNumber,
@@ -209,7 +213,7 @@ class Test extends AsyncResource {
209213
super('Test');
210214

211215
let { fn, name, parent, skip } = options;
212-
const { concurrency, loc, only, timeout, todo, signal } = options;
216+
const { concurrency, loc, only, timeout, todo, signal, coverage } = options;
213217

214218
if (typeof fn !== 'function') {
215219
fn = noop;
@@ -239,6 +243,7 @@ class Test extends AsyncResource {
239243
beforeEach: [],
240244
afterEach: [],
241245
};
246+
this.coverage = coverage;
242247
} else {
243248
const nesting = parent.parent === null ? parent.nesting :
244249
parent.nesting + 1;
@@ -258,6 +263,7 @@ class Test extends AsyncResource {
258263
beforeEach: ArrayPrototypeSlice(parent.hooks.beforeEach),
259264
afterEach: ArrayPrototypeSlice(parent.hooks.afterEach),
260265
};
266+
this.coverage = parent.coverage;
261267
}
262268

263269
switch (typeof concurrency) {
@@ -670,7 +676,9 @@ class Test extends AsyncResource {
670676
// postRun() method is called when the process is getting ready to exit.
671677
// This helps catch any asynchronous activity that occurs after the tests
672678
// have finished executing.
673-
this.postRun();
679+
try {
680+
this.postRun();
681+
} catch { /* ignore error */ }
674682
}
675683
}
676684

@@ -754,6 +762,12 @@ class Test extends AsyncResource {
754762

755763
if (coverage) {
756764
reporter.coverage(nesting, loc, coverage);
765+
const failedCoverage = ArrayPrototypeFilter(
766+
ObjectKeys(coverage.threshold), (key) => !coverage.threshold[key]);
767+
if (failedCoverage.length > 0) {
768+
console.error(`test coverage failed for ${ArrayPrototypeJoin(failedCoverage, ', ')}`);
769+
process.exit(1);
770+
}
757771
}
758772

759773
reporter.end();

lib/internal/test_runner/utils.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,19 @@ const {
33
ArrayPrototypeJoin,
44
ArrayPrototypeMap,
55
ArrayPrototypeFlatMap,
6+
ArrayPrototypeForEach,
67
ArrayPrototypePush,
78
ArrayPrototypeReduce,
89
ObjectGetOwnPropertyDescriptor,
10+
ObjectKeys,
911
MathFloor,
1012
MathMax,
1113
MathMin,
1214
NumberPrototypeToFixed,
13-
SafePromiseAllReturnArrayLike,
1415
RegExp,
1516
RegExpPrototypeExec,
1617
SafeMap,
18+
SafePromiseAllReturnArrayLike,
1719
StringPrototypePadStart,
1820
StringPrototypePadEnd,
1921
StringPrototypeRepeat,
@@ -316,6 +318,7 @@ const kSeparator = ' | ';
316318

317319
function getCoverageReport(pad, summary, symbol, color, table) {
318320
const prefix = `${pad}${symbol}`;
321+
color ||= white;
319322
let report = `${color}${prefix}start of coverage report\n`;
320323

321324
let filePadLength;
@@ -405,6 +408,12 @@ function getCoverageReport(pad, summary, symbol, color, table) {
405408
`${ArrayPrototypeJoin(ArrayPrototypeMap(kColumnsKeys, (columnKey, j) => getCell(NumberPrototypeToFixed(summary.totals[columnKey], 2), columnPadLengths[j], StringPrototypePadStart, false, summary.totals[columnKey])), kSeparator)} |\n`;
406409
if (table) report += addTableLine(prefix, tableWidth);
407410

411+
ArrayPrototypeForEach(ObjectKeys(summary.threshold), (key) => {
412+
if (!summary.threshold[key]) {
413+
report += `${red}${prefix}coverage threshold for ${key} not met\n${color}`;
414+
}
415+
});
416+
408417
report += `${prefix}end of coverage report\n`;
409418
if (color) {
410419
report += white;

0 commit comments

Comments
 (0)