Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(prefer-vi-mocked): Add new prefer-vi-mocked rule #547

Merged
merged 1 commit into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Almost the same as the jest version but only checking for Mock instead of jest.Mock


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;
Comment on lines +82 to +95
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
}
Comment on lines +46 to +64
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);",
Copy link
Contributor Author

@phillip-le phillip-le Oct 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are almost exactly the same as the test cases as in https://github.com/jest-community/eslint-plugin-jest/blob/main/src/rules/__tests__/prefer-jest-mocked.test.ts
But without the jest. prefix for the types

-code: "(foo as jest.Mock).mockReturnValue(1);",
+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" }],
},
],
});