diff --git a/README.md b/README.md index a14bbd5f5..19c755cc5 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,7 @@ set to warn in.\ | [prefer-hooks-in-order](docs/rules/prefer-hooks-in-order.md) | Prefer having hooks in a consistent order | | | | | | [prefer-hooks-on-top](docs/rules/prefer-hooks-on-top.md) | Suggest having hooks before any test cases | | | | | | [prefer-importing-jest-globals](docs/rules/prefer-importing-jest-globals.md) | Prefer importing Jest globals | | | 🔧 | | +| [prefer-jest-mocked](docs/rules/prefer-jest-mocked.md) | Prefer `jest.mocked()` over `fn as jest.Mock` | | | 🔧 | | | [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-snapshot-hint](docs/rules/prefer-snapshot-hint.md) | Prefer including a hint with external snapshots | | | | | diff --git a/docs/rules/prefer-jest-mocked.md b/docs/rules/prefer-jest-mocked.md new file mode 100644 index 000000000..b6a6a6613 --- /dev/null +++ b/docs/rules/prefer-jest-mocked.md @@ -0,0 +1,38 @@ +# Prefer `jest.mocked()` over `fn as jest.Mock` (`prefer-jest-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..e5a158bc3 100644 --- a/src/__tests__/__snapshots__/rules.test.ts.snap +++ b/src/__tests__/__snapshots__/rules.test.ts.snap @@ -46,6 +46,7 @@ exports[`rules should export configs that refer to actual rules 1`] = ` "jest/prefer-hooks-in-order": "error", "jest/prefer-hooks-on-top": "error", "jest/prefer-importing-jest-globals": "error", + "jest/prefer-jest-mocked": "error", "jest/prefer-lowercase-title": "error", "jest/prefer-mock-promise-shorthand": "error", "jest/prefer-snapshot-hint": "error", @@ -128,6 +129,7 @@ exports[`rules should export configs that refer to actual rules 1`] = ` "jest/prefer-hooks-in-order": "error", "jest/prefer-hooks-on-top": "error", "jest/prefer-importing-jest-globals": "error", + "jest/prefer-jest-mocked": "error", "jest/prefer-lowercase-title": "error", "jest/prefer-mock-promise-shorthand": "error", "jest/prefer-snapshot-hint": "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-jest-mocked.test.ts b/src/rules/__tests__/prefer-jest-mocked.test.ts new file mode 100644 index 000000000..983f7987e --- /dev/null +++ b/src/rules/__tests__/prefer-jest-mocked.test.ts @@ -0,0 +1,347 @@ +import dedent from 'dedent'; +import rule from '../prefer-jest-mocked'; +import { FlatCompatRuleTester as RuleTester } from './test-utils'; + +const ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), +}); + +ruleTester.run('prefer-jest-mocked', rule, { + valid: [ + `foo();`, + `jest.mocked(foo).mockReturnValue(1);`, + `bar.mockReturnValue(1);`, + `sinon.stub(foo).returns(1);`, + `foo.mockImplementation(() => 1);`, + `obj.foo();`, + `mockFn.mockReturnValue(1);`, + `arr[0]();`, + `obj.foo.mockReturnValue(1);`, + `jest.spyOn(obj, 'foo').mockReturnValue(1);`, + `(foo as Mock.jest).mockReturnValue(1);`, + + dedent` + type MockType = jest.Mock; + const mockFn = jest.fn(); + (mockFn as MockType).mockReturnValue(1); + `, + ], + invalid: [ + { + code: `(foo as jest.Mock).mockReturnValue(1);`, + output: `(jest.mocked(foo)).mockReturnValue(1);`, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 18, + endLine: 1, + }, + ], + }, + { + code: `(foo as unknown as string as unknown as jest.Mock).mockReturnValue(1);`, + output: `(jest.mocked(foo)).mockReturnValue(1);`, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 50, + endLine: 1, + }, + ], + }, + { + code: `(foo as unknown as jest.Mock as unknown as jest.Mock).mockReturnValue(1);`, + output: `(jest.mocked(foo)).mockReturnValue(1);`, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 53, + endLine: 1, + }, + ], + }, + { + code: `(foo).mockReturnValue(1);`, + output: `(jest.mocked(foo)).mockReturnValue(1);`, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 16, + endLine: 1, + }, + ], + }, + { + code: `(foo as jest.Mock).mockImplementation(1);`, + output: `(jest.mocked(foo)).mockImplementation(1);`, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 18, + endLine: 1, + }, + ], + }, + { + code: `(foo as unknown as jest.Mock).mockReturnValue(1);`, + output: `(jest.mocked(foo)).mockReturnValue(1);`, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 29, + endLine: 1, + }, + ], + }, + { + code: `(foo as unknown).mockReturnValue(1);`, + output: `(jest.mocked(foo) as unknown).mockReturnValue(1);`, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 16, + endLine: 1, + }, + ], + }, + { + code: `(Obj.foo as jest.Mock).mockReturnValue(1);`, + output: `(jest.mocked(Obj.foo)).mockReturnValue(1);`, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 22, + endLine: 1, + }, + ], + }, + { + code: `([].foo as jest.Mock).mockReturnValue(1);`, + output: `(jest.mocked([].foo)).mockReturnValue(1);`, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 21, + endLine: 1, + }, + ], + }, + { + code: `(foo as jest.MockedFunction).mockReturnValue(1);`, + output: `(jest.mocked(foo)).mockReturnValue(1);`, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 28, + endLine: 1, + }, + ], + }, + { + code: `(foo as jest.MockedFunction).mockImplementation(1);`, + output: `(jest.mocked(foo)).mockImplementation(1);`, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 28, + endLine: 1, + }, + ], + }, + { + code: `(foo as unknown as jest.MockedFunction).mockReturnValue(1);`, + output: `(jest.mocked(foo)).mockReturnValue(1);`, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 39, + endLine: 1, + }, + ], + }, + { + code: `(Obj.foo as jest.MockedFunction).mockReturnValue(1);`, + output: `(jest.mocked(Obj.foo)).mockReturnValue(1);`, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 32, + endLine: 1, + }, + ], + }, + { + code: `(new Array(0).fill(null).foo as jest.MockedFunction).mockReturnValue(1);`, + output: `(jest.mocked(new Array(0).fill(null).foo)).mockReturnValue(1);`, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 52, + endLine: 1, + }, + ], + }, + { + code: `(jest.fn(() => foo) as jest.MockedFunction).mockReturnValue(1);`, + output: `(jest.mocked(jest.fn(() => foo))).mockReturnValue(1);`, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 43, + endLine: 1, + }, + ], + }, + { + code: `const mockedUseFocused = useFocused as jest.MockedFunction;`, + output: `const mockedUseFocused = jest.mocked(useFocused);`, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 26, + line: 1, + endColumn: 78, + endLine: 1, + }, + ], + }, + { + code: `const filter = (MessageService.getMessage as jest.Mock).mock.calls[0][0];`, + output: `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: `(foo as jest.MockedObject<{method: () => void}>)`, + output: `(jest.mocked(foo))`, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 48, + endLine: 1, + }, + ], + }, + { + code: `(Obj['foo'] as jest.MockedFunction).mockReturnValue(1);`, + output: `(jest.mocked(Obj['foo'])).mockReturnValue(1);`, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 2, + line: 1, + endColumn: 35, + endLine: 1, + }, + ], + }, + { + code: dedent` + ( + new Array(100) + .fill(undefined) + .map(x => x.value) + .filter(v => !!v).myProperty as jest.MockedFunction<{ + method: () => void; + }> + ).mockReturnValue(1); + `, + output: dedent` + ( + jest.mocked(new Array(100) + .fill(undefined) + .map(x => x.value) + .filter(v => !!v).myProperty) + ).mockReturnValue(1); + `, + options: [], + errors: [ + { + messageId: 'useJestMocked', + column: 3, + line: 2, + endColumn: 5, + endLine: 7, + }, + ], + }, + ], +}); diff --git a/src/rules/prefer-jest-mocked.ts b/src/rules/prefer-jest-mocked.ts new file mode 100644 index 000000000..7ba20ebcd --- /dev/null +++ b/src/rules/prefer-jest-mocked.ts @@ -0,0 +1,71 @@ +import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils'; +import { createRule, followTypeAssertionChain, getSourceCode } 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()`', + }, + 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 = getSourceCode(context).text.slice( + ...followTypeAssertionChain(node.expression).range, + ); + + context.report({ + node: node, + messageId: 'useJestMocked', + 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); + }, + }; + }, +});