Skip to content

Commit 0ccafa4

Browse files
authored
fix(prefer-called-exactly-once-with): detect mock resets between assertions (#780)
* fix: handle cases where mock is reset mid-test * test: add tests * fix: fix format
1 parent 526e378 commit 0ccafa4

File tree

2 files changed

+121
-5
lines changed

2 files changed

+121
-5
lines changed

src/rules/prefer-called-exactly-once-with.ts

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ const MATCHERS_TO_COMBINE = [
1515
'toHaveBeenCalledWith',
1616
] as const
1717

18+
const MOCK_CALL_RESET_METHODS = [
19+
'mockClear',
20+
'mockReset',
21+
'mockRestore',
22+
] as const
23+
1824
type CombinedMatcher = (typeof MATCHERS_TO_COMBINE)[number]
1925

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

2834
const getExpectText = (
29-
expression: TSESTree.CallExpression,
35+
callee: TSESTree.Expression,
3036
source: Readonly<SourceCode>,
3137
) => {
32-
if (expression.callee.type !== AST_NODE_TYPES.MemberExpression) return null
38+
if (callee.type !== AST_NODE_TYPES.MemberExpression) return null
3339

34-
const { range } = expression.callee.object
35-
return source.text.slice(range[0], range[1])
40+
return source.getText(callee.object)
3641
}
3742

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

67+
const getExpectArgText = ({ callee }: TSESTree.CallExpression) => {
68+
if (callee.type !== AST_NODE_TYPES.MemberExpression) return null
69+
const { object } = callee
70+
if (object.type !== AST_NODE_TYPES.CallExpression) return null
71+
72+
const [firstArgument] = object.arguments
73+
if (firstArgument.type !== AST_NODE_TYPES.Identifier) return null
74+
75+
return firstArgument.name
76+
}
77+
78+
const getSharedExpectArgText = (
79+
firstCallExpression: TSESTree.CallExpression,
80+
secondCallExpression: TSESTree.CallExpression,
81+
) => {
82+
const firstArgText = getExpectArgText(firstCallExpression)
83+
if (!firstArgText) return null
84+
const secondArgText = getExpectArgText(secondCallExpression)
85+
if (firstArgText !== secondArgText) return null
86+
87+
return firstArgText
88+
}
89+
90+
const isTargetMockResetCall = (
91+
statement: TSESTree.Statement,
92+
expectArgText: string,
93+
minLine: number,
94+
maxLine: number,
95+
) => {
96+
if (statement.type !== AST_NODE_TYPES.ExpressionStatement) return false
97+
if (statement.expression.type !== AST_NODE_TYPES.CallExpression) return false
98+
99+
const statementLine = statement.loc.start.line
100+
if (statementLine <= minLine || statementLine >= maxLine) return false
101+
102+
const { callee } = statement.expression
103+
if (callee.type !== AST_NODE_TYPES.MemberExpression) return false
104+
105+
const { object, property } = callee
106+
if (object.type !== AST_NODE_TYPES.Identifier) return false
107+
if (object.name !== expectArgText) return false
108+
if (property.type !== AST_NODE_TYPES.Identifier) return false
109+
110+
return MOCK_CALL_RESET_METHODS.some((method) => method === property.name)
111+
}
112+
113+
const hasMockResetBetween = (
114+
body: TSESTree.Statement[],
115+
firstCallExpression: TSESTree.CallExpression,
116+
secondCallExpression: TSESTree.CallExpression,
117+
): boolean => {
118+
const firstLine = firstCallExpression.loc.start.line
119+
const secondLine = secondCallExpression.loc.start.line
120+
const [minLine, maxLine] =
121+
firstLine < secondLine ? [firstLine, secondLine] : [secondLine, firstLine]
122+
123+
const expectArgText = getSharedExpectArgText(
124+
firstCallExpression,
125+
secondCallExpression,
126+
)
127+
if (!expectArgText) return false
128+
129+
return body.some((statement) =>
130+
isTargetMockResetCall(statement, expectArgText, minLine, maxLine),
131+
)
132+
}
133+
62134
const getMemberProperty = (expression: TSESTree.CallExpression) =>
63135
expression.callee.type === AST_NODE_TYPES.MemberExpression
64136
? expression.callee.property
@@ -102,7 +174,7 @@ export default createEslintRule<Options, MESSAGE_IDS>({
102174
const matcherName = getMatcherName(
103175
parseVitestFnCall(callExpression, context),
104176
)
105-
const expectedText = getExpectText(callExpression, sourceCode)
177+
const expectedText = getExpectText(callExpression.callee, sourceCode)
106178
if (!matcherName || !hasMatchersToCombine(matcherName) || !expectedText)
107179
continue
108180

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

216+
if (
217+
hasMockResetBetween(body, firstCallExpression, secondCallExpression)
218+
)
219+
continue
220+
144221
context.report({
145222
messageId: 'preferCalledExactlyOnceWith',
146223
node: targetNode,

tests/prefer-called-exactly-once-with.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,26 @@ ruleTester.run(RULE_NAME, rule, {
2323
expect(x).not.toHaveBeenCalledOnce();
2424
expect(x).not.toHaveBeenCalledWith('hoge');
2525
`,
26+
`
27+
expect(x).toHaveBeenCalledOnce();
28+
x.mockRestore();
29+
expect(x).toHaveBeenCalledWith('hoge');
30+
`,
31+
`
32+
expect(x).toHaveBeenCalledOnce();
33+
x.mockReset();
34+
expect(x).toHaveBeenCalledWith('hoge');
35+
`,
36+
`
37+
expect(x).toHaveBeenCalledOnce();
38+
x.mockClear();
39+
expect(x).toHaveBeenCalledWith('hoge');
40+
`,
41+
`
42+
expect(x).toHaveBeenCalledOnce();
43+
y.mockClear();
44+
expect(y).toHaveBeenCalledWith('hoge');
45+
`,
2646
],
2747
invalid: [
2848
{
@@ -142,5 +162,24 @@ ruleTester.run(RULE_NAME, rule, {
142162
const hoge = 'foo';
143163
`,
144164
},
165+
{
166+
code: `
167+
expect(x).toHaveBeenCalledOnce();
168+
y.mockClear();
169+
expect(x).toHaveBeenCalledWith('hoge');
170+
`,
171+
errors: [
172+
{
173+
messageId: 'preferCalledExactlyOnceWith',
174+
data: { matcherName: 'toHaveBeenCalledWith' },
175+
column: 17,
176+
line: 4,
177+
},
178+
],
179+
output: `
180+
expect(x).toHaveBeenCalledExactlyOnceWith('hoge');
181+
y.mockClear();
182+
`,
183+
},
145184
],
146185
})

0 commit comments

Comments
 (0)