Skip to content

Commit

Permalink
feat(prefer-vi-mocked): Add new prefer-vi-mocked rule (#547)
Browse files Browse the repository at this point in the history
  • Loading branch information
phillip-le authored Oct 4, 2024
1 parent 3784d1c commit cb45ae7
Show file tree
Hide file tree
Showing 7 changed files with 286 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | | 🌐 | | | |
Expand Down
39 changes: 39 additions & 0 deletions docs/rules/prefer-vi-mocked.md
Original file line number Diff line number Diff line change
@@ -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).

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

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);
```
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -116,6 +117,7 @@ const allRules = {
[preferHooksOnTopName]: 'warn',
[preferHooksInOrderName]: 'warn',
[preferMockPromiseShortHandName]: 'warn',
[preferViMockedName]: 'warn',
[preferSnapshotHintName]: 'warn',
[requireTopLevelDescribeName]: 'warn',
[requireToThrowMessageName]: 'warn',
Expand Down Expand Up @@ -195,6 +197,7 @@ const plugin = {
[preferHooksInOrderName]: preferHooksInOrder,
[requireLocalTestContextForConcurrentSnapshotsName]: requireLocalTestContextForConcurrentSnapshots,
[preferMockPromiseShortHandName]: preferMockPromiseShorthand,
[preferViMockedName]: preferViMocked,
[preferSnapshotHintName]: preferSnapshotHint,
[validDescribeCallbackName]: validDescribeCallback,
[requireTopLevelDescribeName]: requireTopLevelDescribe,
Expand Down
64 changes: 64 additions & 0 deletions src/rules/prefer-vi-mocked.ts
Original file line number Diff line number Diff line change
@@ -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<Options, MESSAGE_IDS>({
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);
},
};
},
});
16 changes: 16 additions & 0 deletions src/utils/ast-utils.ts
Original file line number Diff line number Diff line change
@@ -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"))
Expand Down Expand Up @@ -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 = <Expression extends TSESTree.Expression>(
node: MaybeTypeCast<Expression>
): node is TSTypeCastExpression<Expression> =>
node.type === AST_NODE_TYPES.TSAsExpression ||
node.type === AST_NODE_TYPES.TSTypeAssertion;

export const followTypeAssertionChain = <
Expression extends TSESTree.Expression
>(
expression: MaybeTypeCast<Expression>
): Expression =>
isTypeCastExpression(expression)
? followTypeAssertionChain(expression.expression)
: expression;
20 changes: 20 additions & 0 deletions src/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,23 @@ export enum EqualityMatcher {
toEqual = 'toEqual',
toStrictEqual = 'toStrictEqual'
}

export type MaybeTypeCast<Expression extends TSESTree.Expression> =
| TSTypeCastExpression<Expression>
| Expression;

export type TSTypeCastExpression<
Expression extends TSESTree.Expression = TSESTree.Expression
> = AsExpressionChain<Expression> | TypeAssertionChain<Expression>;

interface AsExpressionChain<
Expression extends TSESTree.Expression = TSESTree.Expression
> extends TSESTree.TSAsExpression {
expression: AsExpressionChain<Expression> | Expression;
}

interface TypeAssertionChain<
Expression extends TSESTree.Expression = TSESTree.Expression
> extends TSESTree.TSTypeAssertion {
expression: TypeAssertionChain<Expression> | Expression;
}
143 changes: 143 additions & 0 deletions tests/prefer-vi-mocked.test.ts
Original file line number Diff line number Diff line change
@@ -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: "(<Mock>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: "(<Mock>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<typeof useFocused>;",
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<A>)`,
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" }],
},
],
});

0 comments on commit cb45ae7

Please sign in to comment.