From a4c08edaae4a4991435ce94dc7c06685b4530de4 Mon Sep 17 00:00:00 2001 From: "s.v.zaytsev" Date: Thu, 30 May 2024 19:23:22 +0500 Subject: [PATCH] feat(prefer-mocked): add new rule (#1470) --- README.md | 1 + docs/rules/prefer-mocked.md | 37 ++ .../__snapshots__/rules.test.ts.snap | 2 + src/__tests__/rules.test.ts | 2 +- src/rules/__tests__/prefer-mocked.test.ts | 407 ++++++++++++++++++ src/rules/prefer-mocked.ts | 74 ++++ 6 files changed, 522 insertions(+), 1 deletion(-) create mode 100644 docs/rules/prefer-mocked.md create mode 100644 src/rules/__tests__/prefer-mocked.test.ts create mode 100644 src/rules/prefer-mocked.ts diff --git a/README.md b/README.md index a14bbd5f5..cd64ae8d4 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,7 @@ set to warn in.\ | [prefer-importing-jest-globals](docs/rules/prefer-importing-jest-globals.md) | Prefer importing Jest globals | | | 🔧 | | | [prefer-lowercase-title](docs/rules/prefer-lowercase-title.md) | Enforce lowercase test names | | | 🔧 | | | [prefer-mock-promise-shorthand](docs/rules/prefer-mock-promise-shorthand.md) | Prefer mock resolved/rejected shorthands for promises | | | 🔧 | | +| [prefer-mocked](docs/rules/prefer-mocked.md) | Prefer jest.mocked() over (fn as jest.Mock) | | | 🔧 | | | [prefer-snapshot-hint](docs/rules/prefer-snapshot-hint.md) | Prefer including a hint with external snapshots | | | | | | [prefer-spy-on](docs/rules/prefer-spy-on.md) | Suggest using `jest.spyOn()` | | | 🔧 | | | [prefer-strict-equal](docs/rules/prefer-strict-equal.md) | Suggest using `toStrictEqual()` | | | | 💡 | diff --git a/docs/rules/prefer-mocked.md b/docs/rules/prefer-mocked.md new file mode 100644 index 000000000..73af620d7 --- /dev/null +++ b/docs/rules/prefer-mocked.md @@ -0,0 +1,37 @@ +# Prefer jest.mocked() over (fn as jest.Mock) (`prefer-mocked`) + +🔧 This rule is automatically fixable by the +[`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +When working with mocks of functions using Jest, it's recommended to use the +jest.mocked helper function to properly type the mocked functions. This rule +enforces the use of jest.mocked for better type safety and readability. + +Restricted types: +* `jest.Mock` +* `jest.MockedFunction` +* `jest.MockedClass` +* `jest.MockedObject` + +## Rule details + +The following patterns are warnings: + +```typescript +(foo as jest.Mock).mockReturnValue(1); +const mock = (foo as jest.Mock).mockReturnValue(1); +(foo as unknown as jest.Mock).mockReturnValue(1); +(Obj.foo as jest.Mock).mockReturnValue(1); +([].foo as jest.Mock).mockReturnValue(1); +``` + +The following patterns are not warnings: + +```js +jest.mocked(foo).mockReturnValue(1); +const mock = jest.mocked(foo).mockReturnValue(1); +jest.mocked(Obj.foo).mockReturnValue(1); +jest.mocked([].foo).mockReturnValue(1); +``` diff --git a/src/__tests__/__snapshots__/rules.test.ts.snap b/src/__tests__/__snapshots__/rules.test.ts.snap index 9112e08ee..97221a362 100644 --- a/src/__tests__/__snapshots__/rules.test.ts.snap +++ b/src/__tests__/__snapshots__/rules.test.ts.snap @@ -48,6 +48,7 @@ exports[`rules should export configs that refer to actual rules 1`] = ` "jest/prefer-importing-jest-globals": "error", "jest/prefer-lowercase-title": "error", "jest/prefer-mock-promise-shorthand": "error", + "jest/prefer-mocked": "error", "jest/prefer-snapshot-hint": "error", "jest/prefer-spy-on": "error", "jest/prefer-strict-equal": "error", @@ -130,6 +131,7 @@ exports[`rules should export configs that refer to actual rules 1`] = ` "jest/prefer-importing-jest-globals": "error", "jest/prefer-lowercase-title": "error", "jest/prefer-mock-promise-shorthand": "error", + "jest/prefer-mocked": "error", "jest/prefer-snapshot-hint": "error", "jest/prefer-spy-on": "error", "jest/prefer-strict-equal": "error", diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index b70ba93ac..469b95472 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -2,7 +2,7 @@ import { existsSync } from 'fs'; import { resolve } from 'path'; import plugin from '../'; -const numberOfRules = 53; +const numberOfRules = 54; const ruleNames = Object.keys(plugin.rules); const deprecatedRules = Object.entries(plugin.rules) .filter(([, rule]) => rule.meta.deprecated) diff --git a/src/rules/__tests__/prefer-mocked.test.ts b/src/rules/__tests__/prefer-mocked.test.ts new file mode 100644 index 000000000..fffc518f5 --- /dev/null +++ b/src/rules/__tests__/prefer-mocked.test.ts @@ -0,0 +1,407 @@ +import dedent from 'dedent'; +import rule from '../prefer-mocked'; +import { FlatCompatRuleTester as RuleTester } from './test-utils'; + +const ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), +}); + +ruleTester.run('prefer-mocked', rule, { + valid: [ + dedent` + foo(); + `, + + dedent` + jest.mocked(foo).mockReturnValue(1); + `, + + dedent` + bar.mockReturnValue(1); + `, + + dedent` + sinon.stub(foo).returns(1); + `, + + dedent` + foo.mockImplementation(() => 1); + `, + + dedent` + obj.foo(); + `, + + dedent` + mockFn.mockReturnValue(1); + `, + + dedent` + arr[0](); + `, + + dedent` + obj.foo.mockReturnValue(1); + `, + + dedent` + jest.spyOn(obj, 'foo').mockReturnValue(1); + `, + + dedent` + type MockType = jest.Mock; + const mockFn = jest.fn(); + (mockFn as MockType).mockReturnValue(1); + `, + + dedent` + (foo as Mock.jest).mockReturnValue(1); + `, + ], + invalid: [ + { + code: dedent` + (foo as jest.Mock).mockReturnValue(1); + `, + output: dedent` + (jest.mocked(foo)).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 18, + endLine: 1, + }, + ], + }, + { + code: dedent` + (foo as unknown as string as unknown as jest.Mock).mockReturnValue(1); + `, + output: dedent` + (jest.mocked(foo)).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 50, + endLine: 1, + }, + ], + }, + { + code: dedent` + (foo as unknown as jest.Mock as unknown as jest.Mock).mockReturnValue(1); + `, + output: dedent` + (jest.mocked(foo)).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 53, + endLine: 1, + }, + ], + }, + { + code: dedent` + (foo).mockReturnValue(1); + `, + output: dedent` + (jest.mocked(foo)).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 16, + endLine: 1, + }, + ], + }, + { + code: dedent` + (foo as jest.Mock).mockImplementation(1); + `, + output: dedent` + (jest.mocked(foo)).mockImplementation(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 18, + endLine: 1, + }, + ], + }, + { + code: dedent` + (foo as unknown as jest.Mock).mockReturnValue(1); + `, + output: dedent` + (jest.mocked(foo)).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 29, + endLine: 1, + }, + ], + }, + { + code: dedent` + (foo as unknown).mockReturnValue(1); + `, + output: dedent` + (jest.mocked(foo) as unknown).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 16, + endLine: 1, + }, + ], + }, + { + code: dedent` + (Obj.foo as jest.Mock).mockReturnValue(1); + `, + output: dedent` + (jest.mocked(Obj.foo)).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 22, + endLine: 1, + }, + ], + }, + { + code: dedent` + ([].foo as jest.Mock).mockReturnValue(1); + `, + output: dedent` + (jest.mocked([].foo)).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 21, + endLine: 1, + }, + ], + }, + { + code: dedent` + (foo as jest.MockedFunction).mockReturnValue(1); + `, + output: dedent` + (jest.mocked(foo)).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 28, + endLine: 1, + }, + ], + }, + { + code: dedent` + (foo as jest.MockedFunction).mockImplementation(1); + `, + output: dedent` + (jest.mocked(foo)).mockImplementation(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 28, + endLine: 1, + }, + ], + }, + { + code: dedent` + (foo as unknown as jest.MockedFunction).mockReturnValue(1); + `, + output: dedent` + (jest.mocked(foo)).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 39, + endLine: 1, + }, + ], + }, + { + code: dedent` + (Obj.foo as jest.MockedFunction).mockReturnValue(1); + `, + output: dedent` + (jest.mocked(Obj.foo)).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 32, + endLine: 1, + }, + ], + }, + { + code: dedent` + (new Array(0).fill(null).foo as jest.MockedFunction).mockReturnValue(1); + `, + output: dedent` + (jest.mocked(new Array(0).fill(null).foo)).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 52, + endLine: 1, + }, + ], + }, + { + code: dedent` + (jest.fn(() => foo) as jest.MockedFunction).mockReturnValue(1); + `, + output: dedent` + (jest.mocked(jest.fn(() => foo))).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 43, + endLine: 1, + }, + ], + }, + { + code: dedent` + const mockedUseFocused = useFocused as jest.MockedFunction; + `, + output: dedent` + const mockedUseFocused = jest.mocked(useFocused); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 26, + line: 1, + endColumn: 78, + endLine: 1, + }, + ], + }, + { + code: dedent` + const filter = (MessageService.getMessage as jest.Mock).mock.calls[0][0]; + `, + output: dedent` + const filter = (jest.mocked(MessageService.getMessage)).mock.calls[0][0]; + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 17, + line: 1, + endColumn: 55, + endLine: 1, + }, + ], + }, + { + code: dedent` + class A {} + (foo as jest.MockedClass) + `, + output: dedent` + class A {} + (jest.mocked(foo)) + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 2, + endColumn: 28, + endLine: 2, + }, + ], + }, + { + code: dedent` + (foo as jest.MockedObject<{method: () => void}>) + `, + output: dedent` + (jest.mocked(foo)) + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 48, + endLine: 1, + }, + ], + }, + ], +}); diff --git a/src/rules/prefer-mocked.ts b/src/rules/prefer-mocked.ts new file mode 100644 index 000000000..019b42c0a --- /dev/null +++ b/src/rules/prefer-mocked.ts @@ -0,0 +1,74 @@ +import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils'; +import { createRule, followTypeAssertionChain } from './utils'; + +const mockTypes = ['Mock', 'MockedFunction', 'MockedClass', 'MockedObject']; + +export default createRule({ + name: __filename, + meta: { + docs: { + description: 'Prefer jest.mocked() over (fn as jest.Mock)', + }, + messages: { + useJestMocked: 'Prefer jest.mocked({{ replacement }})', + }, + schema: [], + type: 'suggestion', + fixable: 'code', + }, + defaultOptions: [], + create(context) { + function check(node: TSESTree.TSAsExpression | TSESTree.TSTypeAssertion) { + const { typeAnnotation } = node; + + if (typeAnnotation.type !== AST_NODE_TYPES.TSTypeReference) { + return; + } + + const { typeName } = typeAnnotation; + + if (typeName.type !== AST_NODE_TYPES.TSQualifiedName) { + return; + } + + const { left, right } = typeName; + + if ( + left.type !== AST_NODE_TYPES.Identifier || + right.type !== AST_NODE_TYPES.Identifier || + left.name !== 'jest' || + !mockTypes.includes(right.name) + ) { + return; + } + + const fnName = context + .getSourceCode() + .text.slice(...followTypeAssertionChain(node.expression).range); + + context.report({ + node: node, + messageId: 'useJestMocked', + data: { + replacement: '', + }, + fix(fixer) { + return fixer.replaceText(node, `jest.mocked(${fnName})`); + }, + }); + } + + return { + TSAsExpression(node) { + if (node.parent.type === AST_NODE_TYPES.TSAsExpression) { + return; + } + + check(node); + }, + TSTypeAssertion(node) { + check(node); + }, + }; + }, +});