Skip to content

Commit 9054e31

Browse files
authored
Merge pull request #2138 from microsoft/octogonz/jest-eslint
[eslint-plugin] Add a new rule @rushstack/hoist-jest-mock
2 parents 7f40efe + 1cadbf8 commit 9054e31

File tree

9 files changed

+513
-4
lines changed

9 files changed

+513
-4
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/eslint-plugin",
5+
"comment": "Add new rule @rushstack/hoist-jest-mock",
6+
"type": "minor"
7+
}
8+
],
9+
"packageName": "@rushstack/eslint-plugin",
10+
"email": "[email protected]"
11+
}

stack/eslint-plugin/README.md

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,48 @@ which provides a TypeScript ESLint ruleset tailored for large teams and projects
55
Please see [that project's documentation](https://www.npmjs.com/package/@rushstack/eslint-config)
66
for details. To learn about Rush Stack, please visit: [https://rushstack.io/](https://rushstack.io/)
77

8+
## `@rushstack/hoist-jest-mock`
9+
10+
Require Jest module mocking APIs to be called before any other statements in their code block.
11+
12+
#### Rule Details
13+
14+
Jest module mocking APIs such as "jest.mock()" must be called before the associated module is imported, otherwise
15+
they will have no effect. Transpilers such as `ts-jest` and `babel-jest` automatically "hoist" these calls, however
16+
this can produce counterintuitive behavior. Instead, the `hoist-jest-mocks` lint rule simply requires developers
17+
to write the statements in the correct order.
18+
19+
The following APIs are affected: 'jest.mock()', 'jest.unmock()', 'jest.enableAutomock()', 'jest.disableAutomock()',
20+
'jest.deepUnmock()'.
21+
22+
For technical background, please read the Jest documentation here: https://jestjs.io/docs/en/es6-class-mocks
23+
24+
#### Examples
25+
26+
The following patterns are considered problems when `@rushstack/hoist-jest-mock` is enabled:
27+
28+
```ts
29+
import * as file from './file';
30+
jest.mock('./file'); // error
31+
32+
test("example", () => {
33+
const file2: typeof import('./file2') = require('./file2');
34+
jest.mock('./file2'); // error
35+
});
36+
```
37+
38+
The following patterns are NOT considered problems:
39+
40+
```ts
41+
jest.mock('./file'); // okay, because mock() is first
42+
import * as file from './file';
43+
44+
test("example", () => {
45+
jest.mock('./file2'); // okay, because mock() is first within the test() code block
46+
const file2: typeof import('./file2') = require('./file2');
47+
});
48+
```
49+
850
## `@rushstack/no-new-null`
951

1052
Prevent usage of the JavaScript `null` value, while allowing code to access existing APIs that
@@ -174,4 +216,3 @@ enum E {
174216
}
175217
let e: E._PrivateMember = E._PrivateMember; // okay, because _PrivateMember is declared by E
176218
```
177-
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import { ESLintUtils } from '@typescript-eslint/experimental-utils';
5+
import { hoistJestMock } from './hoist-jest-mock';
6+
7+
const { RuleTester } = ESLintUtils;
8+
const ruleTester = new RuleTester({
9+
parser: '@typescript-eslint/parser'
10+
});
11+
12+
// These are the CODE_WITH_HOISTING cases from ts-jest's hoist-jest.spec.ts
13+
const INVALID_EXAMPLE_CODE = [
14+
/* 001 */ "const foo = 'foo'",
15+
/* 002 */ 'console.log(foo)',
16+
/* 003 */ 'jest.enableAutomock()',
17+
/* 004 */ 'jest.disableAutomock()',
18+
/* 005 */ "jest.mock('./foo')",
19+
/* 006 */ "jest.mock('./foo/bar', () => 'bar')",
20+
/* 007 */ "jest.unmock('./bar/foo').dontMock('./bar/bar')",
21+
/* 008 */ "jest.deepUnmock('./foo')",
22+
/* 009 */ "jest.mock('./foo').mock('./bar')",
23+
/* 010 */ 'const func = () => {',
24+
/* 011 */ " const bar = 'bar'",
25+
/* 012 */ ' console.log(bar)',
26+
/* 013 */ " jest.unmock('./foo')",
27+
/* 014 */ " jest.mock('./bar')",
28+
/* 015 */ " jest.mock('./bar/foo', () => 'foo')",
29+
/* 016 */ " jest.unmock('./foo/bar')",
30+
/* 017 */ " jest.unmock('./bar/foo').dontMock('./bar/bar')",
31+
/* 018 */ " jest.deepUnmock('./bar')",
32+
/* 019 */ " jest.mock('./foo').mock('./bar')",
33+
/* 020 */ '}',
34+
/* 021 */ 'const func2 = () => {',
35+
/* 022 */ " const bar = 'bar'",
36+
/* 023 */ ' console.log(bar)',
37+
/* 024 */ " jest.mock('./bar')",
38+
/* 025 */ " jest.unmock('./foo/bar')",
39+
/* 026 */ " jest.mock('./bar/foo', () => 'foo')",
40+
/* 027 */ " jest.unmock('./foo')",
41+
/* 028 */ " jest.unmock('./bar/foo').dontMock('./bar/bar')",
42+
/* 029 */ " jest.deepUnmock('./bar')",
43+
/* 030 */ " jest.mock('./foo').mock('./bar')",
44+
/* 031 */ '}'
45+
].join('\n');
46+
47+
const VALID_EXAMPLE_CODE = [
48+
/* 001 */ 'jest.enableAutomock()',
49+
/* 002 */ 'jest.disableAutomock()',
50+
/* 003 */ "jest.mock('./foo')",
51+
/* 004 */ "jest.mock('./foo/bar', () => 'bar')",
52+
/* 005 */ "jest.unmock('./bar/foo').dontMock('./bar/bar')",
53+
/* 006 */ "jest.deepUnmock('./foo')",
54+
/* 007 */ "jest.mock('./foo').mock('./bar')",
55+
/* 008 */ "const foo = 'foo'",
56+
/* 009 */ 'console.log(foo)',
57+
/* 010 */ 'const func = () => {',
58+
/* 011 */ " jest.unmock('./foo')",
59+
/* 012 */ " jest.mock('./bar')",
60+
/* 013 */ " jest.mock('./bar/foo', () => 'foo')",
61+
/* 014 */ " jest.unmock('./foo/bar')",
62+
/* 015 */ " jest.unmock('./bar/foo').dontMock('./bar/bar')",
63+
/* 016 */ " jest.deepUnmock('./bar')",
64+
/* 017 */ " jest.mock('./foo').mock('./bar')",
65+
/* 018 */ " const bar = 'bar'",
66+
/* 019 */ ' console.log(bar)',
67+
/* 020 */ '}',
68+
/* 021 */ 'const func2 = () => {',
69+
/* 022 */ " jest.mock('./bar')",
70+
/* 023 */ " jest.unmock('./foo/bar')",
71+
/* 024 */ " jest.mock('./bar/foo', () => 'foo')",
72+
/* 025 */ " jest.unmock('./foo')",
73+
/* 026 */ " jest.unmock('./bar/foo').dontMock('./bar/bar')",
74+
/* 027 */ " jest.deepUnmock('./bar')",
75+
/* 038 */ " jest.mock('./foo').mock('./bar')",
76+
/* 029 */ " const bar = 'bar'",
77+
/* 030 */ ' console.log(bar)',
78+
/* 031 */ '}'
79+
].join('\n');
80+
81+
ruleTester.run('hoist-jest-mock', hoistJestMock, {
82+
invalid: [
83+
{
84+
code: INVALID_EXAMPLE_CODE,
85+
errors: [
86+
{ messageId: 'error-unhoisted-jest-mock', line: 3 },
87+
{ messageId: 'error-unhoisted-jest-mock', line: 4 },
88+
{ messageId: 'error-unhoisted-jest-mock', line: 5 },
89+
{ messageId: 'error-unhoisted-jest-mock', line: 6 },
90+
{ messageId: 'error-unhoisted-jest-mock', line: 7 },
91+
{ messageId: 'error-unhoisted-jest-mock', line: 8 },
92+
{ messageId: 'error-unhoisted-jest-mock', line: 9 },
93+
94+
{ messageId: 'error-unhoisted-jest-mock', line: 13 },
95+
{ messageId: 'error-unhoisted-jest-mock', line: 14 },
96+
{ messageId: 'error-unhoisted-jest-mock', line: 15 },
97+
{ messageId: 'error-unhoisted-jest-mock', line: 16 },
98+
{ messageId: 'error-unhoisted-jest-mock', line: 17 },
99+
{ messageId: 'error-unhoisted-jest-mock', line: 18 },
100+
{ messageId: 'error-unhoisted-jest-mock', line: 19 },
101+
102+
{ messageId: 'error-unhoisted-jest-mock', line: 24 },
103+
{ messageId: 'error-unhoisted-jest-mock', line: 25 },
104+
{ messageId: 'error-unhoisted-jest-mock', line: 26 },
105+
{ messageId: 'error-unhoisted-jest-mock', line: 27 },
106+
{ messageId: 'error-unhoisted-jest-mock', line: 28 },
107+
{ messageId: 'error-unhoisted-jest-mock', line: 29 },
108+
{ messageId: 'error-unhoisted-jest-mock', line: 30 }
109+
]
110+
}
111+
],
112+
valid: [{ code: VALID_EXAMPLE_CODE }]
113+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import type { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils';
5+
import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils';
6+
7+
import { matchTree } from './matchTree';
8+
import * as hoistJestMockPatterns from './hoistJestMockPatterns';
9+
10+
type MessageIds = 'error-unhoisted-jest-mock';
11+
type Options = [];
12+
13+
// Jest APIs that need to be hoisted
14+
// Based on HOIST_METHODS from ts-jest
15+
const HOIST_METHODS = ['mock', 'unmock', 'enableAutomock', 'disableAutomock', 'deepUnmock'];
16+
17+
const hoistJestMock: TSESLint.RuleModule<MessageIds, Options> = {
18+
meta: {
19+
type: 'problem',
20+
messages: {
21+
'error-unhoisted-jest-mock':
22+
"Jest's module mocking APIs must be called before their associated module is imported. " +
23+
' Move this statement to the top of its code block.'
24+
},
25+
schema: [
26+
{
27+
type: 'object',
28+
additionalProperties: false
29+
}
30+
],
31+
docs: {
32+
description:
33+
'Require Jest module mocking APIs to be called before any other statements in their code block.' +
34+
' Jest module mocking APIs such as "jest.mock(\'./example\')" must be called before the associated module' +
35+
' is imported, otherwise they will have no effect. Transpilers such as ts-jest and babel-jest automatically' +
36+
' "hoist" these calls, however this can produce counterintuitive results. Instead, the hoist-jest-mocks' +
37+
' lint rule requires developers to manually hoist these calls. For technical background, please read the' +
38+
' Jest documentation here: https://jestjs.io/docs/en/es6-class-mocks',
39+
category: 'Possible Errors',
40+
recommended: 'error',
41+
url: 'https://www.npmjs.com/package/@rushstack/eslint-plugin'
42+
}
43+
},
44+
45+
create: (context: TSESLint.RuleContext<MessageIds, Options>) => {
46+
function isHoistableJestCall(node: TSESTree.Node | undefined): boolean {
47+
if (node === undefined) {
48+
return false;
49+
}
50+
51+
const captures: hoistJestMockPatterns.IJestCallExpression = {};
52+
53+
if (matchTree(node, hoistJestMockPatterns.jestCallExpression, captures)) {
54+
if (captures.methodName && HOIST_METHODS.indexOf(captures.methodName) >= 0) {
55+
return true;
56+
}
57+
}
58+
59+
// Recurse into some common expression-combining syntaxes
60+
switch (node.type) {
61+
case AST_NODE_TYPES.CallExpression:
62+
return isHoistableJestCall(node.callee);
63+
case AST_NODE_TYPES.MemberExpression:
64+
return isHoistableJestCall(node.object);
65+
case AST_NODE_TYPES.LogicalExpression:
66+
return isHoistableJestCall(node.left) || isHoistableJestCall(node.right);
67+
}
68+
69+
return false;
70+
}
71+
72+
function isHoistableJestStatement(node: TSESTree.Node): boolean {
73+
switch (node.type) {
74+
case AST_NODE_TYPES.ExpressionStatement:
75+
return isHoistableJestCall(node.expression);
76+
}
77+
return false;
78+
}
79+
80+
return {
81+
'TSModuleBlock, BlockStatement, Program': (
82+
node: TSESTree.TSModuleBlock | TSESTree.BlockStatement | TSESTree.Program
83+
): void => {
84+
let encounteredRegularStatements: boolean = false;
85+
86+
for (const statement of node.body) {
87+
if (isHoistableJestStatement(statement)) {
88+
// Are we still at the start of the block?
89+
if (encounteredRegularStatements) {
90+
context.report({ node: statement, messageId: 'error-unhoisted-jest-mock' });
91+
}
92+
} else {
93+
// We encountered a non-hoistable statement, so any further children that we visit
94+
// must also be non-hoistable
95+
encounteredRegularStatements = true;
96+
}
97+
}
98+
}
99+
};
100+
}
101+
};
102+
103+
export { hoistJestMock };
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import { matchTreeArg } from './matchTree';
5+
6+
export interface IJestCallExpression {
7+
// Example: "mock" from "jest.mock('./thing')"
8+
methodName?: string;
9+
}
10+
11+
// Matches a statement expression like this:
12+
// jest.mock('./thing')
13+
//
14+
// Tree:
15+
// {
16+
// type: 'CallExpression',
17+
// callee: {
18+
// type: 'MemberExpression',
19+
// object: {
20+
// type: 'Identifier',
21+
// name: 'jest'
22+
// },
23+
// property: {
24+
// type: 'Identifier',
25+
// name: 'mock'
26+
// }
27+
// },
28+
// arguments: [
29+
// {
30+
// type: 'Literal',
31+
// value: './thing'
32+
// }
33+
// ]
34+
// };
35+
export const jestCallExpression = {
36+
type: 'CallExpression',
37+
callee: {
38+
type: 'MemberExpression',
39+
object: {
40+
type: 'Identifier',
41+
name: 'jest'
42+
},
43+
property: {
44+
type: 'Identifier',
45+
name: matchTreeArg('methodName')
46+
}
47+
}
48+
};

stack/eslint-plugin/src/index.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33

44
import { TSESLint } from '@typescript-eslint/experimental-utils';
55

6-
import { noNullRule } from './no-null';
6+
import { hoistJestMock } from './hoist-jest-mock';
77
import { noNewNullRule } from './no-new-null';
8+
import { noNullRule } from './no-null';
89
import { noUntypedUnderscoreRule } from './no-untyped-underscore';
910

1011
interface IPlugin {
@@ -13,9 +14,16 @@ interface IPlugin {
1314

1415
const plugin: IPlugin = {
1516
rules: {
16-
// NOTE: The actual ESLint rule name will be "@rushstack/no-null".
17-
'no-null': noNullRule,
17+
// Full name: "@rushstack/hoist-jest-mock"
18+
'hoist-jest-mock': hoistJestMock,
19+
20+
// Full name: "@rushstack/no-new-null"
1821
'no-new-null': noNewNullRule,
22+
23+
// Full name: "@rushstack/no-null"
24+
'no-null': noNullRule,
25+
26+
// Full name: "@rushstack/no-untyped-underscore"
1927
'no-untyped-underscore': noUntypedUnderscoreRule
2028
}
2129
};

0 commit comments

Comments
 (0)