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..508511be3 --- /dev/null +++ b/docs/rules/prefer-mocked.md @@ -0,0 +1,31 @@ +# 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. + +## 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..f5bfd9f38 --- /dev/null +++ b/src/rules/__tests__/prefer-mocked.test.ts @@ -0,0 +1,321 @@ +import path from 'path'; +import dedent from 'dedent'; +import rule from '../prefer-mocked'; +import { FlatCompatRuleTester as RuleTester } from './test-utils'; + +function getFixturesRootDir(): string { + return path.join(__dirname, 'fixtures'); +} + +const rootPath = getFixturesRootDir(); + +const ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { + sourceType: 'module', + tsconfigRootDir: rootPath, + project: './tsconfig.json', + }, +}); + +ruleTester.run('prefer-mocked', rule, { + valid: [ + dedent` + import { foo } from './foo'; + foo(); + `, + + dedent` + import { foo } from './foo'; + jest.mocked(foo).mockReturnValue(1); + `, + + dedent` + import { bar } from './bar'; + bar.mockReturnValue(1); + `, + + dedent` + import { foo } from './foo'; + sinon.stub(foo).returns(1); + `, + + dedent` + import { foo } from './foo'; + foo.mockImplementation(() => 1); + `, + + dedent` + const obj = { foo() {} }; + obj.foo(); + `, + + dedent` + const mockFn = jest.fn(); + mockFn.mockReturnValue(1); + `, + + dedent` + const arr = [() => {}]; + arr[0](); + `, + + dedent` + const obj = { foo() {} }; + obj.foo.mockReturnValue(1); + `, + + dedent` + const obj = { foo() {} }; + jest.spyOn(obj, 'foo').mockReturnValue(1); + `, + + dedent` + type MockType = jest.Mock; + const mockFn = jest.fn(); + (mockFn as MockType).mockReturnValue(1); + `, + ], + invalid: [ + { + code: dedent` + import { foo } from './foo'; + + (foo as jest.Mock).mockReturnValue(1); + `, + output: dedent` + import { foo } from './foo'; + + (jest.mocked(foo)).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 3, + }, + ], + }, + { + code: dedent` + import { foo } from './foo'; + + (foo as jest.Mock).mockImplementation(1); + `, + output: dedent` + import { foo } from './foo'; + + (jest.mocked(foo)).mockImplementation(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 3, + }, + ], + }, + { + code: dedent` + import { foo } from './foo'; + + (foo as unknown as jest.Mock).mockReturnValue(1); + `, + output: dedent` + import { foo } from './foo'; + + (jest.mocked(foo)).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 3, + }, + ], + }, + { + code: dedent` + import { Obj } from './foo'; + + (Obj.foo as jest.Mock).mockReturnValue(1); + `, + output: dedent` + import { Obj } from './foo'; + + (jest.mocked(Obj.foo)).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 3, + }, + ], + }, + { + code: dedent` + ([].foo as jest.Mock).mockReturnValue(1); + `, + output: dedent` + (jest.mocked([].foo)).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + }, + ], + }, + { + code: dedent` + import { foo } from './foo'; + + (foo as jest.MockedFunction).mockReturnValue(1); + `, + output: dedent` + import { foo } from './foo'; + + (jest.mocked(foo)).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 3, + }, + ], + }, + { + code: dedent` + import { foo } from './foo'; + + (foo as jest.MockedFunction).mockImplementation(1); + `, + output: dedent` + import { foo } from './foo'; + + (jest.mocked(foo)).mockImplementation(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 3, + }, + ], + }, + { + code: dedent` + import { foo } from './foo'; + + (foo as unknown as jest.MockedFunction).mockReturnValue(1); + `, + output: dedent` + import { foo } from './foo'; + + (jest.mocked(foo)).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 3, + }, + ], + }, + { + code: dedent` + import { Obj } from './foo'; + + (Obj.foo as jest.MockedFunction).mockReturnValue(1); + `, + output: dedent` + import { Obj } from './foo'; + + (jest.mocked(Obj.foo)).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 3, + }, + ], + }, + { + 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, + }, + ], + }, + { + 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, + }, + ], + }, + { + code: dedent` + const mockedUseFocused = useFocused as jest.MockedFunction; + `, + output: dedent` + const mockedUseFocused = jest.mocked(useFocused); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 26, + line: 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, + }, + ], + }, + ], +}); diff --git a/src/rules/prefer-mocked.ts b/src/rules/prefer-mocked.ts new file mode 100644 index 000000000..77fc1c3c3 --- /dev/null +++ b/src/rules/prefer-mocked.ts @@ -0,0 +1,56 @@ +import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils'; +import { createRule } from './utils'; + +type ValidatedTsAsExpression = TSESTree.TSAsExpression & { + typeAnnotation: TSESTree.TSTypeReference & { + typeName: TSESTree.TSQualifiedName & { + left: TSESTree.Identifier; + right: TSESTree.Identifier; + }; + }; +}; + +function getFnName(node: TSESTree.Expression, sourceCode: string): string { + if (node.type === AST_NODE_TYPES.TSAsExpression) { + // case: `myFn as unknown as jest.Mock` + return getFnName(node.expression, sourceCode); + } + + return sourceCode.slice(...node.range); +} + +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) { + return { + 'TSAsExpression:has(TSTypeReference > TSQualifiedName:has(Identifier.left[name="jest"]):has(Identifier.right[name="Mock"],Identifier.right[name="MockedFunction"]))'( + node: ValidatedTsAsExpression, + ) { + const fnName = getFnName(node.expression, context.getSourceCode().text); + + context.report({ + node, + messageId: 'useJestMocked', + data: { + replacement: '', + }, + fix(fixer) { + return fixer.replaceText(node, `jest.mocked(${fnName})`); + }, + }); + }, + }; + }, +});