Skip to content

Commit 475d256

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

File tree

8 files changed

+166
-6
lines changed

8 files changed

+166
-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: 62 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,67 @@ 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 validTestCases: string[] = [
32+
...[
33+
'expect(Console.prototype.log)',
34+
'expect(Console.prototype.log).toHaveBeenCalledTimes(1);',
35+
'expect(Console.prototype.log).not.toHaveBeenCalled();',
36+
'expect(Console.prototype.log).toStrictEqual(somethingElse);',
37+
].map(code => [ConsoleClassAndVariableCode, code].join('\n')),
38+
dedent`
39+
expect(() => {
40+
${ConsoleClassAndVariableCode}
41+
42+
expect(Console.prototype.log).toHaveBeenCalledTimes(1);
43+
}).not.toThrow();
44+
`,
45+
'expect(() => Promise.resolve().then(console.log)).not.toThrow();',
46+
];
47+
48+
const invalidTestCases: Array<TSESLint.InvalidTestCase<MessageIds, Options>> = [
49+
{
50+
code: dedent`
51+
expect(() => {
52+
${ConsoleClassAndVariableCode}
53+
54+
Promise.resolve().then(console.log);
55+
}).not.toThrow();
56+
`,
57+
errors: [
58+
{
59+
line: 10,
60+
messageId: 'unbound',
61+
},
62+
],
63+
},
64+
];
65+
66+
ruleTester.run('unbound-method jest edition', rule, {
67+
valid: validTestCases,
68+
invalid: invalidTestCases,
69+
});
70+
71+
new ESLintUtils.RuleTester({
72+
parser: '@typescript-eslint/parser',
73+
parserOptions: {
74+
sourceType: 'module',
75+
tsconfigRootDir: rootPath,
76+
},
77+
}).run('unbound-method jest edition without type service', rule, {
78+
valid: validTestCases.concat(invalidTestCases.map(({ code }) => code)),
79+
invalid: [],
80+
});
81+
2082
function addContainsMethodsClass(code: string): string {
2183
return `
2284
class ContainsMethods {

src/rules/unbound-method.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,32 @@ 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 } 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+
//------------------------------------------------------------------------------
27+
// Inlining of external dependencies
28+
//------------------------------------------------------------------------------
29+
30+
/* istanbul ignore next */
1031
const tsutils = {
1132
hasModifier(
1233
modifiers: ts.ModifiersArray | undefined,
@@ -28,7 +49,6 @@ const tsutils = {
2849

2950
const util = {
3051
createRule,
31-
getParserServices: ESLintUtils.getParserServices,
3252
isIdentifier: ASTUtils.isIdentifier,
3353
};
3454

@@ -108,6 +128,7 @@ const SUPPORTED_GLOBALS = [
108128
'Intl',
109129
] as const;
110130
const nativelyBoundMembers = SUPPORTED_GLOBALS.map(namespace => {
131+
/* istanbul ignore if */
111132
if (!(namespace in global)) {
112133
// node.js might not have namespaces like Intl depending on compilation options
113134
// https://nodejs.org/api/intl.html#intl_options_for_building_node_js
@@ -156,7 +177,7 @@ export default util.createRule<Options, MessageIds>({
156177
category: 'Best Practices',
157178
description:
158179
'Enforces unbound methods are called with their expected scope',
159-
recommended: 'error',
180+
recommended: false,
160181
requiresTypeChecking: true,
161182
},
162183
messages: {
@@ -182,14 +203,33 @@ export default util.createRule<Options, MessageIds>({
182203
},
183204
],
184205
create(context, [{ ignoreStatic }]) {
185-
const parserServices = util.getParserServices(context);
206+
const parserServices = getParserServices(context);
207+
208+
if (!parserServices) {
209+
return {};
210+
}
211+
186212
const checker = parserServices.program.getTypeChecker();
187213
const currentSourceFile = parserServices.program.getSourceFile(
188214
context.getFilename(),
189215
);
190216

217+
let inExpectCall = false;
218+
191219
return {
220+
CallExpression(node: TSESTree.CallExpression): void {
221+
inExpectCall = isExpectCall(node);
222+
},
223+
'CallExpression:exit'(node: TSESTree.CallExpression): void {
224+
if (inExpectCall && isExpectCall(node)) {
225+
inExpectCall = false;
226+
}
227+
},
192228
MemberExpression(node: TSESTree.MemberExpression): void {
229+
if (inExpectCall) {
230+
return;
231+
}
232+
193233
if (isSafeUse(node)) {
194234
return;
195235
}
@@ -233,6 +273,7 @@ export default util.createRule<Options, MessageIds>({
233273
rightSymbol && isNotImported(rightSymbol, currentSourceFile);
234274

235275
idNode.properties.forEach(property => {
276+
/* istanbul ignore else */
236277
if (
237278
property.type === AST_NODE_TYPES.Property &&
238279
property.key.type === AST_NODE_TYPES.Identifier

0 commit comments

Comments
 (0)