diff --git a/CHANGELOG.md b/CHANGELOG.md index abc4bd929f62..fec131b092aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,8 @@ ### Features +* `[jest-cli]` Add combined coverage threshold for directories. + ([#4885](https://github.com/facebook/jest/pull/4885)) * `[jest-mock]` Add `timestamps` to mock state. ([#4866](https://github.com/facebook/jest/pull/4866)) * `[eslint-plugin-jest]` Add `prefer-to-have-length` lint rule. diff --git a/docs/Configuration.md b/docs/Configuration.md index 4a93a5b0d45d..7153e9f60759 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -165,20 +165,37 @@ _Note: Setting this option overwrites the default values. Add `"text"` or Default: `undefined` This will be used to configure minimum threshold enforcement for coverage -results. If the thresholds are not met, jest will return failure. Thresholds, -when specified as a positive number are taken to be the minimum percentage -required. When a threshold is specified as a negative number it represents the -maximum number of uncovered entities allowed. Thresholds can be specified as -`global`, as `glob` paths or just paths. If globs or paths are specified -alongside `global`, coverage data for matching paths will be subtracted from -overall coverage and thresholds will be applied independently. Threshold for -globs is applied to all files matching the glob. If the file specified by path -is not found, error is returned. - -For example, statements: 90 implies minimum statement coverage is 90%. -statements: -10 implies that no more than 10 uncovered statements are allowed. -`global` branch threshold 50 will be applied to all files minus matching -`./src/components/**/*.js` and `./src/api/very-important-module.js`. +results. Thresholds can be specified as `global`, as +a [glob](https://github.com/isaacs/node-glob#glob-primer), and as a directory or +file path. If thresholds aren't met, jest will fail. Thresholds specified as a +positive number are taken to be the minimum percentage required. Thresholds +specified as a negative number represent the maximum number of uncovered +entities allowed. + +For example, with the following configuration jest will fail if there is less than 80% branch, line, and function coverage, or if there are more than 10 uncovered statements: + +```json +{ + ... + "jest": { + "coverageThreshold": { + "global": { + "branches": 80, + "functions": 80, + "lines": 80, + "statements": -10 + } + } + } +} +``` + +If globs or paths are specified alongside `global`, coverage data for matching +paths will be subtracted from overall coverage and thresholds will be applied +independently. Thresholds for globs are applied to all files matching the +glob. If the file specified by path is not found, error is returned. + +For example, with the following configuration: ```json { @@ -191,10 +208,13 @@ statements: -10 implies that no more than 10 uncovered statements are allowed. "lines": 50, "statements": 50 }, - "./src/components/**/*.js": { + "./src/components/": { "branches": 40, "statements": 40 }, + "./src/reducers/**/*.js": { + "statements": 90, + }, "./src/api/very-important-module.js": { "branches": 100, "functions": 100, @@ -206,6 +226,15 @@ statements: -10 implies that no more than 10 uncovered statements are allowed. } ``` +Jest will fail if: + + - The `./src/components` directory has less than 40% branch or statement coverage. + - One of the files matching the `./src/reducers/**/*.js` glob has less than 90% + statement coverage. + - The `./src/api/very-important-module.js` file has less than 100% coverage. + - Every remaining file combined has less than 50% coverage (`global`). + + ### `globals` [object] Default: `{}` diff --git a/packages/jest-cli/src/reporters/__tests__/coverage_reporter.test.js b/packages/jest-cli/src/reporters/__tests__/coverage_reporter.test.js index 0962ab57d7cd..ca5bcb6be0f8 100644 --- a/packages/jest-cli/src/reporters/__tests__/coverage_reporter.test.js +++ b/packages/jest-cli/src/reporters/__tests__/coverage_reporter.test.js @@ -112,7 +112,7 @@ describe('onRunComplete', () => { }); }); - it('getLastError() returns an error when threshold is not met for global', () => { + test('getLastError() returns an error when threshold is not met for global', () => { const testReporter = new CoverageReporter( { collectCoverage: true, @@ -134,7 +134,7 @@ describe('onRunComplete', () => { }); }); - it('getLastError() returns an error when threshold is not met for file', () => { + test('getLastError() returns an error when threshold is not met for file', () => { const covThreshold = {}; [ 'global', @@ -164,7 +164,7 @@ describe('onRunComplete', () => { }); }); - it('getLastError() returns `undefined` when threshold is met', () => { + test('getLastError() returns `undefined` when threshold is met', () => { const covThreshold = {}; [ 'global', @@ -194,7 +194,7 @@ describe('onRunComplete', () => { }); }); - it('getLastError() returns an error when threshold is for non-covered file', () => { + test('getLastError() returns an error when threshold is not met for non-covered file', () => { const testReporter = new CoverageReporter( { collectCoverage: true, @@ -215,4 +215,70 @@ describe('onRunComplete', () => { expect(testReporter.getLastError().message.split('\n')).toHaveLength(1); }); }); + + test('getLastError() returns an error when threshold is not met for directory', () => { + const testReporter = new CoverageReporter( + { + collectCoverage: true, + coverageThreshold: { + './path-test-files/glob-path/': { + statements: 100, + }, + }, + }, + { + maxWorkers: 2, + }, + ); + testReporter.log = jest.fn(); + return testReporter + .onRunComplete(new Set(), {}, mockAggResults) + .then(() => { + expect(testReporter.getLastError().message.split('\n')).toHaveLength(1); + }); + }); + + test('getLastError() returns `undefined` when threshold is met for directory', () => { + const testReporter = new CoverageReporter( + { + collectCoverage: true, + coverageThreshold: { + './path-test-files/glob-path/': { + statements: 40, + }, + }, + }, + { + maxWorkers: 2, + }, + ); + testReporter.log = jest.fn(); + return testReporter + .onRunComplete(new Set(), {}, mockAggResults) + .then(() => { + expect(testReporter.getLastError()).toBeUndefined(); + }); + }); + + test('getLastError() returns an error when there is no coverage data for a threshold', () => { + const testReporter = new CoverageReporter( + { + collectCoverage: true, + coverageThreshold: { + './path/doesnt/exist': { + statements: 40, + }, + }, + }, + { + maxWorkers: 2, + }, + ); + testReporter.log = jest.fn(); + return testReporter + .onRunComplete(new Set(), {}, mockAggResults) + .then(() => { + expect(testReporter.getLastError().message.split('\n')).toHaveLength(1); + }); + }); }); diff --git a/packages/jest-cli/src/reporters/coverage_reporter.js b/packages/jest-cli/src/reporters/coverage_reporter.js index b7fd95cf111e..b796d7fe69a7 100644 --- a/packages/jest-cli/src/reporters/coverage_reporter.js +++ b/packages/jest-cli/src/reporters/coverage_reporter.js @@ -234,8 +234,9 @@ export default class CoverageReporter extends BaseReporter { } } else if (actual < threshold) { errors.push( - `Jest: Coverage for ${key} (${actual}` + - `%) does not meet ${name} threshold (${threshold}%)`, + `Jest: "${name}" coverage threshold for ${key} (${ + threshold + }%) not met: ` + `${actual}%`, ); } } @@ -245,66 +246,137 @@ export default class CoverageReporter extends BaseReporter { ); } - const expandedThresholds = {}; - Object.keys(globalConfig.coverageThreshold).forEach(filePathOrGlob => { - if (filePathOrGlob !== 'global') { - const pathArray = glob.sync(filePathOrGlob); - pathArray.forEach(filePath => { - expandedThresholds[path.resolve(filePath)] = - globalConfig.coverageThreshold[filePathOrGlob]; - }); - } else { - expandedThresholds.global = globalConfig.coverageThreshold.global; + const THRESHOLD_GROUP_TYPES = { + GLOB: 'glob', + GLOBAL: 'global', + PATH: 'path', + }; + const coveredFiles = map.files(); + const thresholdGroups = Object.keys(globalConfig.coverageThreshold); + const numThresholdGroups = thresholdGroups.length; + const groupTypeByThresholdGroup = {}; + const filesByGlob = {}; + + const coveredFilesSortedIntoThresholdGroup = coveredFiles.map(file => { + for (let i = 0; i < numThresholdGroups; i++) { + const thresholdGroup = thresholdGroups[i]; + const absoluteThresholdGroup = path.resolve(thresholdGroup); + + // The threshold group might be a path: + + if (file.indexOf(absoluteThresholdGroup) === 0) { + groupTypeByThresholdGroup[thresholdGroup] = + THRESHOLD_GROUP_TYPES.PATH; + return [file, thresholdGroup]; + } + + // If the threshold group is not a path it might be a glob: + + // Note: glob.sync is slow. By memoizing the files matching each glob + // (rather than recalculating it for each covered file) we save a tonne + // of execution time. + if (filesByGlob[absoluteThresholdGroup] === undefined) { + filesByGlob[absoluteThresholdGroup] = glob + .sync(absoluteThresholdGroup) + .map(filePath => path.resolve(filePath)); + } + + if (filesByGlob[absoluteThresholdGroup].indexOf(file) > -1) { + groupTypeByThresholdGroup[thresholdGroup] = + THRESHOLD_GROUP_TYPES.GLOB; + return [file, thresholdGroup]; + } } + + // Neither a glob or a path? Toss it in global if there's a global threshold: + if (thresholdGroups.indexOf(THRESHOLD_GROUP_TYPES.GLOBAL) > -1) { + groupTypeByThresholdGroup[THRESHOLD_GROUP_TYPES.GLOBAL] = + THRESHOLD_GROUP_TYPES.GLOBAL; + return [file, THRESHOLD_GROUP_TYPES.GLOBAL]; + } + + // A covered file that doesn't have a threshold: + return [file, undefined]; }); - const filteredCoverageSummary = map - .files() - .filter( - filePath => Object.keys(expandedThresholds).indexOf(filePath) === -1, - ) - .map(filePath => map.fileCoverageFor(filePath)) - .reduce((summary: ?CoverageSummary, fileCov: FileCoverage) => { - return summary === undefined || summary === null - ? (summary = fileCov.toSummary()) - : summary.merge(fileCov.toSummary()); - }, undefined); - - const errors = [].concat.apply( - [], - Object.keys(expandedThresholds) - .map(thresholdKey => { - if (thresholdKey === 'global') { - if (filteredCoverageSummary !== undefined) { - return check( - 'global', - expandedThresholds.global, - filteredCoverageSummary, - ); - } else { - return []; - } - } else { - if (map.files().indexOf(thresholdKey) !== -1) { - return check( - thresholdKey, - expandedThresholds[thresholdKey], - map.fileCoverageFor(thresholdKey).toSummary(), - ); - } else { - return [ - `Jest: Coverage data for ${thresholdKey} was not found.`, - ]; + const getFilesInThresholdGroup = thresholdGroup => + coveredFilesSortedIntoThresholdGroup + .filter(fileAndGroup => fileAndGroup[1] === thresholdGroup) + .map(fileAndGroup => fileAndGroup[0]); + + function combineCoverage(filePaths) { + return filePaths + .map(filePath => map.fileCoverageFor(filePath)) + .reduce( + ( + combinedCoverage: ?CoverageSummary, + nextFileCoverage: FileCoverage, + ) => { + if (combinedCoverage === undefined || combinedCoverage === null) { + return nextFileCoverage.toSummary(); } + return combinedCoverage.merge(nextFileCoverage.toSummary()); + }, + undefined, + ); + } + + let errors = []; + + thresholdGroups.forEach(thresholdGroup => { + switch (groupTypeByThresholdGroup[thresholdGroup]) { + case THRESHOLD_GROUP_TYPES.GLOBAL: { + const coverage = combineCoverage( + getFilesInThresholdGroup(THRESHOLD_GROUP_TYPES.GLOBAL), + ); + if (coverage) { + errors = errors.concat( + check( + thresholdGroup, + globalConfig.coverageThreshold[thresholdGroup], + coverage, + ), + ); } - }) - .filter(errorArray => { - return ( - errorArray !== undefined && - errorArray !== null && - errorArray.length > 0 + break; + } + case THRESHOLD_GROUP_TYPES.PATH: { + const coverage = combineCoverage( + getFilesInThresholdGroup(thresholdGroup), ); - }), + if (coverage) { + errors = errors.concat( + check( + thresholdGroup, + globalConfig.coverageThreshold[thresholdGroup], + coverage, + ), + ); + } + break; + } + case THRESHOLD_GROUP_TYPES.GLOB: + getFilesInThresholdGroup(thresholdGroup).forEach( + fileMatchingGlob => { + errors = errors.concat( + check( + fileMatchingGlob, + globalConfig.coverageThreshold[thresholdGroup], + map.fileCoverageFor(fileMatchingGlob).toSummary(), + ), + ); + }, + ); + break; + default: + errors = errors.concat( + `Jest: Coverage data for ${thresholdGroup} was not found.`, + ); + } + }); + + errors = errors.filter( + err => err !== undefined && err !== null && err.length > 0, ); if (errors.length > 0) {