From cb45ae7f74a64a1baaafa20d1988de68c704df2d Mon Sep 17 00:00:00 2001 From: Phillip Le <53925279+phillip-le@users.noreply.github.com> Date: Sat, 5 Oct 2024 02:49:37 +1000 Subject: [PATCH] feat(prefer-vi-mocked): Add new prefer-vi-mocked rule (#547) --- README.md | 1 + docs/rules/prefer-vi-mocked.md | 39 +++++++++ src/index.ts | 3 + src/rules/prefer-vi-mocked.ts | 64 +++++++++++++++ src/utils/ast-utils.ts | 16 ++++ src/utils/types.ts | 20 +++++ tests/prefer-vi-mocked.test.ts | 143 +++++++++++++++++++++++++++++++++ 7 files changed, 286 insertions(+) create mode 100644 docs/rules/prefer-vi-mocked.md create mode 100644 src/rules/prefer-vi-mocked.ts create mode 100644 tests/prefer-vi-mocked.test.ts diff --git a/README.md b/README.md index 2f1c861b..2a4ffe20 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,7 @@ export default [ | [prefer-to-contain](docs/rules/prefer-to-contain.md) | enforce using toContain() | | 🌐 | 🔧 | | | | [prefer-to-have-length](docs/rules/prefer-to-have-length.md) | enforce using toHaveLength() | | 🌐 | 🔧 | | | | [prefer-todo](docs/rules/prefer-todo.md) | enforce using `test.todo` | | 🌐 | 🔧 | | | +| [prefer-vi-mocked](docs/rules/prefer-vi-mocked.md) | Prefer `vi.mocked()` over `fn as Mock` | | 🌐 | 🔧 | | | | [require-hook](docs/rules/require-hook.md) | require setup and teardown to be within a hook | | 🌐 | | | | | [require-local-test-context-for-concurrent-snapshots](docs/rules/require-local-test-context-for-concurrent-snapshots.md) | require local Test Context for concurrent snapshot tests | ✅ | | | | | | [require-to-throw-message](docs/rules/require-to-throw-message.md) | require toThrow() to be called with an error message | | 🌐 | | | | diff --git a/docs/rules/prefer-vi-mocked.md b/docs/rules/prefer-vi-mocked.md new file mode 100644 index 00000000..c9f5d5bd --- /dev/null +++ b/docs/rules/prefer-vi-mocked.md @@ -0,0 +1,39 @@ +# Prefer `vi.mocked()` over `fn as Mock` (`vitest/prefer-vi-mocked`) + +⚠️ This rule _warns_ in the 🌐 `all` config. + +🔧 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 Vitest, it's recommended to use the +[vi.mocked()](https://vitest.dev/api/vi.html#vi-mocked) helper function to properly type the mocked functions. +This rule enforces the use of `vi.mocked()` for better type safety and readability. + +Restricted types: + +- `Mock` +- `MockedFunction` +- `MockedClass` +- `MockedObject` + +## Rule details + +The following patterns are warnings: + +```typescript +(foo as Mock).mockReturnValue(1); +const mock = (foo as Mock).mockReturnValue(1); +(foo as unknown as Mock).mockReturnValue(1); +(Obj.foo as Mock).mockReturnValue(1); +([].foo as Mock).mockReturnValue(1); +``` + +The following patterns are not warnings: + +```js +vi.mocked(foo).mockReturnValue(1); +const mock = vi.mocked(foo).mockReturnValue(1); +vi.mocked(Obj.foo).mockReturnValue(1); +vi.mocked([].foo).mockReturnValue(1); +``` \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index ee691ed8..6ea4124e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,7 @@ import preferEach, { RULE_NAME as preferEachName } from './rules/prefer-each' import preferHooksOnTop, { RULE_NAME as preferHooksOnTopName } from './rules/prefer-hooks-on-top' import preferHooksInOrder, { RULE_NAME as preferHooksInOrderName } from './rules/prefer-hooks-in-order' import preferMockPromiseShorthand, { RULE_NAME as preferMockPromiseShortHandName } from './rules/prefer-mock-promise-shorthand' +import preferViMocked, { RULE_NAME as preferViMockedName } from "./rules/prefer-vi-mocked"; import preferSnapshotHint, { RULE_NAME as preferSnapshotHintName } from './rules/prefer-snapshot-hint' import validDescribeCallback, { RULE_NAME as validDescribeCallbackName } from './rules/valid-describe-callback' import requireTopLevelDescribe, { RULE_NAME as requireTopLevelDescribeName } from './rules/require-top-level-describe' @@ -116,6 +117,7 @@ const allRules = { [preferHooksOnTopName]: 'warn', [preferHooksInOrderName]: 'warn', [preferMockPromiseShortHandName]: 'warn', + [preferViMockedName]: 'warn', [preferSnapshotHintName]: 'warn', [requireTopLevelDescribeName]: 'warn', [requireToThrowMessageName]: 'warn', @@ -195,6 +197,7 @@ const plugin = { [preferHooksInOrderName]: preferHooksInOrder, [requireLocalTestContextForConcurrentSnapshotsName]: requireLocalTestContextForConcurrentSnapshots, [preferMockPromiseShortHandName]: preferMockPromiseShorthand, + [preferViMockedName]: preferViMocked, [preferSnapshotHintName]: preferSnapshotHint, [validDescribeCallbackName]: validDescribeCallback, [requireTopLevelDescribeName]: requireTopLevelDescribe, diff --git a/src/rules/prefer-vi-mocked.ts b/src/rules/prefer-vi-mocked.ts new file mode 100644 index 00000000..506a4761 --- /dev/null +++ b/src/rules/prefer-vi-mocked.ts @@ -0,0 +1,64 @@ +import { AST_NODE_TYPES, TSESTree } from "@typescript-eslint/utils"; +import { createEslintRule } from "../utils"; +import { followTypeAssertionChain } from "../utils/ast-utils"; + +export const RULE_NAME = "prefer-vi-mocked"; +type MESSAGE_IDS = "useViMocked"; + +const mockTypes = ["Mock", "MockedFunction", "MockedClass", "MockedObject"]; + +type Options = []; + +export default createEslintRule({ + name: RULE_NAME, + meta: { + type: "suggestion", + docs: { + description: "Prefer `vi.mocked()` over `fn as Mock`", + requiresTypeChecking: true, + recommended: false, + }, + fixable: "code", + messages: { + useViMocked: "Prefer `vi.mocked()`", + }, + schema: [], + }, + 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.Identifier) return; + + if (!mockTypes.includes(typeName.name)) return; + + const fnName = context.sourceCode.text.slice( + ...followTypeAssertionChain(node.expression).range + ); + + context.report({ + node, + messageId: "useViMocked", + fix(fixer) { + return fixer.replaceText(node, `vi.mocked(${fnName})`); + }, + }); + } + + return { + TSAsExpression(node) { + if (node.parent.type === AST_NODE_TYPES.TSAsExpression) return; + + check(node); + }, + TSTypeAssertion(node) { + check(node); + }, + }; + }, +}); diff --git a/src/utils/ast-utils.ts b/src/utils/ast-utils.ts index f21e0ef5..8a3f1444 100644 --- a/src/utils/ast-utils.ts +++ b/src/utils/ast-utils.ts @@ -1,5 +1,6 @@ import { AST_NODE_TYPES, AST_TOKEN_TYPES, TSESLint, TSESTree } from "@typescript-eslint/utils"; import { createRequire } from "node:module" +import { MaybeTypeCast, TSTypeCastExpression } from './types' const require = createRequire(import.meta.url) const eslintRequire = createRequire(require.resolve("eslint")) @@ -77,3 +78,18 @@ export const areTokensOnSameLine = ( left: TSESTree.Node | TSESTree.Token, right: TSESTree.Node | TSESTree.Token, ): boolean => left.loc.end.line === right.loc.start.line; + +const isTypeCastExpression = ( + node: MaybeTypeCast +): node is TSTypeCastExpression => + node.type === AST_NODE_TYPES.TSAsExpression || + node.type === AST_NODE_TYPES.TSTypeAssertion; + +export const followTypeAssertionChain = < + Expression extends TSESTree.Expression +>( + expression: MaybeTypeCast +): Expression => + isTypeCastExpression(expression) + ? followTypeAssertionChain(expression.expression) + : expression; diff --git a/src/utils/types.ts b/src/utils/types.ts index 3386bfea..e1dd24c9 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -42,3 +42,23 @@ export enum EqualityMatcher { toEqual = 'toEqual', toStrictEqual = 'toStrictEqual' } + +export type MaybeTypeCast = + | TSTypeCastExpression + | Expression; + +export type TSTypeCastExpression< + Expression extends TSESTree.Expression = TSESTree.Expression +> = AsExpressionChain | TypeAssertionChain; + +interface AsExpressionChain< + Expression extends TSESTree.Expression = TSESTree.Expression +> extends TSESTree.TSAsExpression { + expression: AsExpressionChain | Expression; +} + +interface TypeAssertionChain< + Expression extends TSESTree.Expression = TSESTree.Expression +> extends TSESTree.TSTypeAssertion { + expression: TypeAssertionChain | Expression; +} diff --git a/tests/prefer-vi-mocked.test.ts b/tests/prefer-vi-mocked.test.ts new file mode 100644 index 00000000..99efec95 --- /dev/null +++ b/tests/prefer-vi-mocked.test.ts @@ -0,0 +1,143 @@ +import rule, { RULE_NAME } from "../src/rules/prefer-vi-mocked"; +import { ruleTester } from "./ruleTester"; + +ruleTester.run(RULE_NAME, rule, { + valid: [ + "foo();", + "vi.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);", + 'vi.spyOn(obj, "foo").mockReturnValue(1);', + "(foo as Mock.vi).mockReturnValue(1);", + `type MockType = Mock; +const mockFn = vi.fn(); +(mockFn as MockType).mockReturnValue(1);`, + ], + invalid: [ + { + code: "(foo as Mock).mockReturnValue(1);", + output: "(vi.mocked(foo)).mockReturnValue(1);", + errors: [{ messageId: "useViMocked" }], + }, + { + code: "(foo as unknown as string as unknown as Mock).mockReturnValue(1);", + output: "(vi.mocked(foo)).mockReturnValue(1);", + errors: [{ messageId: "useViMocked" }], + }, + { + code: "(foo as unknown as Mock as unknown as Mock).mockReturnValue(1);", + output: "(vi.mocked(foo)).mockReturnValue(1);", + errors: [{ messageId: "useViMocked" }], + }, + { + code: "(foo).mockReturnValue(1);", + output: "(vi.mocked(foo)).mockReturnValue(1);", + errors: [{ messageId: "useViMocked" }], + }, + { + code: "(foo as Mock).mockImplementation(1);", + output: "(vi.mocked(foo)).mockImplementation(1);", + errors: [{ messageId: "useViMocked" }], + }, + { + code: "(foo as unknown as Mock).mockReturnValue(1);", + output: "(vi.mocked(foo)).mockReturnValue(1);", + errors: [{ messageId: "useViMocked" }], + }, + { + code: "(foo as unknown).mockReturnValue(1);", + output: "(vi.mocked(foo) as unknown).mockReturnValue(1);", + errors: [{ messageId: "useViMocked" }], + }, + { + code: "(Obj.foo as Mock).mockReturnValue(1);", + output: "(vi.mocked(Obj.foo)).mockReturnValue(1);", + errors: [{ messageId: "useViMocked" }], + }, + { + code: "([].foo as Mock).mockReturnValue(1);", + output: "(vi.mocked([].foo)).mockReturnValue(1);", + errors: [{ messageId: "useViMocked" }], + }, + { + code: "(foo as MockedFunction).mockReturnValue(1);", + output: "(vi.mocked(foo)).mockReturnValue(1);", + errors: [{ messageId: "useViMocked" }], + }, + { + code: "(foo as MockedFunction).mockImplementation(1);", + output: "(vi.mocked(foo)).mockImplementation(1);", + errors: [{ messageId: "useViMocked" }], + }, + { + code: "(foo as unknown as MockedFunction).mockReturnValue(1);", + output: "(vi.mocked(foo)).mockReturnValue(1);", + errors: [{ messageId: "useViMocked" }], + }, + { + code: "(Obj.foo as MockedFunction).mockReturnValue(1);", + output: "(vi.mocked(Obj.foo)).mockReturnValue(1);", + errors: [{ messageId: "useViMocked" }], + }, + { + code: "(new Array(0).fill(null).foo as MockedFunction).mockReturnValue(1);", + output: "(vi.mocked(new Array(0).fill(null).foo)).mockReturnValue(1);", + errors: [{ messageId: "useViMocked" }], + }, + { + code: "(vi.fn(() => foo) as MockedFunction).mockReturnValue(1);", + output: "(vi.mocked(vi.fn(() => foo))).mockReturnValue(1);", + errors: [{ messageId: "useViMocked" }], + }, + { + code: "const mockedUseFocused = useFocused as MockedFunction;", + output: "const mockedUseFocused = vi.mocked(useFocused);", + errors: [{ messageId: "useViMocked" }], + }, + { + code: "const filter = (MessageService.getMessage as Mock).mock.calls[0][0];", + output: + "const filter = (vi.mocked(MessageService.getMessage)).mock.calls[0][0];", + errors: [{ messageId: "useViMocked" }], + }, + { + code: `class A {} +(foo as MockedClass)`, + output: `class A {} +(vi.mocked(foo))`, + errors: [{ messageId: "useViMocked" }], + }, + { + code: "(foo as MockedObject<{method: () => void}>)", + output: "(vi.mocked(foo))", + errors: [{ messageId: "useViMocked" }], + }, + { + code: '(Obj["foo"] as MockedFunction).mockReturnValue(1);', + output: '(vi.mocked(Obj["foo"])).mockReturnValue(1);', + errors: [{ messageId: "useViMocked" }], + }, + { + code: `( +new Array(100) + .fill(undefined) + .map(x => x.value) + .filter(v => !!v).myProperty as MockedFunction<{ + method: () => void; +}> +).mockReturnValue(1);`, + output: `( +vi.mocked(new Array(100) + .fill(undefined) + .map(x => x.value) + .filter(v => !!v).myProperty) +).mockReturnValue(1);`, + errors: [{ messageId: "useViMocked" }], + }, + ], +});