-
Notifications
You must be signed in to change notification settings - Fork 30.3k
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: add coverage support to run function #53937
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -1246,6 +1246,9 @@ added: | |
- v18.9.0 | ||
- v16.19.0 | ||
changes: | ||
- version: REPLACEME | ||
pr-url: https://github.com/nodejs/node/pull/53937 | ||
description: Added coverage options. | ||
- version: v22.8.0 | ||
pr-url: https://github.com/nodejs/node/pull/53927 | ||
description: Added the `isolation` option. | ||
|
@@ -1319,6 +1322,29 @@ 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 [code coverage][] collection. | ||
**Default:** `false`. | ||
* `coverageExcludeGlobs` {string|Array} Excludes specific files from code coverage | ||
using a glob pattern, which can match both absolute and relative file paths. | ||
This property is only applicable when `coverage` was set to `true`. | ||
If both `coverageExcludeGlobs` and `coverageIncludeGlobs` are provided, | ||
files must meet **both** criteria to be included in the coverage report. | ||
**Default:** `undefined`. | ||
* `coverageIncludeGlobs` {string|Array} Includes specific files in code coverage | ||
using a glob pattern, which can match both absolute and relative file paths. | ||
This property is only applicable when `coverage` was set to `true`. | ||
If both `coverageExcludeGlobs` and `coverageIncludeGlobs` are provided, | ||
files must meet **both** criteria to be included in the coverage report. | ||
**Default:** `undefined`. | ||
* `lineCoverage` {number} Require a minimum percent of covered lines. If code | ||
coverage does not reach the threshold specified, the process will exit with code `1`. | ||
**Default:** `0`. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should indicate what the default means. That is, does 0 actually turn off the check? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Not really, it just means even when the coverage is 0% no error is thrown... |
||
* `branchCoverage` {number} Require a minimum percent of covered branches. If code | ||
coverage does not reach the threshold specified, the process will exit with code `1`. | ||
**Default:** `0`. | ||
* `functionCoverage` {number} Require a minimum percent of covered functions. If code | ||
coverage does not reach the threshold specified, the process will exit with code `1`. | ||
**Default:** `0`. | ||
* Returns: {TestsStream} | ||
|
||
**Note:** `shard` is used to horizontally parallelize test running across | ||
|
@@ -3537,6 +3563,7 @@ Can be used to abort test subtasks when the test has been aborted. | |
[`run()`]: #runoptions | ||
[`suite()`]: #suitename-options-fn | ||
[`test()`]: #testname-options-fn | ||
[code coverage]: #collecting-code-coverage | ||
[describe options]: #describename-options-fn | ||
[it options]: #testname-options-fn | ||
[stream.compose]: stream.md#streamcomposestreams | ||
|
atlowChemi marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,186 @@ | ||
import * as common from '../common/index.mjs'; | ||
import * as fixtures from '../common/fixtures.mjs'; | ||
import { describe, it, run } from 'node:test'; | ||
import assert from 'node:assert'; | ||
import { sep } from 'node:path'; | ||
|
||
const files = [fixtures.path('test-runner', 'coverage.js')]; | ||
const abortedSignal = AbortSignal.abort(); | ||
|
||
describe('require(\'node:test\').run coverage settings', { concurrency: true }, async () => { | ||
await describe('validation', async () => { | ||
await it('should only allow boolean in options.coverage', async () => { | ||
[Symbol(), {}, () => {}, 0, 1, 0n, 1n, '', '1', Promise.resolve(true), []] | ||
.forEach((coverage) => assert.throws(() => run({ coverage }), { | ||
code: 'ERR_INVALID_ARG_TYPE' | ||
})); | ||
}); | ||
|
||
await it('should only allow string|string[] in options.coverageExcludeGlobs', async () => { | ||
[Symbol(), {}, () => {}, 0, 1, 0n, 1n, Promise.resolve([]), true, false] | ||
.forEach((coverageExcludeGlobs) => { | ||
assert.throws(() => run({ coverage: true, coverageExcludeGlobs }), { | ||
code: 'ERR_INVALID_ARG_TYPE' | ||
}); | ||
assert.throws(() => run({ coverage: true, coverageExcludeGlobs: [coverageExcludeGlobs] }), { | ||
code: 'ERR_INVALID_ARG_TYPE' | ||
}); | ||
}); | ||
run({ files: [], signal: abortedSignal, coverage: true, coverageExcludeGlobs: [''] }); | ||
run({ files: [], signal: abortedSignal, coverage: true, coverageExcludeGlobs: '' }); | ||
}); | ||
|
||
await it('should only allow string|string[] in options.coverageIncludeGlobs', async () => { | ||
[Symbol(), {}, () => {}, 0, 1, 0n, 1n, Promise.resolve([]), true, false] | ||
.forEach((coverageIncludeGlobs) => { | ||
assert.throws(() => run({ coverage: true, coverageIncludeGlobs }), { | ||
code: 'ERR_INVALID_ARG_TYPE' | ||
}); | ||
assert.throws(() => run({ coverage: true, coverageIncludeGlobs: [coverageIncludeGlobs] }), { | ||
code: 'ERR_INVALID_ARG_TYPE' | ||
}); | ||
}); | ||
|
||
run({ files: [], signal: abortedSignal, coverage: true, coverageIncludeGlobs: [''] }); | ||
run({ files: [], signal: abortedSignal, coverage: true, coverageIncludeGlobs: '' }); | ||
}); | ||
|
||
await it('should only allow an int within range in options.lineCoverage', async () => { | ||
[Symbol(), {}, () => {}, [], 0n, 1n, Promise.resolve([]), true, false] | ||
.forEach((lineCoverage) => { | ||
assert.throws(() => run({ coverage: true, lineCoverage }), { | ||
code: 'ERR_INVALID_ARG_TYPE' | ||
}); | ||
assert.throws(() => run({ coverage: true, lineCoverage: [lineCoverage] }), { | ||
code: 'ERR_INVALID_ARG_TYPE' | ||
}); | ||
}); | ||
assert.throws(() => run({ coverage: true, lineCoverage: -1 }), { code: 'ERR_OUT_OF_RANGE' }); | ||
assert.throws(() => run({ coverage: true, lineCoverage: 101 }), { code: 'ERR_OUT_OF_RANGE' }); | ||
|
||
run({ files: [], signal: abortedSignal, coverage: true, lineCoverage: 0 }); | ||
}); | ||
|
||
await it('should only allow an int within range in options.branchCoverage', async () => { | ||
[Symbol(), {}, () => {}, [], 0n, 1n, Promise.resolve([]), true, false] | ||
.forEach((branchCoverage) => { | ||
assert.throws(() => run({ coverage: true, branchCoverage }), { | ||
code: 'ERR_INVALID_ARG_TYPE' | ||
}); | ||
assert.throws(() => run({ coverage: true, branchCoverage: [branchCoverage] }), { | ||
code: 'ERR_INVALID_ARG_TYPE' | ||
}); | ||
}); | ||
|
||
assert.throws(() => run({ coverage: true, branchCoverage: -1 }), { code: 'ERR_OUT_OF_RANGE' }); | ||
assert.throws(() => run({ coverage: true, branchCoverage: 101 }), { code: 'ERR_OUT_OF_RANGE' }); | ||
|
||
run({ files: [], signal: abortedSignal, coverage: true, branchCoverage: 0 }); | ||
}); | ||
|
||
await it('should only allow an int within range in options.functionCoverage', async () => { | ||
[Symbol(), {}, () => {}, [], 0n, 1n, Promise.resolve([]), true, false] | ||
.forEach((functionCoverage) => { | ||
assert.throws(() => run({ coverage: true, functionCoverage }), { | ||
code: 'ERR_INVALID_ARG_TYPE' | ||
}); | ||
assert.throws(() => run({ coverage: true, functionCoverage: [functionCoverage] }), { | ||
code: 'ERR_INVALID_ARG_TYPE' | ||
}); | ||
}); | ||
|
||
assert.throws(() => run({ coverage: true, functionCoverage: -1 }), { code: 'ERR_OUT_OF_RANGE' }); | ||
assert.throws(() => run({ coverage: true, functionCoverage: 101 }), { code: 'ERR_OUT_OF_RANGE' }); | ||
|
||
run({ files: [], signal: abortedSignal, coverage: true, functionCoverage: 0 }); | ||
}); | ||
}); | ||
|
||
const options = { concurrency: false, skip: !process.features.inspector ? 'inspector disabled' : false }; | ||
await describe('run with coverage', options, async () => { | ||
await it('should run with coverage', async () => { | ||
const stream = run({ files, coverage: true }); | ||
stream.on('test:fail', common.mustNotCall()); | ||
stream.on('test:pass', common.mustCall()); | ||
stream.on('test:coverage', common.mustCall()); | ||
// eslint-disable-next-line no-unused-vars | ||
for await (const _ of stream); | ||
}); | ||
|
||
await it('should run with coverage and exclude by glob', async () => { | ||
const stream = run({ files, coverage: true, coverageExcludeGlobs: ['test/*/test-runner/invalid-tap.js'] }); | ||
stream.on('test:fail', common.mustNotCall()); | ||
stream.on('test:pass', common.mustCall(1)); | ||
stream.on('test:coverage', common.mustCall(({ summary: { files } }) => { | ||
const filesPaths = files.map(({ path }) => path); | ||
assert.strictEqual(filesPaths.some((path) => path.includes(`test-runner${sep}invalid-tap.js`)), false); | ||
})); | ||
// eslint-disable-next-line no-unused-vars | ||
for await (const _ of stream); | ||
}); | ||
|
||
await it('should run with coverage and include by glob', async () => { | ||
const stream = run({ | ||
files, | ||
coverage: true, | ||
coverageIncludeGlobs: ['test/fixtures/test-runner/coverage.js', 'test/*/v8-coverage/throw.js'], | ||
}); | ||
stream.on('test:fail', common.mustNotCall()); | ||
stream.on('test:pass', common.mustCall(1)); | ||
stream.on('test:coverage', common.mustCall(({ summary: { files } }) => { | ||
const filesPaths = files.map(({ path }) => path); | ||
assert.strictEqual(filesPaths.some((path) => path.includes(`v8-coverage${sep}throw.js`)), true); | ||
})); | ||
// eslint-disable-next-line no-unused-vars | ||
for await (const _ of stream); | ||
}); | ||
|
||
await it('should run while including and excluding globs', async () => { | ||
const stream = run({ | ||
files: [...files, fixtures.path('test-runner/invalid-tap.js')], | ||
coverage: true, | ||
coverageIncludeGlobs: ['test/fixtures/test-runner/*.js'], | ||
coverageExcludeGlobs: ['test/fixtures/test-runner/*-tap.js'] | ||
}); | ||
stream.on('test:fail', common.mustNotCall()); | ||
stream.on('test:pass', common.mustCall(2)); | ||
stream.on('test:coverage', common.mustCall(({ summary: { files } }) => { | ||
const filesPaths = files.map(({ path }) => path); | ||
assert.strictEqual(filesPaths.every((path) => !path.includes(`test-runner${sep}invalid-tap.js`)), true); | ||
assert.strictEqual(filesPaths.some((path) => path.includes(`test-runner${sep}coverage.js`)), true); | ||
})); | ||
// eslint-disable-next-line no-unused-vars | ||
for await (const _ of stream); | ||
}); | ||
|
||
await it('should run with coverage and fail when below line threshold', async () => { | ||
const thresholdErrors = []; | ||
const originalExitCode = process.exitCode; | ||
assert.notStrictEqual(originalExitCode, 1); | ||
const stream = run({ files, coverage: true, lineCoverage: 99, branchCoverage: 99, functionCoverage: 99 }); | ||
stream.on('test:fail', common.mustNotCall()); | ||
stream.on('test:pass', common.mustCall(1)); | ||
stream.on('test:diagnostic', ({ message }) => { | ||
const match = message.match(/Error: \d{2}\.\d{2}% (line|branch|function) coverage does not meet threshold of 99%/); | ||
if (match) { | ||
thresholdErrors.push(match[1]); | ||
} | ||
}); | ||
// eslint-disable-next-line no-unused-vars | ||
for await (const _ of stream); | ||
assert.deepStrictEqual(thresholdErrors.sort(), ['branch', 'function', 'line']); | ||
assert.strictEqual(process.exitCode, 1); | ||
process.exitCode = originalExitCode; | ||
}); | ||
atlowChemi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}); | ||
}); | ||
|
||
|
||
// exitHandler doesn't run until after the tests / after hooks finish. | ||
process.on('exit', () => { | ||
assert.strictEqual(process.listeners('uncaughtException').length, 0); | ||
assert.strictEqual(process.listeners('unhandledRejection').length, 0); | ||
assert.strictEqual(process.listeners('beforeExit').length, 0); | ||
assert.strictEqual(process.listeners('SIGINT').length, 0); | ||
assert.strictEqual(process.listeners('SIGTERM').length, 0); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice! When this change is released I can add it to the
@node/types
👍🏼