From ddac1cf0598591ffa07b760f946d3c72ba059905 Mon Sep 17 00:00:00 2001 From: Thomas Marek Date: Tue, 21 Nov 2017 02:49:13 -0500 Subject: [PATCH] Add combined coverage threshold for directories (#4885) * Add combined coverage threshold for directories Add unit test for passing directory coverage Add test for when there is no coverage data available Fix type errors and make code more familiar Run prettier on changed files * Fix Windows bug * Update changelog * Update docs --- CHANGELOG.md | 2 + docs/Configuration.md | 59 ++++-- .../__tests__/coverage_reporter.test.js | 74 ++++++- .../src/reporters/coverage_reporter.js | 184 ++++++++++++------ 4 files changed, 244 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b081261703f0..513ebeb7b918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,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 11b1f5e80d37..a2af23be7465 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) {