Skip to content

Commit

Permalink
feat(prefer-jest-mocked): add new rule (#1599)
Browse files Browse the repository at this point in the history
Co-authored-by: s.v.zaytsev <s.v.zaytsev@tinkoff.ru>
Co-authored-by: Gareth Jones <Jones258@Gmail.com>
3 people authored Jun 6, 2024
1 parent 5b9b47e commit 4b6a4f2
Showing 6 changed files with 460 additions and 1 deletion.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -355,6 +355,7 @@ Manually fixable by
| [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 | | | | |
38 changes: 38 additions & 0 deletions docs/rules/prefer-jest-mocked.md
Original file line number Diff line number Diff line change
@@ -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).

<!-- end auto-generated rule header -->

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);
```
2 changes: 2 additions & 0 deletions src/__tests__/__snapshots__/rules.test.ts.snap
Original file line number Diff line number Diff line change
@@ -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",
2 changes: 1 addition & 1 deletion src/__tests__/rules.test.ts
Original file line number Diff line number Diff line change
@@ -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)
347 changes: 347 additions & 0 deletions src/rules/__tests__/prefer-jest-mocked.test.ts
Original file line number Diff line number Diff line change
@@ -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: `(<jest.Mock>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: `(<jest.Mock>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<typeof useFocused>;`,
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<A>)
`,
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,
},
],
},
],
});
71 changes: 71 additions & 0 deletions src/rules/prefer-jest-mocked.ts
Original file line number Diff line number Diff line change
@@ -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);
},
};
},
});

0 comments on commit 4b6a4f2

Please sign in to comment.