-
Notifications
You must be signed in to change notification settings - Fork 57
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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); | ||
``` |
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Almost the same as the jest version but only checking for |
||
|
||
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); | ||
}, | ||
}; | ||
}, | ||
}); |
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")) | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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,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);", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 -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" }], | ||
}, | ||
], | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pretty much the same as https://github.com/jest-community/eslint-plugin-jest/blob/main/docs/rules/prefer-jest-mocked.md