From ab1d9fac92522ef674ac945ee870dd1209499111 Mon Sep 17 00:00:00 2001 From: ttmarek Date: Mon, 13 Nov 2017 13:54:26 -0500 Subject: [PATCH 1/4] 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 --- .../__tests__/coverage_reporter.test.js | 74 ++++++- .../src/reporters/coverage_reporter.js | 184 ++++++++++++------ 2 files changed, 198 insertions(+), 60 deletions(-) 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..b6e17b626433 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, + ); + } + + 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, + ), + ); + } + break; + } + case THRESHOLD_GROUP_TYPES.PATH: { + const coverage = combineCoverage( + getFilesInThresholdGroup(thresholdGroup), + ); + 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.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) { From 1aefd5eccd13d0bd19741a8d34ba5f3e7bf0ecdb Mon Sep 17 00:00:00 2001 From: ttmarek Date: Thu, 16 Nov 2017 12:06:15 -0500 Subject: [PATCH 2/4] Fix Windows bug --- packages/jest-cli/src/reporters/coverage_reporter.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/jest-cli/src/reporters/coverage_reporter.js b/packages/jest-cli/src/reporters/coverage_reporter.js index b6e17b626433..b796d7fe69a7 100644 --- a/packages/jest-cli/src/reporters/coverage_reporter.js +++ b/packages/jest-cli/src/reporters/coverage_reporter.js @@ -276,9 +276,9 @@ export default class CoverageReporter extends BaseReporter { // (rather than recalculating it for each covered file) we save a tonne // of execution time. if (filesByGlob[absoluteThresholdGroup] === undefined) { - filesByGlob[absoluteThresholdGroup] = glob.sync( - absoluteThresholdGroup, - ); + filesByGlob[absoluteThresholdGroup] = glob + .sync(absoluteThresholdGroup) + .map(filePath => path.resolve(filePath)); } if (filesByGlob[absoluteThresholdGroup].indexOf(file) > -1) { From 0450ed1affcf27dec598ac0a26e1effeeb31dbf4 Mon Sep 17 00:00:00 2001 From: ttmarek Date: Mon, 20 Nov 2017 09:22:32 -0500 Subject: [PATCH 3/4] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) 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. From cc61b81b7a16180f034e159770cb9cd170dee097 Mon Sep 17 00:00:00 2001 From: ttmarek Date: Mon, 20 Nov 2017 10:27:15 -0500 Subject: [PATCH 4/4] Update docs --- docs/Configuration.md | 59 ++++++++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 15 deletions(-) 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: `{}`