Skip to content
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
87 changes: 82 additions & 5 deletions src/rules/prefer-called-exactly-once-with.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ const MATCHERS_TO_COMBINE = [
'toHaveBeenCalledWith',
] as const

const MOCK_CALL_RESET_METHODS = [
'mockClear',
'mockReset',
'mockRestore',
] as const

type CombinedMatcher = (typeof MATCHERS_TO_COMBINE)[number]

type MatcherReference = {
Expand All @@ -26,13 +32,12 @@ const hasMatchersToCombine = (target: string): target is CombinedMatcher =>
MATCHERS_TO_COMBINE.some((matcher) => matcher === target)

const getExpectText = (
expression: TSESTree.CallExpression,
callee: TSESTree.Expression,
source: Readonly<SourceCode>,
) => {
if (expression.callee.type !== AST_NODE_TYPES.MemberExpression) return null
if (callee.type !== AST_NODE_TYPES.MemberExpression) return null

const { range } = expression.callee.object
return source.text.slice(range[0], range[1])
return source.getText(callee.object)
}

const getArgumentsText = (
Expand All @@ -59,6 +64,73 @@ const getMatcherName = (vitestFnCall: ReturnType<typeof parseVitestFnCall>) => {
return validExpectCall ? getAccessorValue(validExpectCall.matcher) : null
}

const getExpectArgText = ({ callee }: TSESTree.CallExpression) => {
if (callee.type !== AST_NODE_TYPES.MemberExpression) return null
const { object } = callee
if (object.type !== AST_NODE_TYPES.CallExpression) return null

const [firstArgument] = object.arguments
if (firstArgument.type !== AST_NODE_TYPES.Identifier) return null

return firstArgument.name
}

const getSharedExpectArgText = (
firstCallExpression: TSESTree.CallExpression,
secondCallExpression: TSESTree.CallExpression,
) => {
const firstArgText = getExpectArgText(firstCallExpression)
if (!firstArgText) return null
const secondArgText = getExpectArgText(secondCallExpression)
if (firstArgText !== secondArgText) return null

return firstArgText
}

const isTargetMockResetCall = (
statement: TSESTree.Statement,
expectArgText: string,
minLine: number,
maxLine: number,
) => {
if (statement.type !== AST_NODE_TYPES.ExpressionStatement) return false
if (statement.expression.type !== AST_NODE_TYPES.CallExpression) return false

const statementLine = statement.loc.start.line
if (statementLine <= minLine || statementLine >= maxLine) return false

const { callee } = statement.expression
if (callee.type !== AST_NODE_TYPES.MemberExpression) return false

const { object, property } = callee
if (object.type !== AST_NODE_TYPES.Identifier) return false
if (object.name !== expectArgText) return false
if (property.type !== AST_NODE_TYPES.Identifier) return false

return MOCK_CALL_RESET_METHODS.some((method) => method === property.name)
}

const hasMockResetBetween = (
body: TSESTree.Statement[],
firstCallExpression: TSESTree.CallExpression,
secondCallExpression: TSESTree.CallExpression,
): boolean => {
const firstLine = firstCallExpression.loc.start.line
const secondLine = secondCallExpression.loc.start.line
const [minLine, maxLine] =
firstLine < secondLine ? [firstLine, secondLine] : [secondLine, firstLine]

const expectArgText = getSharedExpectArgText(
firstCallExpression,
secondCallExpression,
)
if (!expectArgText) return false

return body.some((statement) =>
isTargetMockResetCall(statement, expectArgText, minLine, maxLine),
)
}

const getMemberProperty = (expression: TSESTree.CallExpression) =>
expression.callee.type === AST_NODE_TYPES.MemberExpression
? expression.callee.property
Expand Down Expand Up @@ -102,7 +174,7 @@ export default createEslintRule<Options, MESSAGE_IDS>({
const matcherName = getMatcherName(
parseVitestFnCall(callExpression, context),
)
const expectedText = getExpectText(callExpression, sourceCode)
const expectedText = getExpectText(callExpression.callee, sourceCode)
if (!matcherName || !hasMatchersToCombine(matcherName) || !expectedText)
continue

Expand Down Expand Up @@ -141,6 +213,11 @@ export default createEslintRule<Options, MESSAGE_IDS>({
const { callExpression: secondCallExpression, matcherName } =
secondMatcherReference

if (
hasMockResetBetween(body, firstCallExpression, secondCallExpression)
)
continue

context.report({
messageId: 'preferCalledExactlyOnceWith',
node: targetNode,
Expand Down
39 changes: 39 additions & 0 deletions tests/prefer-called-exactly-once-with.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ ruleTester.run(RULE_NAME, rule, {
expect(x).not.toHaveBeenCalledOnce();
expect(x).not.toHaveBeenCalledWith('hoge');
`,
`
expect(x).toHaveBeenCalledOnce();
x.mockRestore();
expect(x).toHaveBeenCalledWith('hoge');
`,
`
expect(x).toHaveBeenCalledOnce();
x.mockReset();
expect(x).toHaveBeenCalledWith('hoge');
`,
`
expect(x).toHaveBeenCalledOnce();
x.mockClear();
expect(x).toHaveBeenCalledWith('hoge');
`,
`
expect(x).toHaveBeenCalledOnce();
y.mockClear();
expect(y).toHaveBeenCalledWith('hoge');
`,
],
invalid: [
{
Expand Down Expand Up @@ -142,5 +162,24 @@ ruleTester.run(RULE_NAME, rule, {
const hoge = 'foo';
`,
},
{
code: `
expect(x).toHaveBeenCalledOnce();
y.mockClear();
expect(x).toHaveBeenCalledWith('hoge');
`,
errors: [
{
messageId: 'preferCalledExactlyOnceWith',
data: { matcherName: 'toHaveBeenCalledWith' },
column: 17,
line: 4,
},
],
output: `
expect(x).toHaveBeenCalledExactlyOnceWith('hoge');
y.mockClear();
`,
},
],
})