Skip to content

Commit dc2f4dc

Browse files
committed
feat(unbound-method): modify rule for jest
1 parent c0037ae commit dc2f4dc

File tree

8 files changed

+220
-6
lines changed

8 files changed

+220
-6
lines changed

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
coverage/
22
lib/
33
!.eslintrc.js
4+
src/rules/__tests__/fixtures/

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ installations requiring long-term consistency.
168168
| [prefer-todo](docs/rules/prefer-todo.md) | Suggest using `test.todo` | | ![fixable][] |
169169
| [require-to-throw-message](docs/rules/require-to-throw-message.md) | Require a message for `toThrow()` | | |
170170
| [require-top-level-describe](docs/rules/require-top-level-describe.md) | Require test cases and hooks to be inside a `describe` block | | |
171+
| [unbound-method](docs/rules/unbound-method.md) | Enforces unbound methods are called with their expected scope | | |
171172
| [valid-describe](docs/rules/valid-describe.md) | Enforce valid `describe()` callback | ![recommended][] | |
172173
| [valid-expect](docs/rules/valid-expect.md) | Enforce valid `expect()` usage | ![recommended][] | |
173174
| [valid-expect-in-promise](docs/rules/valid-expect-in-promise.md) | Enforce having return statement when testing with promises | ![recommended][] | |

docs/rules/unbound-method.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Enforces unbound methods are called with their expected scope (`unbound-method`)
2+
3+
## Rule Details
4+
5+
This rule extends the base [`@typescript-eslint/unbound-method`][original-rule]
6+
rule. It adds support for understanding when it's ok to pass an unbound method
7+
to `expect` calls.
8+
9+
See the [`@typescript-eslint` documentation][original-rule] for more details on
10+
the `unbound-method` rule.
11+
12+
Note that while this rule requires type information to work, it will fail
13+
silently when not available allowing you to safely enable it on projects that
14+
are not using TypeScript.
15+
16+
## How to use
17+
18+
```json5
19+
{
20+
parser: '@typescript-eslint/parser',
21+
parserOptions: {
22+
project: 'tsconfig.json',
23+
ecmaVersion: 2020,
24+
sourceType: 'module',
25+
},
26+
overrides: [
27+
{
28+
files: ['test/**'],
29+
extends: ['jest'],
30+
rules: {
31+
// you should turn the original rule off *only* for test files
32+
'@typescript-eslint/unbound-method': 'off',
33+
'jest/unbound-method': 'error',
34+
},
35+
},
36+
],
37+
rules: {
38+
'@typescript-eslint/unbound-method': 'error',
39+
},
40+
}
41+
```
42+
43+
This rule should be applied to your test files in place of the original rule,
44+
which should be applied to the rest of your codebase.
45+
46+
## Options
47+
48+
See [`@typescript-eslint/unbound-method`][original-rule] options.
49+
50+
<sup>Taken with ❤️ [from `@typescript-eslint` core][original-rule]</sup>
51+
52+
[original-rule]:
53+
https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/unbound-method.md

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@
6565
"displayName": "test",
6666
"testEnvironment": "node",
6767
"testPathIgnorePatterns": [
68-
"<rootDir>/lib/.*"
68+
"<rootDir>/lib/.*",
69+
"<rootDir>/src/rules/__tests__/fixtures/*"
6970
]
7071
},
7172
{

src/__tests__/__snapshots__/rules.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Object {
4646
"jest/prefer-todo": "error",
4747
"jest/require-to-throw-message": "error",
4848
"jest/require-top-level-describe": "error",
49+
"jest/unbound-method": "error",
4950
"jest/valid-describe": "error",
5051
"jest/valid-expect": "error",
5152
"jest/valid-expect-in-promise": "error",

src/__tests__/rules.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { existsSync } from 'fs';
22
import { resolve } from 'path';
33
import plugin from '../';
44

5-
const numberOfRules = 44;
5+
const numberOfRules = 45;
66
const ruleNames = Object.keys(plugin.rules);
77
const deprecatedRules = Object.entries(plugin.rules)
88
.filter(([, rule]) => rule.meta.deprecated)

src/rules/__tests__/unbound-method.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import path from 'path';
22
import { ESLintUtils, TSESLint } from '@typescript-eslint/experimental-utils';
3+
import dedent from 'dedent';
34
import rule, { MessageIds, Options } from '../unbound-method';
45

56
function getFixturesRootDir(): string {
@@ -17,6 +18,104 @@ const ruleTester = new ESLintUtils.RuleTester({
1718
},
1819
});
1920

21+
const ConsoleClassAndVariableCode = dedent`
22+
class Console {
23+
log(str) {
24+
process.stdout.write(str);
25+
}
26+
}
27+
28+
const console = new Console();
29+
`;
30+
31+
const toThrowMatchers = [
32+
'toThrow',
33+
'toThrowError',
34+
'toThrowErrorMatchingSnapshot',
35+
'toThrowErrorMatchingInlineSnapshot',
36+
];
37+
38+
const validTestCases: string[] = [
39+
...[
40+
'expect(Console.prototype.log)',
41+
'expect(Console.prototype.log).toHaveBeenCalledTimes(1);',
42+
'expect(Console.prototype.log).not.toHaveBeenCalled();',
43+
'expect(Console.prototype.log).toStrictEqual(somethingElse);',
44+
].map(code => [ConsoleClassAndVariableCode, code].join('\n')),
45+
dedent`
46+
expect(() => {
47+
${ConsoleClassAndVariableCode}
48+
49+
expect(Console.prototype.log).toHaveBeenCalledTimes(1);
50+
}).not.toThrow();
51+
`,
52+
'expect(() => Promise.resolve().then(console.log)).not.toThrow();',
53+
...toThrowMatchers.map(matcher => `expect(console.log).not.${matcher}();`),
54+
...toThrowMatchers.map(matcher => `expect(console.log).${matcher}();`),
55+
];
56+
57+
const invalidTestCases: Array<TSESLint.InvalidTestCase<MessageIds, Options>> = [
58+
{
59+
code: dedent`
60+
expect(() => {
61+
${ConsoleClassAndVariableCode}
62+
63+
Promise.resolve().then(console.log);
64+
}).not.toThrow();
65+
`,
66+
errors: [
67+
{
68+
line: 10,
69+
messageId: 'unbound',
70+
},
71+
],
72+
},
73+
// toThrow matchers call the expected value (which is expected to be a function)
74+
...toThrowMatchers.map(matcher => ({
75+
code: dedent`
76+
${ConsoleClassAndVariableCode}
77+
78+
expect(console.log).${matcher}();
79+
`,
80+
errors: [
81+
{
82+
line: 9,
83+
messageId: 'unbound' as const,
84+
},
85+
],
86+
})),
87+
// toThrow matchers call the expected value (which is expected to be a function)
88+
...toThrowMatchers.map(matcher => ({
89+
code: dedent`
90+
${ConsoleClassAndVariableCode}
91+
92+
expect(console.log).not.${matcher}();
93+
`,
94+
errors: [
95+
{
96+
line: 9,
97+
messageId: 'unbound' as const,
98+
},
99+
],
100+
})),
101+
];
102+
103+
ruleTester.run('unbound-method jest edition', rule, {
104+
valid: validTestCases,
105+
invalid: invalidTestCases,
106+
});
107+
108+
new ESLintUtils.RuleTester({
109+
parser: '@typescript-eslint/parser',
110+
parserOptions: {
111+
sourceType: 'module',
112+
tsconfigRootDir: rootPath,
113+
},
114+
}).run('unbound-method jest edition without type service', rule, {
115+
valid: validTestCases.concat(invalidTestCases.map(({ code }) => code)),
116+
invalid: [],
117+
});
118+
20119
function addContainsMethodsClass(code: string): string {
21120
return `
22121
class ContainsMethods {

src/rules/unbound-method.ts

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,49 @@ import {
22
ASTUtils,
33
AST_NODE_TYPES,
44
ESLintUtils,
5+
ParserServices,
6+
TSESLint,
57
TSESTree,
68
} from '@typescript-eslint/experimental-utils';
79
import * as ts from 'typescript';
8-
import { createRule } from './utils';
10+
import { createRule, isExpectCall, parseExpectCall } from './utils';
911

12+
//------------------------------------------------------------------------------
13+
// eslint-plugin-jest extension
14+
//------------------------------------------------------------------------------
15+
16+
const getParserServices = (
17+
context: Readonly<TSESLint.RuleContext<MessageIds, Options>>,
18+
): ParserServices | null => {
19+
try {
20+
return ESLintUtils.getParserServices(context);
21+
} catch {
22+
return null;
23+
}
24+
};
25+
26+
const toThrowMatchers = [
27+
'toThrow',
28+
'toThrowError',
29+
'toThrowErrorMatchingSnapshot',
30+
'toThrowErrorMatchingInlineSnapshot',
31+
];
32+
33+
const isJestExpectCall = (node: TSESTree.CallExpression) => {
34+
if (!isExpectCall(node)) {
35+
return false;
36+
}
37+
38+
const { matcher } = parseExpectCall(node);
39+
40+
return !toThrowMatchers.includes(matcher?.name ?? '');
41+
};
42+
43+
//------------------------------------------------------------------------------
44+
// Inlining of external dependencies
45+
//------------------------------------------------------------------------------
46+
47+
/* istanbul ignore next */
1048
const tsutils = {
1149
hasModifier(
1250
modifiers: ts.ModifiersArray | undefined,
@@ -28,7 +66,6 @@ const tsutils = {
2866

2967
const util = {
3068
createRule,
31-
getParserServices: ESLintUtils.getParserServices,
3269
isIdentifier: ASTUtils.isIdentifier,
3370
};
3471

@@ -108,6 +145,7 @@ const SUPPORTED_GLOBALS = [
108145
'Intl',
109146
] as const;
110147
const nativelyBoundMembers = SUPPORTED_GLOBALS.map(namespace => {
148+
/* istanbul ignore if */
111149
if (!(namespace in global)) {
112150
// node.js might not have namespaces like Intl depending on compilation options
113151
// https://nodejs.org/api/intl.html#intl_options_for_building_node_js
@@ -156,7 +194,7 @@ export default util.createRule<Options, MessageIds>({
156194
category: 'Best Practices',
157195
description:
158196
'Enforces unbound methods are called with their expected scope',
159-
recommended: 'error',
197+
recommended: false,
160198
requiresTypeChecking: true,
161199
},
162200
messages: {
@@ -182,14 +220,33 @@ export default util.createRule<Options, MessageIds>({
182220
},
183221
],
184222
create(context, [{ ignoreStatic }]) {
185-
const parserServices = util.getParserServices(context);
223+
const parserServices = getParserServices(context);
224+
225+
if (!parserServices) {
226+
return {};
227+
}
228+
186229
const checker = parserServices.program.getTypeChecker();
187230
const currentSourceFile = parserServices.program.getSourceFile(
188231
context.getFilename(),
189232
);
190233

234+
let inExpectCall = false;
235+
191236
return {
237+
CallExpression(node: TSESTree.CallExpression): void {
238+
inExpectCall = isJestExpectCall(node);
239+
},
240+
'CallExpression:exit'(node: TSESTree.CallExpression): void {
241+
if (inExpectCall && isJestExpectCall(node)) {
242+
inExpectCall = false;
243+
}
244+
},
192245
MemberExpression(node: TSESTree.MemberExpression): void {
246+
if (inExpectCall) {
247+
return;
248+
}
249+
193250
if (isSafeUse(node)) {
194251
return;
195252
}
@@ -233,6 +290,7 @@ export default util.createRule<Options, MessageIds>({
233290
rightSymbol && isNotImported(rightSymbol, currentSourceFile);
234291

235292
idNode.properties.forEach(property => {
293+
/* istanbul ignore else */
236294
if (
237295
property.type === AST_NODE_TYPES.Property &&
238296
property.key.type === AST_NODE_TYPES.Identifier

0 commit comments

Comments
 (0)