jest-doctor 0.1.0 โ†’ 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,12 +38,12 @@ declare const createEnvMixin: <EnvironmentConstructor extends JestDoctorConstruc
38
38
  (str: Uint8Array | string, encoding?: BufferEncoding, cb?: (err?: Error | null) => void): boolean;
39
39
  };
40
40
  console: {
41
- log: (message?: any, ...optionalParams: any[]) => void;
42
- info: (message?: any, ...optionalParams: any[]) => void;
43
- warn: (message?: any, ...optionalParams: any[]) => void;
44
- error: (message?: any, ...optionalParams: any[]) => void;
45
- debug: (message?: any, ...optionalParams: any[]) => void;
46
- trace: (message?: any, ...optionalParams: any[]) => void;
41
+ log: (...data: any[]) => void;
42
+ info: (...data: any[]) => void;
43
+ warn: (...data: any[]) => void;
44
+ error: (...data: any[]) => void;
45
+ debug: (...data: any[]) => void;
46
+ trace: (...data: any[]) => void;
47
47
  };
48
48
  };
49
49
  readonly promiseOwner: Map<number, string>;
@@ -55,8 +55,10 @@ const createEnvMixin = (Environment) => {
55
55
  processOutputs: 0,
56
56
  };
57
57
  const tmpDir = (0, getReporterTmpDir_1.default)(config.projectConfig.reporters || config.globalConfig.reporters);
58
+ const isWorker = typeof process.send === 'function';
59
+ const seed = (isWorker ? process.ppid : process.pid).toString();
58
60
  this.reporterTmpDir = tmpDir
59
- ? node_path_1.default.join(tmpDir, config.globalConfig.seed.toString(), this.testPath + 'json')
61
+ ? node_path_1.default.join(tmpDir, seed, this.testPath + '.json')
60
62
  : '';
61
63
  this.options = (0, normalizeOptions_1.default)(config.projectConfig.testEnvironmentOptions);
62
64
  (0, initLeakRecord_1.default)(this, consts_1.MAIN_THREAD);
@@ -16,17 +16,17 @@ declare const _default: {
16
16
  (str: Uint8Array | string, encoding?: BufferEncoding, cb?: (err?: Error | null) => void): boolean;
17
17
  };
18
18
  console: {
19
- log: (message?: any, ...optionalParams: any[]) => void;
20
- info: (message?: any, ...optionalParams: any[]) => void;
21
- warn: (message?: any, ...optionalParams: any[]) => void;
22
- error: (message?: any, ...optionalParams: any[]) => void;
23
- debug: (message?: any, ...optionalParams: any[]) => void;
24
- trace: (message?: any, ...optionalParams: any[]) => void;
19
+ log: (...data: any[]) => void;
20
+ info: (...data: any[]) => void;
21
+ warn: (...data: any[]) => void;
22
+ error: (...data: any[]) => void;
23
+ debug: (...data: any[]) => void;
24
+ trace: (...data: any[]) => void;
25
25
  };
26
26
  };
27
27
  readonly promiseOwner: Map<number, string>;
28
- readonly asyncHookDetector?: import("async_hooks").AsyncHook;
29
- readonly asyncHookCleaner?: import("async_hooks").AsyncHook;
28
+ readonly asyncHookDetector?: import("node:async_hooks").AsyncHook;
29
+ readonly asyncHookCleaner?: import("node:async_hooks").AsyncHook;
30
30
  readonly options: import("../types").NormalizedOptions;
31
31
  seenTearDown: boolean;
32
32
  currentAfterEachCount: number;
@@ -53,7 +53,7 @@ declare const _default: {
53
53
  fakeTimers: import("@jest/fake-timers").LegacyFakeTimers<unknown> | null;
54
54
  fakeTimersModern: import("@jest/fake-timers").ModernFakeTimers | null;
55
55
  moduleMocker: import("jest-mock").ModuleMocker | null;
56
- getVmContext: () => import("vm").Context | null;
56
+ getVmContext: () => import("node:vm").Context | null;
57
57
  exportConditions: (() => Array<string>) | undefined;
58
58
  };
59
59
  } & import("../createEnvMixin").JestDoctorConstructor;
@@ -16,17 +16,17 @@ declare const _default: {
16
16
  (str: Uint8Array | string, encoding?: BufferEncoding, cb?: (err?: Error | null) => void): boolean;
17
17
  };
18
18
  console: {
19
- log: (message?: any, ...optionalParams: any[]) => void;
20
- info: (message?: any, ...optionalParams: any[]) => void;
21
- warn: (message?: any, ...optionalParams: any[]) => void;
22
- error: (message?: any, ...optionalParams: any[]) => void;
23
- debug: (message?: any, ...optionalParams: any[]) => void;
24
- trace: (message?: any, ...optionalParams: any[]) => void;
19
+ log: (...data: any[]) => void;
20
+ info: (...data: any[]) => void;
21
+ warn: (...data: any[]) => void;
22
+ error: (...data: any[]) => void;
23
+ debug: (...data: any[]) => void;
24
+ trace: (...data: any[]) => void;
25
25
  };
26
26
  };
27
27
  readonly promiseOwner: Map<number, string>;
28
- readonly asyncHookDetector?: import("async_hooks").AsyncHook;
29
- readonly asyncHookCleaner?: import("async_hooks").AsyncHook;
28
+ readonly asyncHookDetector?: import("node:async_hooks").AsyncHook;
29
+ readonly asyncHookCleaner?: import("node:async_hooks").AsyncHook;
30
30
  readonly options: import("../types").NormalizedOptions;
31
31
  seenTearDown: boolean;
32
32
  currentAfterEachCount: number;
@@ -53,7 +53,7 @@ declare const _default: {
53
53
  fakeTimers: import("@jest/fake-timers").LegacyFakeTimers<unknown> | null;
54
54
  fakeTimersModern: import("@jest/fake-timers").ModernFakeTimers | null;
55
55
  moduleMocker: import("jest-mock").ModuleMocker | null;
56
- getVmContext: () => import("vm").Context | null;
56
+ getVmContext: () => import("node:vm").Context | null;
57
57
  exportConditions: (() => Array<string>) | undefined;
58
58
  };
59
59
  } & import("../createEnvMixin").JestDoctorConstructor;
@@ -1,3 +1,3 @@
1
1
  import type { JestDoctorEnvironment } from '../types';
2
- declare const createAsyncHookCleaner: (that: JestDoctorEnvironment) => import("async_hooks").AsyncHook;
2
+ declare const createAsyncHookCleaner: (that: JestDoctorEnvironment) => import("node:async_hooks").AsyncHook;
3
3
  export default createAsyncHookCleaner;
@@ -1,3 +1,3 @@
1
1
  import { JestDoctorEnvironment } from '../types';
2
- declare const createAsyncHookDetector: (that: JestDoctorEnvironment) => import("async_hooks").AsyncHook;
2
+ declare const createAsyncHookDetector: (that: JestDoctorEnvironment) => import("node:async_hooks").AsyncHook;
3
3
  export default createAsyncHookDetector;
@@ -16,7 +16,7 @@ const patchFakeTimers = (that) => {
16
16
  const originalFakeSetInterval = env.setInterval.bind(env);
17
17
  const originalFakeClearTimeout = env.clearTimeout.bind(env);
18
18
  const originalFakeClearInterval = env.clearInterval.bind(env);
19
- env.setTimeout = function (callback, delay) {
19
+ env.setTimeout = Object.assign(function (callback, delay) {
20
20
  const fakeTimeout = that.leakRecords.get(that.currentTestName)?.fakeTimers;
21
21
  const timerId = originalFakeSetTimeout(() => {
22
22
  fakeTimeout?.delete(timerId);
@@ -31,8 +31,8 @@ const patchFakeTimers = (that) => {
31
31
  });
32
32
  }
33
33
  return timerId;
34
- };
35
- env.setInterval = function (callback, delay) {
34
+ }, env.setTimeout);
35
+ env.setInterval = Object.assign(function (callback, delay) {
36
36
  const intervalId = originalFakeSetInterval(callback, delay);
37
37
  const stack = (0, getStack_1.default)(that.global.setInterval);
38
38
  if (!(0, isIgnored_1.default)(stack, that.options.report.fakeTimers.ignore)) {
@@ -45,17 +45,17 @@ const patchFakeTimers = (that) => {
45
45
  });
46
46
  }
47
47
  return intervalId;
48
- };
49
- env.clearTimeout = (timerId) => {
48
+ }, env.setInterval);
49
+ env.clearTimeout = Object.assign((timerId) => {
50
50
  that.leakRecords.get(that.currentTestName)?.fakeTimers.delete(timerId);
51
51
  originalFakeClearTimeout(timerId);
52
- };
53
- env.clearInterval = (intervalId) => {
52
+ }, env.clearTimeout);
53
+ env.clearInterval = Object.assign((intervalId) => {
54
54
  that.leakRecords
55
55
  .get(that.currentTestName)
56
56
  ?.fakeTimers.delete(intervalId);
57
57
  originalFakeClearInterval(intervalId);
58
- };
58
+ }, env.clearInterval);
59
59
  };
60
60
  const originalClearAllTimers = api.clearAllTimers.bind(api);
61
61
  api.clearAllTimers = () => {
@@ -5,11 +5,11 @@ const patchPromiseConcurrency = (that) => {
5
5
  const env = that.global;
6
6
  const getRootIds = () => {
7
7
  let triggerId = (0, node_async_hooks_1.executionAsyncId)();
8
- const rootIds = [triggerId];
9
- while (triggerId !== that.asyncRoot && triggerId) {
10
- triggerId = that.asyncIdToParentId.get(triggerId);
8
+ const rootIds = [];
9
+ do {
11
10
  rootIds.push(triggerId);
12
- }
11
+ triggerId = that.asyncIdToParentId.get(triggerId);
12
+ } while (triggerId && triggerId !== that.asyncRoot);
13
13
  return rootIds;
14
14
  };
15
15
  const cleanPromise = (promises, promise, asyncId) => {
@@ -8,9 +8,7 @@ declare class JestDoctorReporter implements Reporter {
8
8
  private readonly reportDir;
9
9
  private readonly pidDir;
10
10
  private readonly keep;
11
- constructor(globalConfig: {
12
- seed: number;
13
- }, options: ReporterOptions);
11
+ constructor(_: object, options: ReporterOptions);
14
12
  onRunComplete(): Promise<void>;
15
13
  }
16
14
  export default JestDoctorReporter;
package/dist/reporter.js CHANGED
@@ -56,12 +56,13 @@ class JestDoctorReporter {
56
56
  reportDir;
57
57
  pidDir;
58
58
  keep;
59
- constructor(globalConfig, options) {
60
- const seed = globalConfig.seed.toString();
59
+ constructor(_, options) {
60
+ const seed = process.pid.toString();
61
61
  this.tmpDir = options.tmpDir || consts_1.REPORTER_TMP_DIR;
62
62
  this.keep = options.keep || false;
63
63
  this.reportDir = node_path_1.default.join(this.tmpDir, seed);
64
64
  this.pidDir = node_path_1.default.join(this.tmpDir, 'pid');
65
+ (0, node_fs_1.rmSync)(this.reportDir, { recursive: true, force: true });
65
66
  (0, node_fs_1.mkdirSync)(this.pidDir, { recursive: true });
66
67
  (0, node_fs_1.mkdirSync)(this.reportDir, { recursive: true });
67
68
  if (!this.keep) {
@@ -9,8 +9,7 @@ const getStack = (stackFrom) => {
9
9
  lines.shift();
10
10
  const finalStack = [];
11
11
  for (const line of lines) {
12
- if (/[/\\]/.test(line) && // this will remove all anonymous frames without a path
13
- !line.includes('(node:internal/') &&
12
+ if (!line.includes('(node:internal/') &&
14
13
  !line.includes('node_modules/jest-runtime') &&
15
14
  !line.includes('node_modules/jest-circus') &&
16
15
  !line.includes('node_modules/jest-runner') &&
@@ -12,12 +12,12 @@ declare const initOriginal: () => {
12
12
  (str: Uint8Array | string, encoding?: BufferEncoding, cb?: (err?: Error | null) => void): boolean;
13
13
  };
14
14
  console: {
15
- log: (message?: any, ...optionalParams: any[]) => void;
16
- info: (message?: any, ...optionalParams: any[]) => void;
17
- warn: (message?: any, ...optionalParams: any[]) => void;
18
- error: (message?: any, ...optionalParams: any[]) => void;
19
- debug: (message?: any, ...optionalParams: any[]) => void;
20
- trace: (message?: any, ...optionalParams: any[]) => void;
15
+ log: (...data: any[]) => void;
16
+ info: (...data: any[]) => void;
17
+ warn: (...data: any[]) => void;
18
+ error: (...data: any[]) => void;
19
+ debug: (...data: any[]) => void;
20
+ trace: (...data: any[]) => void;
21
21
  };
22
22
  };
23
23
  export default initOriginal;
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "jest-doctor",
3
- "version": "0.1.0",
4
- "description": "jest environment for leak detection",
3
+ "version": "0.1.2",
4
+ "description": "jest environment for leak and issue detection",
5
5
  "license": "MIT",
6
6
  "author": "Stephan Dum",
7
7
  "type": "commonjs",
8
8
  "homepage": "https://github.com/stephan-dum/jest-doctor",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "git+https://github.com/stephan-dum/jest-doctor.git"
11
+ "url": "git+https://github.com/stephan-dum/jest-doctor.git",
12
+ "directory": "packages/jest-doctor"
12
13
  },
13
14
  "exports": {
14
15
  "./package.json": "./package.json",
@@ -26,7 +27,10 @@
26
27
  "jest-doctor",
27
28
  "leak detection",
28
29
  "open handles",
29
- "testing"
30
+ "issue detection",
31
+ "testing",
32
+ "jest environment",
33
+ "test hygien"
30
34
  ],
31
35
  "types": "./dist/types.d.ts",
32
36
  "devDependencies": {
@@ -37,11 +41,11 @@
37
41
  "@swc/jest": "^0.2.39",
38
42
  "@testing-library/dom": "^10.4.1",
39
43
  "@testing-library/jest-dom": "^6.9.1",
40
- "@testing-library/react": "^16.3.1",
44
+ "@testing-library/react": "^16.3.2",
41
45
  "@types/cross-spawn": "^6.0.6",
42
46
  "@types/jest": "30.0.0",
43
- "@types/node": "24.3.3",
44
- "@types/react": "^19.2.8",
47
+ "@types/node": "^25.0.10",
48
+ "@types/react": "^19.2.9",
45
49
  "@types/react-dom": "^19.2.3",
46
50
  "c8": "^10.1.3",
47
51
  "cross-spawn": "^7.0.6",
@@ -49,12 +53,12 @@
49
53
  "jest": "30.2.0",
50
54
  "jest-environment-jsdom": "^30.2.0",
51
55
  "jest-environment-node": "^30.2.0",
52
- "memfs": "^4.52.0",
56
+ "memfs": "^4.56.10",
53
57
  "nyc": "^17.1.0",
54
58
  "react": "^19.2.3",
55
59
  "react-dom": "^19.2.3",
56
60
  "ts-jest": "^29.4.6",
57
- "typescript": "5.9.2"
61
+ "typescript": "5.9.3"
58
62
  },
59
63
  "peerDependencies": {
60
64
  "jest-environment-jsdom": "*",
@@ -70,7 +74,7 @@
70
74
  },
71
75
  "dependencies": {
72
76
  "chalk": "4.1.2",
73
- "zod": "^4.3.5"
77
+ "zod": "^4.3.6"
74
78
  },
75
79
  "scripts": {
76
80
  "typecheck": "tsc",
@@ -80,6 +84,6 @@
80
84
  "test:unit": "jest --coverage",
81
85
  "test:matrix": "node ./e2e/runMatrix.mjs",
82
86
  "test": "yarn matrix && yarn test:unit && yarn coverage:merge",
83
- "coverage:merge": "nyc report --reporter html --reporter text"
87
+ "coverage:merge": "nyc report"
84
88
  }
85
89
  }
package/readme.md CHANGED
@@ -1,165 +1,302 @@
1
- # jest-doctor
2
-
3
- **jest-doctor** is a custom Jest environment that **detects async leaks and test isolation bugs**.
4
-
5
- It fails tests deterministically when they:
6
- - Leave unresolved Promises
7
- - leave open (fake) timers or intervals
8
- - Use real timers or intervals and their total delay reach a certain threshold
9
- - Emit console output
10
-
11
- Jest-doctor is intentionally strict. If your test leaks async work, that is a bug, even if Jest normally ignores it.
12
-
13
- ## Installation
14
-
15
- ```bash
16
- npm install --save-dev jest-doctor
17
- ```
18
- or
19
- ```bash
20
- yarn add -D jest-doctor
21
- ```
22
-
23
- ## Usage
24
-
25
- Add one of the provided environments to your `jest.config.js`, in addition, the reporter provides a list ordered by severity and a total count overview.
26
-
27
- ```js
28
- export default {
29
- testEnvironment: 'jest-doctor/env/node',
30
- // optional
31
- reporters: ['default', 'jest-doctor/reporter']
32
- };
33
- ```
34
-
35
- Out-of-the-box jest-doctor supports node and jsdom environments. But you can also [build your own environment](./docs/build_your_own_environment.md).
36
-
37
- ### Configuration
38
-
39
- The environment can be configured through jest config `testEnvironmentOptions`:
40
- - **report**: an object defining which leaks should be tracked and reported
41
- - **timers**: `false` or object (default: object)
42
- - **onError**: `'warn' | 'throw'` (default `throw`) whether normal setTimeout and setInterval should be reported and how
43
- - **ignore**: `string | regexp | Array<string | regexp>` (default: []) allows to excluded timers from tracking if the stack trace matches
44
- - **fakeTimers**: `false'` or object (default object)
45
- - **onError**: `'warn' | 'throw'` (default `throw`) same as timers but for fake api
46
- - **ignore**: `string | regexp | Array<string | regexp>` (default: []) same as timers but for fake api
47
- - **promises**: `false'` (default object)
48
- - **onError**: `'warn' | 'throw'` (default `throw`) indicating if promises should be reported and how
49
- - **ignore**: `string | regexp | Array<string | regexp>` (default: []) same as timers but for promises
50
- - **console**: `false` or object (default object)
51
- - **onError**: `'warn' | 'throw'` (default `throw`) how to handle reporting
52
- - **methods**: `keyof Console` (default all methods) which console methods should be tracked
53
- - **ignore**: `string | regexp | Array<string | regexp>` (default: []) same as timers but for console output
54
- - **processOutputs**: `false` or object (default to object)
55
- - **onError**: `'warn' | 'throw'` (default `throw`) how to handle reporting
56
- - **methods**: `Array<stderr | stdout>` (default all methods) which process output methods should be tracked
57
- - **ignore**: `string | regexp | Array<string | regexp>` (default: []) same as timers but for process output
58
- - **timerIsolation**: `'afterEach' | 'immediate'` (default: `'afterEach'`)
59
- - **immediate**: report and clear timers directly after each test / hook block
60
- - **afterEach**: `beforeAll`, `beforeEach` and `afterAll` are immediate but `test` and `afterEach` block defer reporting and cleanup until the last `afterEach` block is executed (or directly after the test if there are no `afterEach` blocks). This allows an easier clean up for example react testing framework registers an unmount function in an `afterEach` block to clean up.
61
- - **delayThreshold**: `number` (default: `0`) the delay in milliseconds of all `setTimeout` and `setInterval` callback that get executed is add up. If this the sum is higher then the threshold at the end when reporting an error is throw, otherwise a warning is logged.
62
- - **clearTimers**: `boolean` (default: `true`) whether to clear all timers base on `timerIsolation`
63
-
64
- here is an example how the configuration could look like:
65
- ```js
66
- export default {
67
- testEnvironmentOptions: {
68
- report: {
69
- console: {
70
- onError: 'warn',
71
- methods: ["log", "warn", "error"],
72
- ignore: /Third party message/,
73
- },
74
- timers: {
75
- onError: 'warn',
76
- },
77
- fakeTimers: {
78
- onError: 'throw'
79
- },
80
- promises: false,
81
- },
82
- delayThreshold: 1000,
83
- timerIsolation: 'afterEach',
84
- clearTimers: true,
85
- },
86
- };
87
- ```
88
-
89
- the reporter can be configured by the standard jest reporter config syntax
90
- - **tmpDir**: `string` (default: `.tmp`) where to store reports from the environment to be read by the reporter
91
-
92
- ```js
93
- export default {
94
- reporters: [
95
- 'default',
96
- ['jest-doctor/reporter', { tmpDir: 'custom-dir' }]
97
- ],
98
- }
99
- ```
100
-
101
- ## Limitations
102
- - it.concurrent is replaced with a sync version
103
- - test and hook blocks do not support done callback or generators
104
- - promises that resolve within the next tick cannot be tracked, for example:
105
- ```js
106
- Promise.resolve().then(() => {
107
- /* i am not tracked as unresolved */
108
- });
109
- ```
110
- - Promise.race, Promise.any and Promise.all do not support nested blocks.
111
- ```js
112
- const doSomething = async () => {
113
- // both promises will be tracked and never released
114
- await someAsyncTask();
115
- return new Promise(() => { setTimeout(resolve, 10)})
116
- };
117
-
118
- const p1 = Promise.resolve()
119
- .then(() => { /* no problem if not async */});
120
-
121
- const p2 = Promise.resolve()
122
- .then(() => new Promise((resolve) => {
123
- /* the promise will be also always tracked */
124
- resolve();
125
- }));
126
-
127
- await Promise.race([p1, p2, doSomething()]);
128
- ```
129
-
130
- - setTimeout / setInterval can also be imported and will not participate in leak detection in these cases, but this can also serve as exit hatch if needed.
131
- ```js
132
- import { setTimeout, setInterval } from 'node:timers';
133
- ```
134
-
135
-
136
- ## Recommendations
137
- - use eslint to
138
- - detect floating promises
139
- - disallow setTimeout / setInterval in test files
140
- - disallow console usage
141
- - do only mock console / process output per test not globally, to avoid missing out on errors that are thrown in silence
142
- - enable fake timers globally in config (be aware that there might be some issues ie axe needs real timers)
143
- ```js
144
- afterEach(async () => {
145
- jest.useRealTimers();
146
- await axe();
147
- jest.useFakeTimers();
148
- });
149
- ```
150
-
151
- ## Tested against
152
- - Jest 27, 28, 29, 30
153
- - node 22, 24
154
-
155
- # FAQ
156
-
157
- ### Why is jest-doctor so strict?
158
- Because flaky tests cost more than broken builds.
159
-
160
- ### Does this slow tests down?
161
- Slightly. Overhead is intentional and bounded.
162
-
163
- ### Why does console output fail tests?
164
- It pollutes the console and is often a strong indicator that something is wrong.
165
- Tests should always spy on console and assert on the output.
1
+ # jest-doctor [![main](https://github.com/stephan-dum/jest-doctor/actions/workflows/main.yml/badge.svg)](https://github.com/stephan-dum/jest-doctor/actions/workflows/main.yml) [![codecov](https://codecov.io/gh/stephan-dum/jest-doctor/branch/main/graph/badge.svg)](https://codecov.io/gh/stephan-dum/jest-doctor) [![npm version](https://img.shields.io/npm/v/jest-doctor.svg)](https://www.npmjs.com/package/jest-doctor) [![License](https://img.shields.io/npm/l/jest-doctor.svg)](./LICENSE)
2
+
3
+ jest-doctor is a custom Jest environment that fails tests deterministically
4
+ when [async work leaks](#what-is-an-async-leak) across test boundaries.
5
+ It prevents flaky tests and enforces strong test hygiene.
6
+
7
+ ## โœจ What problems does it catch?
8
+
9
+ It detects and reports when tests:
10
+ - Leave unresolved Promises
11
+ - Leave open real or fake timers
12
+ - Rely on excessive real-time delays
13
+ - Emit process / console outputs
14
+
15
+ ---
16
+ **Docs**
17
+
18
+ - [Quick Start](#quick-start)
19
+ - [Configuration](#configuration)
20
+ - [Reporter](#reporter)
21
+ - [When not to use jest-doctor](#when-not-to-use-jest-doctor)
22
+ - [Limitations](#limitations)
23
+ - [FAQ](#faq)
24
+ - [Migration](./docs/migration.md)
25
+
26
+ ---
27
+ ## ๐Ÿš€ Quick Start
28
+
29
+ ```bash
30
+ npm install --save-dev jest-doctor
31
+ ```
32
+
33
+ or
34
+
35
+ ```bash
36
+ yarn add -D jest-doctor
37
+ ```
38
+
39
+ Add one of the provided environments to your `jest.config.js`.
40
+
41
+ ```js
42
+ export default {
43
+ testEnvironment: 'jest-doctor/env/node',
44
+ // optional
45
+ reporters: ['default', 'jest-doctor/reporter'],
46
+ };
47
+ ```
48
+
49
+ Out-of-the-box jest-doctor supports node and jsdom environments. But you can also [build your own environment](./docs/build_your_own_environment.md).
50
+
51
+ ---
52
+ ## โš™๏ธ Configuration
53
+
54
+ The environment can be configured through the Jest config `testEnvironmentOptions`:
55
+
56
+
57
+ ```js
58
+ export default {
59
+ testEnvironmentOptions: {
60
+ report: {
61
+ console: {
62
+ onError: 'warn',
63
+ methods: ['log', 'warn', 'error'],
64
+ ignore: /Third party message/,
65
+ },
66
+ timers: {
67
+ onError: 'warn',
68
+ },
69
+ fakeTimers: {
70
+ onError: 'throw',
71
+ },
72
+ promises: false,
73
+ processOutputs: {
74
+ onError: 'warn',
75
+ methods: ['stderr'],
76
+ },
77
+ },
78
+ delayThreshold: 1000,
79
+ timerIsolation: 'afterEach',
80
+ clearTimers: true,
81
+ },
82
+ };
83
+ ```
84
+
85
+ ### report
86
+ Controls which leak types are detected and how they are reported.
87
+
88
+ Each option can be:
89
+ - false โ†’ disabled
90
+ - object โ†’ enabled with configuration
91
+
92
+ Common options:
93
+ - **onError**: `'warn' | 'throw'` (default: `'throw'`)
94
+ - ignore: `string | RegExp | Array<string | RegExp>` (default: `[]`)
95
+ If the stack trace or emitted message matches, the leak is ignored.
96
+
97
+ #### possible report options
98
+
99
+ - **timers:** track real timers
100
+ - **fakeTimers:** track fake timers
101
+ - **promises:** track not awaited promises
102
+ - **console:** track console output
103
+ - **methods:** `Array<keyof Console>` (default: all) which console methods should be tracked
104
+ - **processOutputs:** track process output
105
+ - **methods:** `Array<'stderr' | 'stdout'>` (default: both) which process output methods should be tracked
106
+
107
+ ### timerIsolation
108
+ Controls when timers are validated and cleared.
109
+
110
+ **afterEach** (default)
111
+ `beforeAll`, `beforeEach` and `afterAll` are still immediate but `test` / `it` and `afterEach` block defer reporting and cleanup until the last `afterEach` block is executed (or directly after the test if there are no `afterEach` blocks).
112
+
113
+ ```
114
+ beforeAll โ†’ check
115
+ beforeEach โ†’ check
116
+ test โ†’ defer
117
+ afterEach โ†’ defer
118
+ afterEach โ†’ final check
119
+ afterAll โ†’ check
120
+ ```
121
+
122
+ This allows easier cleanup., for example react testing framework registers an unmount function in an `afterEach` block to clean up.
123
+ The disadvantage of this method is that it can happen that in an afterEach block a long-running task is executed and while running it timers resolve unnoticed.
124
+
125
+ **immediate**
126
+ timers are checked **after** each test / hook block
127
+ ```
128
+ beforeAll โ†’ check
129
+ beforeEach โ†’ check
130
+ test โ†’ check
131
+ afterEach โ†’ check
132
+ afterAll โ†’ check
133
+ ```
134
+ Use when tests should clean up immediately.
135
+
136
+ ### delayThreshold
137
+ `number` (default: `0`)
138
+
139
+ The delay in milliseconds of all `setTimeout` and `setInterval` callback that get executed is added up.
140
+ If the sum is higher than the threshold, an error is thrown; otherwise a warning is logged.
141
+ This feature should helps to detect tests that accidentally rely on real time.
142
+
143
+ ### clearTimers
144
+ `boolean` (default: `true`)
145
+
146
+ Whether timers should be cleared automatically based on `timerIsolation`.
147
+
148
+ ### verbose
149
+ `boolean` (default: `false`)
150
+
151
+ Jest often hides stack traces and files are not clickable.
152
+ Also it is only possible to report one error type at a time.
153
+ This option will print all errors with the related stack traces.
154
+
155
+ ---
156
+ ## ๐Ÿ“Š Reporter
157
+
158
+ The reporter aggregates leaks across all test environments and prints:
159
+
160
+ - Total number of leaks
161
+ - Grouped by type (timers, promises, console, etc.)
162
+ - Ordered by severity
163
+
164
+ The environment writes temporary reports to disk and the reporter reads them.
165
+
166
+ The reporter can be configured by the standard jest reporter config syntax
167
+
168
+ Options:
169
+
170
+
171
+ - **tmpDir**: `string` (default: `.tmp`) Directory used to exchange data between environment and reporter.
172
+
173
+ ```js
174
+ export default {
175
+ reporters: ['default', ['jest-doctor/reporter', { tmpDir: 'custom-dir' }]],
176
+ };
177
+ ```
178
+
179
+ ---
180
+ ## โš ๏ธ Limitations
181
+
182
+ ### No it.concurrent
183
+ Concurrent tests cannot be isolated reliably. jest-doctor replaces them with
184
+ a synchronous version to guarantee deterministic cleanup.
185
+
186
+ ### No done callbacks or generators
187
+ Since this is also a legacy pattern, it is not supported to avoid unnecessary complexity.
188
+
189
+ ## Results are inconsistent
190
+ Promises are handled differently depending on the OS and node version.
191
+ This means the report will always look a bit different depending on the environment.
192
+
193
+ ### Microtasks resolving in same tick are not tracked
194
+ This is a JavaScript limitation, not specific to jest-doctor.
195
+ ```js
196
+ Promise.resolve().then(() => {
197
+ /* i am not tracked as unresolved */
198
+ });
199
+ ```
200
+
201
+ ### Concurrent promise combinators with nested async are problematic
202
+ `Promise.race`, `Promise.any`, `Promise.all` cannot safely untrack nested async:
203
+
204
+ ```js
205
+ const doSomething = async () => {
206
+ // both promises will be tracked and never released
207
+ await someAsyncTask();
208
+ return new Promise(() => {
209
+ setTimeout(resolve, 10);
210
+ });
211
+ };
212
+
213
+ const p1 = Promise.resolve().then(() => {
214
+ /* no problem if not async */
215
+ });
216
+
217
+ const p2 = Promise.resolve().then(
218
+ () =>
219
+ new Promise((resolve) => {
220
+ /* this promise will be also always tracked */
221
+ resolve();
222
+ }),
223
+ );
224
+
225
+ await Promise.race([p1, p2, doSomething()]);
226
+ ```
227
+
228
+ ### Imported timers bypass tracking
229
+ These timers are not intercepted. This can also be used as an escape hatch.
230
+ ```js
231
+ import { setTimeout, setInterval } from 'node:timers';
232
+ ```
233
+
234
+ ---
235
+ ## ๐Ÿšซ When not to use jest-doctor
236
+ - Heavy integration tests with background workers
237
+ - Tests relying on long-running real timers
238
+ - Legacy test suites using callback-based async
239
+
240
+ In such cases, consider selectively disabling checks or using ignore rules.
241
+
242
+ ---
243
+ ## ๐Ÿ’ก Recommendations
244
+
245
+ - Use ESLint to
246
+ - detect floating promises
247
+ - disallow setTimeout / setInterval in tests
248
+ - disallow console usage
249
+ - Only mock console / process output *per test* not globally, to avoid missing out on errors that are thrown in silence
250
+ - Enable fake timers globally in config (be aware that there might be some issues ie axe needs real timers)
251
+
252
+ ```js
253
+ afterEach(async () => {
254
+ jest.useRealTimers();
255
+ await axe();
256
+ jest.useFakeTimers();
257
+ });
258
+ ```
259
+
260
+ ---
261
+ ## ๐Ÿงช Tested Against
262
+
263
+ This project is tested against the following combinations:
264
+ - **jest**: 28, 29, 30
265
+ - **node**: 20, 22, 24
266
+
267
+ ---
268
+ # โ“ FAQ
269
+
270
+ ### How to migrate an existing project?
271
+
272
+ Please read the [migration guide](./docs/migration.md).
273
+
274
+ ### Why is jest-doctor so strict?
275
+
276
+ Because flaky tests cost more than broken builds.
277
+
278
+ ### Does this slow tests down?
279
+
280
+ Slightly. Overhead is intentional and bounded.
281
+
282
+ ### What is an async leak?
283
+
284
+ An async leak happens when a test starts asynchronous work but does not
285
+ properly wait for or clean it up. This can:
286
+
287
+ - Interfere with later tests
288
+ - Cause flaky failures
289
+ - Hide real bugs
290
+
291
+
292
+ ### Why does console output fail tests?
293
+
294
+ In the best case it just pollutes the console.
295
+ In the worst case a real bug is logged but ignored.
296
+ Thats why tests should always spy on console and assert on the output.
297
+ The [react example](./e2e/fixtures/react.fixture.tsx#L20-L25) shows a common problem that can be caught by tests that mock console correctly.
298
+
299
+ ---
300
+
301
+ If jest-doctor helped you eliminate flaky tests, consider โญ starring the repo โ€”
302
+ it helps others discover the project and motivates continued development.