Skip to content

Commit 3d2eb37

Browse files
mjesuncpojer
authored andcommitted
Detect memory leaks (#4895)
* Make use of jest-leak-detector to detect tests leaking memory * Addressed feedback
1 parent e5f58a6 commit 3d2eb37

File tree

14 files changed

+104
-20
lines changed

14 files changed

+104
-20
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@
5151

5252
### Features
5353

54+
* `[jest-runner]` Enable experimental detection of leaked contexts
55+
([#4895](https://github.com/facebook/jest/pull/4895))
5456
* `[jest-cli]` Add combined coverage threshold for directories.
5557
([#4885](https://github.com/facebook/jest/pull/4885))
5658
* `[jest-mock]` Add `timestamps` to mock state.

integration_tests/__tests__/__snapshots__/show_config.test.js.snap

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ exports[`--showConfig outputs config info and exits 1`] = `
1212
\\"coveragePathIgnorePatterns\\": [
1313
\\"/node_modules/\\"
1414
],
15+
\\"detectLeaks\\": false,
1516
\\"globals\\": {},
1617
\\"haste\\": {
1718
\\"providesModuleNodeModules\\": []
@@ -66,6 +67,7 @@ exports[`--showConfig outputs config info and exits 1`] = `
6667
\\"lcov\\",
6768
\\"clover\\"
6869
],
70+
\\"detectLeaks\\": false,
6971
\\"expand\\": false,
7072
\\"listTests\\": false,
7173
\\"mapCoverage\\": false,

packages/jest-circus/src/legacy_code_todo_rewrite/jest_adapter_init.js

+1
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export const runAndTransformResultsToJestFormat = async ({
129129
console: null,
130130
displayName: config.displayName,
131131
failureMessage,
132+
leaks: false, // That's legacy code, just adding it so Flow is happy.
132133
numFailingTests,
133134
numPassingTests,
134135
numPendingTests,

packages/jest-cli/src/cli/args.js

+8
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,14 @@ export const options = {
202202
description: 'Print debugging info about your jest config.',
203203
type: 'boolean',
204204
},
205+
detectLeaks: {
206+
default: false,
207+
description:
208+
'**EXPERIMENTAL**: Detect memory leaks in tests. After executing a ' +
209+
'test, it will try to garbage collect the global object used, and fail ' +
210+
'if it was leaked',
211+
type: 'boolean',
212+
},
205213
env: {
206214
description:
207215
'The test environment used for all tests. This can point to ' +

packages/jest-cli/src/test_result_helpers.js

+1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export const buildFailureTestResult = (
5454
console: null,
5555
displayName: '',
5656
failureMessage: null,
57+
leaks: false,
5758
numFailingTests: 0,
5859
numPassingTests: 0,
5960
numPendingTests: 0,

packages/jest-cli/src/test_scheduler.js

+22-2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {GlobalConfig, ReporterConfig} from 'types/Config';
1212
import type {Context} from 'types/Context';
1313
import type {Reporter, Test} from 'types/TestRunner';
1414

15+
import chalk from 'chalk';
1516
import {formatExecError} from 'jest-message-util';
1617
import {
1718
addResult,
@@ -88,14 +89,33 @@ export default class TestScheduler {
8889
if (watcher.isInterrupted()) {
8990
return Promise.resolve();
9091
}
92+
9193
if (testResult.testResults.length === 0) {
9294
const message = 'Your test suite must contain at least one test.';
93-
await onFailure(test, {
95+
96+
return onFailure(test, {
9497
message,
9598
stack: new Error(message).stack,
9699
});
97-
return Promise.resolve();
98100
}
101+
102+
// Throws when the context is leaked after executinga test.
103+
if (testResult.leaks) {
104+
const message =
105+
chalk.red.bold('EXPERIMENTAL FEATURE!\n') +
106+
'Your test suite is leaking memory. Please ensure all references are cleaned.\n' +
107+
'\n' +
108+
'There is a number of things that can leak memory:\n' +
109+
' - Async operations that have not finished (e.g. fs.readFile).\n' +
110+
' - Timers not properly mocked (e.g. setInterval, setTimeout).\n' +
111+
' - Keeping references to the global scope.';
112+
113+
return onFailure(test, {
114+
message,
115+
stack: new Error(message).stack,
116+
});
117+
}
118+
99119
addResult(aggregatedResults, testResult);
100120
await this._dispatcher.onTestResult(test, testResult, aggregatedResults);
101121
return this._bailIfNeeded(contexts, aggregatedResults, watcher);

packages/jest-config/src/defaults.js

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export default ({
3636
clearMocks: false,
3737
coveragePathIgnorePatterns: [NODE_MODULES_REGEXP],
3838
coverageReporters: ['json', 'text', 'lcov', 'clover'],
39+
detectLeaks: false,
3940
expand: false,
4041
globals: {},
4142
haste: {

packages/jest-config/src/index.js

+2
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ const getConfigs = (
8181
coverageDirectory: options.coverageDirectory,
8282
coverageReporters: options.coverageReporters,
8383
coverageThreshold: options.coverageThreshold,
84+
detectLeaks: options.detectLeaks,
8485
expand: options.expand,
8586
findRelatedTests: options.findRelatedTests,
8687
forceExit: options.forceExit,
@@ -123,6 +124,7 @@ const getConfigs = (
123124
clearMocks: options.clearMocks,
124125
coveragePathIgnorePatterns: options.coveragePathIgnorePatterns,
125126
cwd: options.cwd,
127+
detectLeaks: options.detectLeaks,
126128
displayName: options.displayName,
127129
globals: options.globals,
128130
haste: options.haste,

packages/jest-config/src/normalize.js

+1
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,7 @@ export default function normalize(options: InitialOptions, argv: Argv) {
455455
case 'collectCoverage':
456456
case 'coverageReporters':
457457
case 'coverageThreshold':
458+
case 'detectLeaks':
458459
case 'displayName':
459460
case 'expand':
460461
case 'globals':

packages/jest-runner/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"jest-docblock": "^21.2.0",
1313
"jest-haste-map": "^21.2.0",
1414
"jest-jasmine2": "^21.2.1",
15+
"jest-leak-detector": "^21.2.1",
1516
"jest-message-util": "^21.2.1",
1617
"jest-runtime": "^21.2.1",
1718
"jest-util": "^21.2.1",

packages/jest-runner/src/run_test.js

+55-17
Original file line numberDiff line numberDiff line change
@@ -23,29 +23,38 @@ import {
2323
setGlobal,
2424
} from 'jest-util';
2525
import jasmine2 from 'jest-jasmine2';
26+
import LeakDetector from 'jest-leak-detector';
2627
import {getTestEnvironment} from 'jest-config';
2728
import * as docblock from 'jest-docblock';
2829

30+
type RunTestInternalResult = {
31+
leakDetector: ?LeakDetector,
32+
result: TestResult,
33+
};
34+
2935
// The default jest-runner is required because it is the default test runner
3036
// and required implicitly through the `testRunner` ProjectConfig option.
3137
jasmine2;
3238

33-
export default (async function runTest(
39+
// Keeping the core of "runTest" as a separate function (as "runTestInternal")
40+
// is key to be able to detect memory leaks. Since all variables are local to
41+
// the function, when "runTestInternal" finishes its execution, they can all be
42+
// freed, UNLESS something else is leaking them (and that's why we can detect
43+
// the leak!).
44+
//
45+
// If we had all the code in a single function, we should manually nullify all
46+
// references to verify if there is a leak, which is not maintainable and error
47+
// prone. That's why "runTestInternal" CANNOT be inlined inside "runTest".
48+
async function runTestInternal(
3449
path: Path,
3550
globalConfig: GlobalConfig,
3651
config: ProjectConfig,
3752
resolver: Resolver,
38-
) {
39-
let testSource;
40-
41-
try {
42-
testSource = fs.readFileSync(path, 'utf8');
43-
} catch (e) {
44-
return Promise.reject(e);
45-
}
46-
53+
): Promise<RunTestInternalResult> {
54+
const testSource = fs.readFileSync(path, 'utf8');
4755
const parsedDocblock = docblock.parse(docblock.extract(testSource));
4856
const customEnvironment = parsedDocblock['jest-environment'];
57+
4958
let testEnvironment = config.testEnvironment;
5059

5160
if (customEnvironment) {
@@ -66,6 +75,10 @@ export default (async function runTest(
6675
>);
6776

6877
const environment = new TestEnvironment(config);
78+
const leakDetector = config.detectLeaks
79+
? new LeakDetector(environment)
80+
: null;
81+
6982
const consoleOut = globalConfig.useStderr ? process.stderr : process.stdout;
7083
const consoleFormatter = (type, message) =>
7184
getConsoleOutput(
@@ -76,24 +89,25 @@ export default (async function runTest(
7689
);
7790

7891
let testConsole;
92+
7993
if (globalConfig.silent) {
8094
testConsole = new NullConsole(consoleOut, process.stderr, consoleFormatter);
95+
} else if (globalConfig.verbose) {
96+
testConsole = new Console(consoleOut, process.stderr, consoleFormatter);
8197
} else {
82-
if (globalConfig.verbose) {
83-
testConsole = new Console(consoleOut, process.stderr, consoleFormatter);
84-
} else {
85-
testConsole = new BufferedConsole();
86-
}
98+
testConsole = new BufferedConsole();
8799
}
88100

89101
const cacheFS = {[path]: testSource};
90102
setGlobal(environment.global, 'console', testConsole);
103+
91104
const runtime = new Runtime(config, environment, resolver, cacheFS, {
92105
collectCoverage: globalConfig.collectCoverage,
93106
collectCoverageFrom: globalConfig.collectCoverageFrom,
94107
collectCoverageOnlyFrom: globalConfig.collectCoverageOnlyFrom,
95108
mapCoverage: globalConfig.mapCoverage,
96109
});
110+
97111
const start = Date.now();
98112
await environment.setup();
99113
try {
@@ -106,22 +120,46 @@ export default (async function runTest(
106120
);
107121
const testCount =
108122
result.numPassingTests + result.numFailingTests + result.numPendingTests;
123+
109124
result.perfStats = {end: Date.now(), start};
110125
result.testFilePath = path;
111126
result.coverage = runtime.getAllCoverageInfo();
112127
result.sourceMaps = runtime.getSourceMapInfo();
113128
result.console = testConsole.getBuffer();
114129
result.skipped = testCount === result.numPendingTests;
115130
result.displayName = config.displayName;
131+
116132
if (globalConfig.logHeapUsage) {
117133
if (global.gc) {
118134
global.gc();
119135
}
120136
result.memoryUsage = process.memoryUsage().heapUsed;
121137
}
138+
122139
// Delay the resolution to allow log messages to be output.
123-
return new Promise(resolve => setImmediate(() => resolve(result)));
140+
return new Promise(resolve => {
141+
setImmediate(() => resolve({leakDetector, result}));
142+
});
124143
} finally {
125144
await environment.teardown();
126145
}
127-
});
146+
}
147+
148+
export default async function runTest(
149+
path: Path,
150+
globalConfig: GlobalConfig,
151+
config: ProjectConfig,
152+
resolver: Resolver,
153+
): Promise<TestResult> {
154+
const {leakDetector, result} = await runTestInternal(
155+
path,
156+
globalConfig,
157+
config,
158+
resolver,
159+
);
160+
161+
// Resolve leak detector, outside the "runTestInternal" closure.
162+
result.leaks = leakDetector ? leakDetector.isLeaking() : false;
163+
164+
return result;
165+
}

test_utils.js

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const DEFAULT_GLOBAL_CONFIG: GlobalConfig = {
2020
coverageDirectory: 'coverage',
2121
coverageReporters: [],
2222
coverageThreshold: {global: {}},
23+
detectLeaks: false,
2324
expand: false,
2425
findRelatedTests: false,
2526
forceExit: false,
@@ -63,6 +64,7 @@ const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
6364
clearMocks: false,
6465
coveragePathIgnorePatterns: [],
6566
cwd: '/test_root_dir/',
67+
detectLeaks: false,
6668
displayName: undefined,
6769
globals: {},
6870
haste: {

types/Config.js

+4
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export type DefaultOptions = {|
3333
expand: boolean,
3434
globals: ConfigGlobals,
3535
haste: HasteConfig,
36+
detectLeaks: boolean,
3637
mapCoverage: boolean,
3738
moduleDirectories: Array<string>,
3839
moduleFileExtensions: Array<string>,
@@ -78,6 +79,7 @@ export type InitialOptions = {
7879
coveragePathIgnorePatterns?: Array<string>,
7980
coverageReporters?: Array<string>,
8081
coverageThreshold?: {global: {[key: string]: number}},
82+
detectLeaks?: boolean,
8183
displayName?: string,
8284
expand?: boolean,
8385
findRelatedTests?: boolean,
@@ -154,6 +156,7 @@ export type GlobalConfig = {|
154156
coverageDirectory: string,
155157
coverageReporters: Array<string>,
156158
coverageThreshold: {global: {[key: string]: number}},
159+
detectLeaks: boolean,
157160
expand: boolean,
158161
findRelatedTests: boolean,
159162
forceExit: boolean,
@@ -197,6 +200,7 @@ export type ProjectConfig = {|
197200
clearMocks: boolean,
198201
coveragePathIgnorePatterns: Array<string>,
199202
cwd: Path,
203+
detectLeaks: boolean,
200204
displayName: ?string,
201205
globals: ConfigGlobals,
202206
haste: HasteConfig,

types/TestResult.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,9 @@ export type TestResult = {|
138138
console: ?ConsoleBuffer,
139139
coverage?: RawCoverage,
140140
displayName: ?string,
141-
memoryUsage?: Bytes,
142141
failureMessage: ?string,
142+
leaks: boolean,
143+
memoryUsage?: Bytes,
143144
numFailingTests: number,
144145
numPassingTests: number,
145146
numPendingTests: number,

0 commit comments

Comments
 (0)