diff --git a/CHANGELOG.md b/CHANGELOG.md index a5fe04d618ee..9399452de15f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ### Features +* `[jest-cli]` Add support for using `--coverage` in combination with watch + mode, `--onlyChanged`, `--findRelatedTests` and more + ([#5601](https://github.com/facebook/jest/pull/5601)) * `[jest-jasmine2]` Adds error throwing and descriptive errors to `it`/ `test` for invalid arguments. `[jest-circus]` Adds error throwing and descriptive errors to `it`/ `test` for invalid arguments diff --git a/docs/CLI.md b/docs/CLI.md index ad98e977b092..e30350ad48c2 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -189,7 +189,9 @@ Alias: `-e`. Use this flag to show full diffs and errors instead of a patch. Find and run the tests that cover a space separated list of source files that were passed in as arguments. Useful for pre-commit hook integration to run the -minimal amount of tests necessary. +minimal amount of tests necessary. Can be used together with `--coverage` to +include a test coverage for the source files, no duplicate +`--collectCoverageFrom` arguments needed. ### `--forceExit` diff --git a/integration-tests/Utils.js b/integration-tests/Utils.js index bad044ad6faf..899145fafe6b 100644 --- a/integration-tests/Utils.js +++ b/integration-tests/Utils.js @@ -166,7 +166,6 @@ const cleanupStackTrace = (output: string) => { module.exports = { cleanup, - cleanupStackTrace, copyDir, createEmptyPackage, extractSummary, diff --git a/integration-tests/__tests__/__snapshots__/find_related_files.test.js.snap b/integration-tests/__tests__/__snapshots__/find_related_files.test.js.snap new file mode 100644 index 000000000000..cdddb39ad81b --- /dev/null +++ b/integration-tests/__tests__/__snapshots__/find_related_files.test.js.snap @@ -0,0 +1,54 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`--findRelatedTests flag generates coverage report for filename 1`] = ` +"Test Suites: 2 passed, 2 total +Tests: 2 passed, 2 total +Snapshots: 0 total +Time: <> +Ran all test suites. +" +`; + +exports[`--findRelatedTests flag generates coverage report for filename 2`] = ` +" + +PASS __tests__/a.test.js +PASS __tests__/b.test.js" +`; + +exports[`--findRelatedTests flag generates coverage report for filename 3`] = ` +"----------|----------|----------|----------|----------|-------------------| +File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s | +----------|----------|----------|----------|----------|-------------------| +All files | 100 | 100 | 100 | 100 | | + a.js | 100 | 100 | 100 | 100 | | + b.js | 100 | 100 | 100 | 100 | | +----------|----------|----------|----------|----------|-------------------| +" +`; + +exports[`--findRelatedTests flag generates coverage report for filename 4`] = ` +"Test Suites: 1 passed, 1 total +Tests: 1 passed, 1 total +Snapshots: 0 total +Time: <> +Ran all test suites related to files matching /a.js/i. +" +`; + +exports[`--findRelatedTests flag generates coverage report for filename 5`] = ` +"PASS __tests__/a.test.js + ✓ a + +" +`; + +exports[`--findRelatedTests flag generates coverage report for filename 6`] = ` +"----------|----------|----------|----------|----------|-------------------| +File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s | +----------|----------|----------|----------|----------|-------------------| +All files | 100 | 100 | 100 | 100 | | + a.js | 100 | 100 | 100 | 100 | | +----------|----------|----------|----------|----------|-------------------| +" +`; diff --git a/integration-tests/__tests__/find_related_files.test.js b/integration-tests/__tests__/find_related_files.test.js index 18a25d08ef86..ffa924d257ff 100644 --- a/integration-tests/__tests__/find_related_files.test.js +++ b/integration-tests/__tests__/find_related_files.test.js @@ -13,7 +13,7 @@ import runJest from '../runJest'; import os from 'os'; import path from 'path'; -const {cleanup, writeFiles} = require('../Utils'); +const {cleanup, writeFiles, extractSummary} = require('../Utils'); const SkipOnWindows = require('../../scripts/SkipOnWindows'); const DIR = path.resolve(os.tmpdir(), 'find_related_tests_test'); @@ -23,23 +23,74 @@ SkipOnWindows.suite(); beforeEach(() => cleanup(DIR)); afterEach(() => cleanup(DIR)); -test('runs tests related to filename', () => { - writeFiles(DIR, { - '.watchmanconfig': '', - '__tests__/test.test.js': ` +describe('--findRelatedTests flag', () => { + test('runs tests related to filename', () => { + writeFiles(DIR, { + '.watchmanconfig': '', + '__tests__/test.test.js': ` const a = require('../a'); test('a', () => {}); `, - 'a.js': 'module.exports = {};', - 'package.json': JSON.stringify({jest: {testEnvironment: 'node'}}), + 'a.js': 'module.exports = {};', + 'package.json': JSON.stringify({jest: {testEnvironment: 'node'}}), + }); + + const {stdout} = runJest(DIR, ['a.js']); + expect(stdout).toMatch(''); + + const {stderr} = runJest(DIR, ['--findRelatedTests', 'a.js']); + expect(stderr).toMatch('PASS __tests__/test.test.js'); + + const summaryMsg = 'Ran all test suites related to files matching /a.js/i.'; + expect(stderr).toMatch(summaryMsg); }); - const {stdout} = runJest(DIR, ['a.js']); - expect(stdout).toMatch(''); + test('generates coverage report for filename', () => { + writeFiles(DIR, { + '.watchmanconfig': '', + '__tests__/a.test.js': ` + require('../a'); + require('../b'); + test('a', () => expect(1).toBe(1)); + `, + '__tests__/b.test.js': ` + require('../b'); + test('b', () => expect(1).toBe(1)); + `, + 'a.js': 'module.exports = {}', + 'b.js': 'module.exports = {}', + 'package.json': JSON.stringify({ + jest: {collectCoverage: true, testEnvironment: 'node'}, + }), + }); + + let stdout; + let stderr; - const {stderr} = runJest(DIR, ['--findRelatedTests', 'a.js']); - expect(stderr).toMatch('PASS __tests__/test.test.js'); + ({stdout, stderr} = runJest(DIR)); + let summary; + let rest; + ({summary, rest} = extractSummary(stderr)); + expect(summary).toMatchSnapshot(); + expect( + rest + .split('\n') + .map(s => s.trim()) + .sort() + .join('\n'), + ).toMatchSnapshot(); - const summaryMsg = 'Ran all test suites related to files matching /a.js/i.'; - expect(stderr).toMatch(summaryMsg); + // both a.js and b.js should be in the coverage + expect(stdout).toMatchSnapshot(); + + ({stdout, stderr} = runJest(DIR, ['--findRelatedTests', 'a.js'])); + + ({summary, rest} = extractSummary(stderr)); + + expect(summary).toMatchSnapshot(); + // should only run a.js + expect(rest).toMatchSnapshot(); + // coverage should be collected only for a.js + expect(stdout).toMatchSnapshot(); + }); }); diff --git a/integration-tests/__tests__/only_changed.test.js b/integration-tests/__tests__/only_changed.test.js index 16ff3e633b7a..ed51ea3bd13a 100644 --- a/integration-tests/__tests__/only_changed.test.js +++ b/integration-tests/__tests__/only_changed.test.js @@ -73,6 +73,51 @@ test('run only changed files', () => { expect(stderr).toMatch(/PASS __tests__(\/|\\)file3.test.js/); }); +test('report test coverage for only changed files', () => { + writeFiles(DIR, { + '__tests__/a.test.js': ` + require('../a'); + require('../b'); + test('a', () => expect(1).toBe(1)); + `, + '__tests__/b.test.js': ` + require('../b'); + test('b', () => expect(1).toBe(1)); + `, + 'a.js': 'module.exports = {}', + 'b.js': 'module.exports = {}', + 'package.json': JSON.stringify({ + jest: { + collectCoverage: true, + coverageReporters: ['text'], + testEnvironment: 'node', + }, + }), + }); + + run(`${GIT} init`, DIR); + run(`${GIT} add .`, DIR); + run(`${GIT} commit -m "first"`, DIR); + + writeFiles(DIR, { + 'a.js': 'module.exports = {modified: true}', + }); + + let stdout; + + ({stdout} = runJest(DIR)); + + // both a.js and b.js should be in the coverage + expect(stdout).toMatch('a.js'); + expect(stdout).toMatch('b.js'); + + ({stdout} = runJest(DIR, ['-o'])); + + // coverage should be collected only for a.js + expect(stdout).toMatch('a.js'); + expect(stdout).not.toMatch('b.js'); +}); + test('onlyChanged in config is overwritten by --all or testPathPattern', () => { writeFiles(DIR, { '.watchmanconfig': '', diff --git a/packages/jest-cli/src/__tests__/run_jest_with_coverage.test.js b/packages/jest-cli/src/__tests__/run_jest_with_coverage.test.js new file mode 100644 index 000000000000..9df75caec40d --- /dev/null +++ b/packages/jest-cli/src/__tests__/run_jest_with_coverage.test.js @@ -0,0 +1,112 @@ +import runJest from '../run_jest'; + +jest.mock('jest-util'); + +jest.mock( + '../test_scheduler', + () => + class { + constructor(globalConfig) { + this._globalConfig = globalConfig; + } + + scheduleTests() { + return {_globalConfig: this._globalConfig}; + } + }, +); + +jest.mock( + '../test_sequencer', + () => + class { + sort(allTests) { + return allTests; + } + cacheResults() {} + }, +); + +jest.mock( + '../search_source', + () => + class { + constructor(context) { + this._context = context; + } + + async getTestPaths(globalConfig, changedFilesPromise) { + const {files} = await changedFilesPromise; + const paths = files.filter(path => path.match(/__tests__/)); + + return { + collectCoverageFrom: files.filter(path => !path.match(/__tests__/)), + tests: paths.map(path => ({ + context: this._context, + duration: null, + path, + })), + }; + } + }, +); + +const config = {roots: [], testPathIgnorePatterns: [], testRegex: ''}; +let globalConfig; +const defaults = { + changedFilesPromise: Promise.resolve({ + files: ['foo.js', '__tests__/foo-test.js', 'dont/cover.js'], + }), + contexts: [{config}], + onComplete: runResults => (globalConfig = runResults._globalConfig), + outputStream: {}, + startRun: {}, + testWatcher: {isInterrupted: () => false}, +}; + +describe('collectCoverageFrom patterns', () => { + it('should apply collectCoverageFrom patterns coming from SearchSource', async () => { + expect.assertions(1); + + await runJest( + Object.assign({}, defaults, { + globalConfig: { + rootDir: '', + }, + }), + ); + expect(globalConfig.collectCoverageFrom).toEqual([ + 'foo.js', + 'dont/cover.js', + ]); + }); + + it('excludes coverage from files outside the global collectCoverageFrom config', async () => { + expect.assertions(1); + + await runJest( + Object.assign({}, defaults, { + globalConfig: { + collectCoverageFrom: ['**/dont/*.js'], + rootDir: '', + }, + }), + ); + expect(globalConfig.collectCoverageFrom).toEqual(['dont/cover.js']); + }); + + it('respects coveragePathIgnorePatterns', async () => { + expect.assertions(1); + + await runJest( + Object.assign({}, defaults, { + globalConfig: { + collectCoverageFrom: ['**/*.js'], + coveragePathIgnorePatterns: ['dont'], + rootDir: '', + }, + }), + ); + expect(globalConfig.collectCoverageFrom).toEqual(['foo.js']); + }); +}); diff --git a/packages/jest-cli/src/run_jest.js b/packages/jest-cli/src/run_jest.js index 8a3df9a0900d..a99e689ef975 100644 --- a/packages/jest-cli/src/run_jest.js +++ b/packages/jest-cli/src/run_jest.js @@ -13,6 +13,7 @@ import type {GlobalConfig} from 'types/Config'; import type {AggregatedResult} from 'types/TestResult'; import type TestWatcher from './test_watcher'; +import micromatch from 'micromatch'; import chalk from 'chalk'; import path from 'path'; import {Console, formatTestResults} from 'jest-util'; @@ -131,6 +132,8 @@ export default (async function runJest({ } } + let collectCoverageFrom = []; + const testRunData = await Promise.all( contexts.map(async context => { const matches = await getTestPaths( @@ -141,10 +144,44 @@ export default (async function runJest({ jestHooks, ); allTests = allTests.concat(matches.tests); + + if (matches.collectCoverageFrom) { + collectCoverageFrom = collectCoverageFrom.concat( + matches.collectCoverageFrom.filter(filename => { + if ( + globalConfig.collectCoverageFrom && + !micromatch( + [path.relative(globalConfig.rootDir, filename)], + globalConfig.collectCoverageFrom, + ).length + ) { + return false; + } + + if ( + globalConfig.coveragePathIgnorePatterns && + globalConfig.coveragePathIgnorePatterns.some(pattern => + filename.match(pattern), + ) + ) { + return false; + } + + return true; + }), + ); + } + return {context, matches}; }), ); + if (collectCoverageFrom.length) { + globalConfig = Object.freeze( + Object.assign({}, globalConfig, {collectCoverageFrom}), + ); + } + allTests = sequencer.sort(allTests); if (globalConfig.listTests) { diff --git a/packages/jest-cli/src/search_source.js b/packages/jest-cli/src/search_source.js index 1b5c7e06039b..e574c8e5fc48 100644 --- a/packages/jest-cli/src/search_source.js +++ b/packages/jest-cli/src/search_source.js @@ -17,10 +17,12 @@ import micromatch from 'micromatch'; import DependencyResolver from 'jest-resolve-dependencies'; import testPathPatternToRegExp from './test_path_pattern_to_regexp'; import {escapePathForRegex} from 'jest-regex-util'; +import {replaceRootDirInPath} from 'jest-config'; type SearchResult = {| noSCM?: boolean, stats?: {[key: string]: number}, + collectCoverageFrom?: Array, tests: Array, total?: number, |}; @@ -140,23 +142,38 @@ export default class SearchSource { return this._getAllTestPaths(testPathPattern); } - findRelatedTests(allPaths: Set): SearchResult { + findRelatedTests( + allPaths: Set, + collectCoverage: boolean, + ): SearchResult { const dependencyResolver = new DependencyResolver( this._context.resolver, this._context.hasteFS, ); - return { - tests: toTests( - this._context, - dependencyResolver.resolveInverse( - allPaths, - this.isTestFilePath.bind(this), - { - skipNodeResolution: this._context.config.skipNodeResolution, - }, - ), + + const tests = toTests( + this._context, + dependencyResolver.resolveInverse( + allPaths, + this.isTestFilePath.bind(this), + { + skipNodeResolution: this._context.config.skipNodeResolution, + }, ), - }; + ); + let collectCoverageFrom; + + // If we are collecting coverage, also return collectCoverageFrom patterns + if (collectCoverage) { + collectCoverageFrom = Array.from(allPaths).map(filename => { + filename = replaceRootDirInPath(this._context.config.rootDir, filename); + return path.isAbsolute(filename) + ? path.relative(this._context.config.rootDir, filename) + : filename; + }); + } + + return {collectCoverageFrom, tests}; } findTestsByPaths(paths: Array): SearchResult { @@ -170,24 +187,27 @@ export default class SearchSource { }; } - findRelatedTestsFromPattern(paths: Array): SearchResult { + findRelatedTestsFromPattern( + paths: Array, + collectCoverage: boolean, + ): SearchResult { if (Array.isArray(paths) && paths.length) { const resolvedPaths = paths.map(p => path.resolve(process.cwd(), p)); - return this.findRelatedTests(new Set(resolvedPaths)); + return this.findRelatedTests(new Set(resolvedPaths), collectCoverage); } return {tests: []}; } async findTestRelatedToChangedFiles( changedFilesPromise: ChangedFilesPromise, + collectCoverage: boolean, ) { const {repos, changedFiles} = await changedFilesPromise; - // no SCM (git/hg/...) is found in any of the roots. const noSCM = Object.keys(repos).every(scm => repos[scm].size === 0); return noSCM ? {noSCM: true, tests: []} - : this.findRelatedTests(changedFiles); + : this.findRelatedTests(changedFiles, collectCoverage); } async getTestPaths( @@ -200,11 +220,16 @@ export default class SearchSource { throw new Error('This promise must be present when running with -o.'); } - return this.findTestRelatedToChangedFiles(changedFilesPromise); + return this.findTestRelatedToChangedFiles( + changedFilesPromise, + globalConfig.collectCoverage, + ); } else if (globalConfig.runTestsByPath && paths && paths.length) { return Promise.resolve(this.findTestsByPaths(paths)); } else if (globalConfig.findRelatedTests && paths && paths.length) { - return Promise.resolve(this.findRelatedTestsFromPattern(paths)); + return Promise.resolve( + this.findRelatedTestsFromPattern(paths, globalConfig.collectCoverage), + ); } else if (globalConfig.testPathPattern != null) { return Promise.resolve( this.findMatchingTests(globalConfig.testPathPattern), diff --git a/packages/jest-config/src/__tests__/normalize.test.js b/packages/jest-config/src/__tests__/normalize.test.js index 0eeb2fed1791..94003c410899 100644 --- a/packages/jest-config/src/__tests__/normalize.test.js +++ b/packages/jest-config/src/__tests__/normalize.test.js @@ -211,6 +211,31 @@ describe('collectCoverageFrom', () => { }); }); +describe('findRelatedTests', () => { + it('it generates --coverageCoverageFrom patterns when needed', () => { + const sourceFile = 'file1.js'; + + const {options} = normalize( + { + collectCoverage: true, + rootDir: '/root/path/foo/', + }, + { + _: [ + `/root/path/${sourceFile}`, + sourceFile, + `/bar/${sourceFile}`, + ], + findRelatedTests: true, + }, + ); + + const expected = [`../${sourceFile}`, `${sourceFile}`, `bar/${sourceFile}`]; + + expect(options.collectCoverageFrom).toEqual(expected); + }); +}); + function testPathArray(key) { it('normalizes all paths relative to rootDir', () => { const {options} = normalize( diff --git a/packages/jest-config/src/index.js b/packages/jest-config/src/index.js index 62b469cc580c..88afaedfb6c4 100644 --- a/packages/jest-config/src/index.js +++ b/packages/jest-config/src/index.js @@ -24,6 +24,7 @@ import readConfigFileAndSetRootDir from './read_config_file_and_set_root_dir'; export {getTestEnvironment, isJSONString} from './utils'; export {default as normalize} from './normalize'; export {default as deprecationEntries} from './deprecated'; +export {replaceRootDirInPath} from './utils'; export function readConfig( argv: Argv, diff --git a/packages/jest-config/src/normalize.js b/packages/jest-config/src/normalize.js index 7e0827c67fc2..b53185643f3f 100644 --- a/packages/jest-config/src/normalize.js +++ b/packages/jest-config/src/normalize.js @@ -23,7 +23,7 @@ import {replacePathSepForRegex} from 'jest-regex-util'; import { BULLET, DOCUMENTATION_NOTE, - _replaceRootDirInPath, + replaceRootDirInPath, _replaceRootDirTags, escapeGlobCharacters, getTestEnvironment, @@ -66,7 +66,7 @@ const setupPreset = ( optionsPreset: string, ): InitialOptions => { let preset; - const presetPath = _replaceRootDirInPath(options.rootDir, optionsPreset); + const presetPath = replaceRootDirInPath(options.rootDir, optionsPreset); const presetModule = Resolver.findNodeModule( presetPath.endsWith(JSON_EXTENSION) ? presetPath @@ -146,7 +146,7 @@ const normalizeCollectCoverageOnlyFrom = ( return collectCoverageOnlyFrom.reduce((map, filePath) => { filePath = path.resolve( options.rootDir, - _replaceRootDirInPath(options.rootDir, filePath), + replaceRootDirInPath(options.rootDir, filePath), ); map[filePath] = true; return map; @@ -271,7 +271,7 @@ const normalizeReporters = (options: InitialOptions, basedir) => { [reporterConfig, {}] : reporterConfig; - const reporterPath = _replaceRootDirInPath( + const reporterPath = replaceRootDirInPath( options.rootDir, normalizedReporterConfig[0], ); @@ -391,7 +391,7 @@ export default function normalize(options: InitialOptions, argv: Argv) { options[key].map(filePath => path.resolve( options.rootDir, - _replaceRootDirInPath(options.rootDir, filePath), + replaceRootDirInPath(options.rootDir, filePath), ), ); break; @@ -404,7 +404,7 @@ export default function normalize(options: InitialOptions, argv: Argv) { options[key] && path.resolve( options.rootDir, - _replaceRootDirInPath(options.rootDir, options[key]), + replaceRootDirInPath(options.rootDir, options[key]), ); break; case 'globalSetup': @@ -449,7 +449,7 @@ export default function normalize(options: InitialOptions, argv: Argv) { value.hasteImplModulePath = resolve( options.rootDir, 'haste.hasteImplModulePath', - _replaceRootDirInPath(options.rootDir, value.hasteImplModulePath), + replaceRootDirInPath(options.rootDir, value.hasteImplModulePath), ); } break; @@ -599,6 +599,21 @@ export default function normalize(options: InitialOptions, argv: Argv) { ); } + // If collectCoverage is enabled while using --findRelatedTests we need to + // avoid having false negatives in the generated coverage report. + // The following: `--findRelatedTests '/rootDir/file1.js' --coverage` + // Is transformed to: `--findRelatedTests '/rootDir/file1.js' --coverage --collectCoverageFrom 'file1.js'` + // where arguments to `--collectCoverageFrom` should be globs (or relative + // paths to the rootDir) + if (newOptions.collectCoverage && argv.findRelatedTests) { + newOptions.collectCoverageFrom = argv._.map(filename => { + filename = replaceRootDirInPath(options.rootDir, filename); + return path.isAbsolute(filename) + ? path.relative(options.rootDir, filename) + : filename; + }); + } + return { hasDeprecationWarnings, options: newOptions, diff --git a/packages/jest-config/src/utils.js b/packages/jest-config/src/utils.js index 8831b25c0233..c85998edbfe4 100644 --- a/packages/jest-config/src/utils.js +++ b/packages/jest-config/src/utils.js @@ -30,7 +30,7 @@ const createValidationError = (message: string) => { export const resolve = (rootDir: string, key: string, filePath: Path) => { const module = Resolver.findNodeModule( - _replaceRootDirInPath(rootDir, filePath), + replaceRootDirInPath(rootDir, filePath), { basedir: rootDir, }, @@ -52,7 +52,7 @@ export const escapeGlobCharacters = (path: Path): Glob => { return path.replace(/([()*{}\[\]!?\\])/g, '\\$1'); }; -export const _replaceRootDirInPath = ( +export const replaceRootDirInPath = ( rootDir: string, filePath: Path, ): string => { @@ -91,7 +91,7 @@ export const _replaceRootDirTags = (rootDir: string, config: any) => { } return _replaceRootDirInObject(rootDir, config); case 'string': - return _replaceRootDirInPath(rootDir, config); + return replaceRootDirInPath(rootDir, config); } return config; }; @@ -105,7 +105,7 @@ export const _replaceRootDirTags = (rootDir: string, config: any) => { * 1. looks for relative to Jest. */ export const getTestEnvironment = (config: Object) => { - const env = _replaceRootDirInPath(config.rootDir, config.testEnvironment); + const env = replaceRootDirInPath(config.rootDir, config.testEnvironment); let module = Resolver.findNodeModule(`jest-environment-${env}`, { basedir: config.rootDir, });