Skip to content

Commit ffe1e56

Browse files
committed
test_runner: add support for coverage via run()
1 parent dc74f17 commit ffe1e56

File tree

3 files changed

+263
-1
lines changed

3 files changed

+263
-1
lines changed

doc/api/test.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1246,6 +1246,9 @@ added:
12461246
- v18.9.0
12471247
- v16.19.0
12481248
changes:
1249+
- version: REPLACEME
1250+
pr-url: https://github.com/nodejs/node/pull/53937
1251+
description: Added coverage options.
12491252
- version: v22.8.0
12501253
pr-url: https://github.com/nodejs/node/pull/53927
12511254
description: Added the `isolation` option.
@@ -1319,6 +1322,29 @@ changes:
13191322
that specifies the index of the shard to run. This option is _required_.
13201323
* `total` {number} is a positive integer that specifies the total number
13211324
of shards to split the test files to. This option is _required_.
1325+
* `coverage` {boolean} enable [code coverage][] collection.
1326+
**Default:** `false`.
1327+
* `coverageExcludeGlobs` {string|Array} Excludes specific files from code coverage
1328+
using a glob pattern, which can match both absolute and relative file paths.
1329+
This property is only applicable when `coverage` was set to `true`.
1330+
If both `coverageExcludeGlobs` and `coverageIncludeGlobs` are provided,
1331+
files must meet **both** criteria to be included in the coverage report.
1332+
**Default:** `undefined`.
1333+
* `coverageIncludeGlobs` {string|Array} Includes specific files in code coverage
1334+
using a glob pattern, which can match both absolute and relative file paths.
1335+
This property is only applicable when `coverage` was set to `true`.
1336+
If both `coverageExcludeGlobs` and `coverageIncludeGlobs` are provided,
1337+
files must meet **both** criteria to be included in the coverage report.
1338+
**Default:** `undefined`.
1339+
* `lineCoverage` {number} Require a minimum percent of covered lines. If code
1340+
coverage does not reach the threshold specified, the process will exit with code `1`.
1341+
**Default:** `0`.
1342+
* `branchCoverage` {number} Require a minimum percent of covered branches. If code
1343+
coverage does not reach the threshold specified, the process will exit with code `1`.
1344+
**Default:** `0`.
1345+
* `functionCoverage` {number} Require a minimum percent of covered functions. If code
1346+
coverage does not reach the threshold specified, the process will exit with code `1`.
1347+
**Default:** `0`.
13221348
* Returns: {TestsStream}
13231349

13241350
**Note:** `shard` is used to horizontally parallelize test running across
@@ -3532,6 +3558,7 @@ Can be used to abort test subtasks when the test has been aborted.
35323558
[`run()`]: #runoptions
35333559
[`suite()`]: #suitename-options-fn
35343560
[`test()`]: #testname-options-fn
3561+
[code coverage]: #collecting-code-coverage
35353562
[describe options]: #describename-options-fn
35363563
[it options]: #testname-options-fn
35373564
[stream.compose]: stream.md#streamcomposestreams

lib/internal/test_runner/runner.js

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const {
5454
validateObject,
5555
validateOneOf,
5656
validateInteger,
57+
validateStringArray,
5758
} = require('internal/validators');
5859
const { getInspectPort, isUsingInspector, isInspectorMessage } = require('internal/util/inspector');
5960
const { isRegExp } = require('internal/util/types');
@@ -523,7 +524,13 @@ function watchFiles(testFiles, opts) {
523524
function run(options = kEmptyObject) {
524525
validateObject(options, 'options');
525526

526-
let { testNamePatterns, testSkipPatterns, shard } = options;
527+
let {
528+
testNamePatterns,
529+
testSkipPatterns,
530+
shard,
531+
coverageExcludeGlobs,
532+
coverageIncludeGlobs,
533+
} = options;
527534
const {
528535
concurrency,
529536
timeout,
@@ -536,6 +543,10 @@ function run(options = kEmptyObject) {
536543
setup,
537544
only,
538545
globPatterns,
546+
coverage,
547+
lineCoverage,
548+
branchCoverage,
549+
functionCoverage,
539550
} = options;
540551

541552
if (files != null) {
@@ -614,6 +625,65 @@ function run(options = kEmptyObject) {
614625
});
615626
}
616627
validateOneOf(isolation, 'options.isolation', ['process', 'none']);
628+
if (coverage != null) {
629+
validateBoolean(coverage, 'options.coverage');
630+
}
631+
if (coverageExcludeGlobs != null) {
632+
if (!coverage) {
633+
throw new ERR_INVALID_ARG_VALUE(
634+
'options.coverageExcludeGlobs',
635+
coverageExcludeGlobs,
636+
'is only supported when coverage is enabled',
637+
);
638+
}
639+
if (!ArrayIsArray(coverageExcludeGlobs)) {
640+
coverageExcludeGlobs = [coverageExcludeGlobs];
641+
}
642+
validateStringArray(coverageExcludeGlobs, 'options.coverageExcludeGlobs');
643+
}
644+
if (coverageIncludeGlobs != null) {
645+
if (!coverage) {
646+
throw new ERR_INVALID_ARG_VALUE(
647+
'options.coverageIncludeGlobs',
648+
coverageIncludeGlobs,
649+
'is only supported when coverage is enabled',
650+
);
651+
}
652+
if (!ArrayIsArray(coverageIncludeGlobs)) {
653+
coverageIncludeGlobs = [coverageIncludeGlobs];
654+
}
655+
validateStringArray(coverageIncludeGlobs, 'options.coverageIncludeGlobs');
656+
}
657+
if (lineCoverage != null) {
658+
if (!coverage) {
659+
throw new ERR_INVALID_ARG_VALUE(
660+
'options.lineCoverage',
661+
lineCoverage,
662+
'is only supported when coverage is enabled',
663+
);
664+
}
665+
validateInteger(lineCoverage, 'options.lineCoverage', 0, 100);
666+
}
667+
if (branchCoverage != null) {
668+
if (!coverage) {
669+
throw new ERR_INVALID_ARG_VALUE(
670+
'options.branchCoverage',
671+
branchCoverage,
672+
'is only supported when coverage is enabled',
673+
);
674+
}
675+
validateInteger(branchCoverage, 'options.branchCoverage', 0, 100);
676+
}
677+
if (functionCoverage != null) {
678+
if (!coverage) {
679+
throw new ERR_INVALID_ARG_VALUE(
680+
'options.functionCoverage',
681+
functionCoverage,
682+
'is only supported when coverage is enabled',
683+
);
684+
}
685+
validateInteger(functionCoverage, 'options.functionCoverage', 0, 100);
686+
}
617687

618688
const rootTestOptions = { __proto__: null, concurrency, timeout, signal };
619689
const globalOptions = {
@@ -622,6 +692,12 @@ function run(options = kEmptyObject) {
622692
// behavior has relied on it, so removing it must be done in a semver major.
623693
...parseCommandLine(),
624694
setup, // This line can be removed when parseCommandLine() is removed here.
695+
coverage,
696+
coverageExcludeGlobs,
697+
coverageIncludeGlobs,
698+
lineCoverage: lineCoverage ?? 0,
699+
branchCoverage: branchCoverage ?? 0,
700+
functionCoverage: functionCoverage ?? 0,
625701
};
626702
const root = createTestTree(rootTestOptions, globalOptions);
627703

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import * as common from '../common/index.mjs';
2+
import * as fixtures from '../common/fixtures.mjs';
3+
import { describe, it, run } from 'node:test';
4+
import assert from 'node:assert';
5+
6+
const files = [fixtures.path('test-runner', 'coverage.js')];
7+
const skipIfNoInspector = {
8+
skip: !process.features.inspector ? 'inspector disabled' : false
9+
};
10+
11+
describe('require(\'node:test\').run Coverage settings', { concurrency: true }, () => {
12+
describe('validation', () => {
13+
it('should only allow boolean in options.coverage', async () => {
14+
[Symbol(), {}, () => {}, 0, 1, 0n, 1n, '', '1', Promise.resolve(true), []]
15+
.forEach((coverage) => assert.throws(() => run({ coverage }), {
16+
code: 'ERR_INVALID_ARG_TYPE'
17+
}));
18+
});
19+
20+
it('should only allow coverage options when coverage is true', async () => {
21+
assert.throws(
22+
() => run({ coverage: false, coverageIncludeGlobs: [] }),
23+
{ code: 'ERR_INVALID_ARG_VALUE' },
24+
);
25+
assert.throws(
26+
() => run({ coverage: false, coverageExcludeGlobs: [] }),
27+
{ code: 'ERR_INVALID_ARG_VALUE' },
28+
);
29+
assert.throws(
30+
() => run({ coverage: false, lineCoverage: 0 }),
31+
{ code: 'ERR_INVALID_ARG_VALUE' },
32+
);
33+
assert.throws(
34+
() => run({ coverage: false, branchCoverage: 0 }),
35+
{ code: 'ERR_INVALID_ARG_VALUE' },
36+
);
37+
assert.throws(
38+
() => run({ coverage: false, functionCoverage: 0 }),
39+
{ code: 'ERR_INVALID_ARG_VALUE' },
40+
);
41+
});
42+
43+
it('should only allow string|string[] in options.coverageExcludeGlobs', async () => {
44+
[Symbol(), {}, () => {}, 0, 1, 0n, 1n, Promise.resolve([]), true, false]
45+
.forEach((coverageExcludeGlobs) => {
46+
assert.throws(() => run({ coverage: true, coverageExcludeGlobs }), {
47+
code: 'ERR_INVALID_ARG_TYPE'
48+
});
49+
assert.throws(() => run({ coverage: true, coverageExcludeGlobs: [coverageExcludeGlobs] }), {
50+
code: 'ERR_INVALID_ARG_TYPE'
51+
});
52+
});
53+
run({ files: [], signal: AbortSignal.abort(), coverage: true, coverageExcludeGlobs: [''] });
54+
run({ files: [], signal: AbortSignal.abort(), coverage: true, coverageExcludeGlobs: '' });
55+
});
56+
57+
it('should only allow string|string[] in options.coverageIncludeGlobs', async () => {
58+
[Symbol(), {}, () => {}, 0, 1, 0n, 1n, Promise.resolve([]), true, false]
59+
.forEach((coverageIncludeGlobs) => {
60+
assert.throws(() => run({ coverage: true, coverageIncludeGlobs }), {
61+
code: 'ERR_INVALID_ARG_TYPE'
62+
});
63+
assert.throws(() => run({ coverage: true, coverageIncludeGlobs: [coverageIncludeGlobs] }), {
64+
code: 'ERR_INVALID_ARG_TYPE'
65+
});
66+
});
67+
68+
run({ files: [], signal: AbortSignal.abort(), coverage: true, coverageIncludeGlobs: [''] });
69+
run({ files: [], signal: AbortSignal.abort(), coverage: true, coverageIncludeGlobs: '' });
70+
});
71+
72+
it('should only allow an int in options.lineCoverage', async () => {
73+
[Symbol(), {}, () => {}, [], 0n, 1n, Promise.resolve([]), true, false]
74+
.forEach((lineCoverage) => {
75+
assert.throws(() => run({ coverage: true, lineCoverage }), {
76+
code: 'ERR_INVALID_ARG_TYPE'
77+
});
78+
assert.throws(() => run({ coverage: true, lineCoverage: [lineCoverage] }), {
79+
code: 'ERR_INVALID_ARG_TYPE'
80+
});
81+
});
82+
83+
run({ files: [], signal: AbortSignal.abort(), coverage: true, lineCoverage: 0 });
84+
});
85+
86+
it('should only allow an int in options.branchCoverage', async () => {
87+
[Symbol(), {}, () => {}, [], 0n, 1n, Promise.resolve([]), true, false]
88+
.forEach((branchCoverage) => {
89+
assert.throws(() => run({ coverage: true, branchCoverage }), {
90+
code: 'ERR_INVALID_ARG_TYPE'
91+
});
92+
assert.throws(() => run({ coverage: true, branchCoverage: [branchCoverage] }), {
93+
code: 'ERR_INVALID_ARG_TYPE'
94+
});
95+
});
96+
97+
run({ files: [], signal: AbortSignal.abort(), coverage: true, branchCoverage: 0 });
98+
});
99+
100+
it('should only allow an int in options.functionCoverage', async () => {
101+
[Symbol(), {}, () => {}, [], 0n, 1n, Promise.resolve([]), true, false]
102+
.forEach((functionCoverage) => {
103+
assert.throws(() => run({ coverage: true, functionCoverage }), {
104+
code: 'ERR_INVALID_ARG_TYPE'
105+
});
106+
assert.throws(() => run({ coverage: true, functionCoverage: [functionCoverage] }), {
107+
code: 'ERR_INVALID_ARG_TYPE'
108+
});
109+
});
110+
111+
run({ files: [], signal: AbortSignal.abort(), coverage: true, functionCoverage: 0 });
112+
});
113+
});
114+
115+
describe('run with coverage', skipIfNoInspector, () => {
116+
it('should run with coverage', async () => {
117+
const stream = run({ files, coverage: true });
118+
stream.on('test:fail', common.mustNotCall());
119+
stream.on('test:pass', common.mustCall());
120+
stream.on('test:coverage', common.mustCall());
121+
// eslint-disable-next-line no-unused-vars
122+
for await (const _ of stream);
123+
});
124+
125+
it('should run with coverage and exclude by glob', async () => {
126+
const stream = run({ files, coverage: true, coverageExcludeGlobs: ['test/*/test-runner/invalid-tap.js'] });
127+
stream.on('test:fail', common.mustNotCall());
128+
stream.on('test:pass', common.mustCall(1));
129+
stream.on('test:coverage', common.mustCall(({ summary: { files } }) => {
130+
const filesPaths = files.map(({ path }) => path);
131+
assert.strictEqual(filesPaths.some((path) => path.includes('test-runner/invalid-tap.js')), false);
132+
}));
133+
// eslint-disable-next-line no-unused-vars
134+
for await (const _ of stream);
135+
});
136+
137+
it('should run with coverage and include by glob', async () => {
138+
const stream = run({ files, coverage: true, coverageIncludeGlobs: ['test/*/test-runner/invalid-tap.js'] });
139+
stream.on('test:fail', common.mustNotCall());
140+
stream.on('test:pass', common.mustCall(1));
141+
stream.on('test:coverage', common.mustCall(({ summary: { files } }) => {
142+
const filesPaths = files.map(({ path }) => path);
143+
assert.strictEqual(filesPaths.some((path) => path.includes('test-runner/invalid-tap.js')), true);
144+
}));
145+
// eslint-disable-next-line no-unused-vars
146+
for await (const _ of stream);
147+
});
148+
});
149+
});
150+
151+
152+
// exitHandler doesn't run until after the tests / after hooks finish.
153+
process.on('exit', () => {
154+
assert.strictEqual(process.listeners('uncaughtException').length, 0);
155+
assert.strictEqual(process.listeners('unhandledRejection').length, 0);
156+
assert.strictEqual(process.listeners('beforeExit').length, 0);
157+
assert.strictEqual(process.listeners('SIGINT').length, 0);
158+
assert.strictEqual(process.listeners('SIGTERM').length, 0);
159+
});

0 commit comments

Comments
 (0)