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
179 changes: 151 additions & 28 deletions src/rules/prefer-called-exactly-once-with.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,69 @@
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'
import { createEslintRule, getAccessorValue } from '../utils'
import { parseVitestFnCall } from '../utils/parse-vitest-fn-call'
import {
ParsedExpectVitestFnCall,
parseVitestFnCall,
} from '../utils/parse-vitest-fn-call'
import { SourceCode } from '@typescript-eslint/utils/ts-eslint'

type MESSAGE_IDS = 'preferCalledExactlyOnceWith'
export const RULE_NAME = 'prefer-called-exactly-once-with'
type Options = []

const MATCHERS_TO_COMBINE = [
'toHaveBeenCalledOnce',
'toHaveBeenCalledWith',
] as const

type CombinedMatcher = (typeof MATCHERS_TO_COMBINE)[number]

type MatcherReference = {
matcherName: CombinedMatcher
callExpression: TSESTree.CallExpression
}

const hasMatchersToCombine = (target: string): target is CombinedMatcher =>
MATCHERS_TO_COMBINE.some((matcher) => matcher === target)

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

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

const getArgumentsText = (
callExpression: TSESTree.CallExpression,
source: Readonly<SourceCode>,
) => callExpression.arguments.map((arg) => source.getText(arg)).join(', ')

const getValidExpectCall = (
vitestFnCall: ReturnType<typeof parseVitestFnCall>,
): ParsedExpectVitestFnCall | null => {
if (vitestFnCall?.type !== 'expect') return null
if (
vitestFnCall.modifiers.some(
(modifier) => getAccessorValue(modifier) === 'not',
)
)
return null

return vitestFnCall
}

const getMatcherName = (vitestFnCall: ReturnType<typeof parseVitestFnCall>) => {
const validExpectCall = getValidExpectCall(vitestFnCall)
return validExpectCall ? getAccessorValue(validExpectCall.matcher) : null
}

const getMemberProperty = (expression: TSESTree.CallExpression) =>
expression.callee.type === AST_NODE_TYPES.MemberExpression
? expression.callee.property
: null

export default createEslintRule<Options, MESSAGE_IDS>({
name: RULE_NAME,
meta: {
Expand All @@ -22,36 +81,100 @@ export default createEslintRule<Options, MESSAGE_IDS>({
},
defaultOptions: [],
create(context) {
return {
CallExpression(node) {
const vitestFnCall = parseVitestFnCall(node, context)
const { sourceCode } = context

if (vitestFnCall?.type !== 'expect') return
const getCallExpressions = (
body: TSESTree.Statement[],
): TSESTree.CallExpression[] =>
body
.filter((node) => node.type === AST_NODE_TYPES.ExpressionStatement)
.flatMap((node) =>
node.expression.type === AST_NODE_TYPES.CallExpression
? node.expression
: [],
)

const checkBlockBody = (body: TSESTree.Statement[]) => {
const callExpressions = getCallExpressions(body)
const expectMatcherMap = new Map<string, Readonly<MatcherReference>[]>()

if (
vitestFnCall.modifiers.some(
(node) => getAccessorValue(node) === 'not',
)
for (const callExpression of callExpressions) {
const matcherName = getMatcherName(
parseVitestFnCall(callExpression, context),
)
return

const { matcher } = vitestFnCall
const matcherName = getAccessorValue(matcher)

if (
['toHaveBeenCalledOnce', 'toHaveBeenCalledWith'].includes(matcherName)
) {
context.report({
data: {
matcherName,
},
messageId: 'preferCalledExactlyOnceWith',
node: matcher,
fix: (fixer) => [
fixer.replaceText(matcher, `toHaveBeenCalledExactlyOnceWith`),
],
})
}
const expectedText = getExpectText(callExpression, sourceCode)
if (!matcherName || !hasMatchersToCombine(matcherName) || !expectedText)
continue

const existingNodes = expectMatcherMap.get(expectedText) ?? []
const newTargetNodes = [
...existingNodes,
{ matcherName, callExpression },
] as const satisfies MatcherReference[]
expectMatcherMap.set(expectedText, newTargetNodes)
}

for (const [
expectedText,
matcherReferences,
] of expectMatcherMap.entries()) {
if (matcherReferences.length !== 2) continue

const targetArgNode = matcherReferences.find(
(reference) => reference.matcherName === 'toHaveBeenCalledWith',
)
if (!targetArgNode) continue

const argsText = getArgumentsText(
targetArgNode.callExpression,
sourceCode,
)

const [firstMatcherReference, secondMatcherReference] =
matcherReferences
const targetNode = getMemberProperty(
secondMatcherReference.callExpression,
)
if (!targetNode) continue

const { callExpression: firstCallExpression } = firstMatcherReference
const { callExpression: secondCallExpression, matcherName } =
secondMatcherReference

context.report({
messageId: 'preferCalledExactlyOnceWith',
node: targetNode,
data: { matcherName },
fix(fixer) {
const indentation = sourceCode.text.slice(
firstCallExpression.parent.range[0],
firstCallExpression.range[0],
)
const replacement = `${indentation}${expectedText}.toHaveBeenCalledExactlyOnceWith(${argsText})`

const lineStart = sourceCode.getIndexFromLoc({
line: secondCallExpression.parent.loc.start.line,
column: 0,
})
const lineEnd = sourceCode.getIndexFromLoc({
line: secondCallExpression.parent.loc.end.line + 1,
column: 0,
})
return [
fixer.replaceText(firstCallExpression, replacement),
fixer.removeRange([lineStart, lineEnd]),
]
},
})
}
}

return {
Program(node) {
checkBlockBody(node.body)
},
BlockStatement(node) {
checkBlockBody(node.body)
},
}
},
Expand Down
131 changes: 121 additions & 10 deletions tests/prefer-called-exactly-once-with.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,143 @@ import { ruleTester } from './ruleTester'
ruleTester.run(RULE_NAME, rule, {
valid: [
'expect(fn).toHaveBeenCalledExactlyOnceWith();',
'expect(x). toHaveBeenCalledExactlyOnceWith(args);',
'expect(x).toHaveBeenCalledExactlyOnceWith(args);',
'expect(x).toHaveBeenCalledOnce();',
`expect(x).toHaveBeenCalledWith('hoge');`,
`
expect(x).toHaveBeenCalledOnce();
expect(y).toHaveBeenCalledWith('hoge');
`,
`
expect(x).toHaveBeenCalledOnce();
expect(x).not.toHaveBeenCalledWith('hoge');
`,
`
expect(x).not.toHaveBeenCalledOnce();
expect(x).toHaveBeenCalledWith('hoge');
`,
`
expect(x).not.toHaveBeenCalledOnce();
expect(x).not.toHaveBeenCalledWith('hoge');
`,
],
invalid: [
{
code: 'expect(x).toHaveBeenCalledOnce();',
code: `
expect(x).toHaveBeenCalledOnce();
expect(x).toHaveBeenCalledWith('hoge');
`,
errors: [
{
messageId: 'preferCalledExactlyOnceWith',
data: { matcherName: 'toHaveBeenCalledWith' },
column: 17,
line: 3,
},
],
output: `
expect(x).toHaveBeenCalledExactlyOnceWith('hoge');
`,
},
{
code: `
expect(x).toHaveBeenCalledWith('hoge');
expect(x).toHaveBeenCalledOnce();
`,
errors: [
{
messageId: 'preferCalledExactlyOnceWith',
data: { matcherName: 'toHaveBeenCalledOnce' },
column: 11,
line: 1,
column: 17,
line: 3,
},
],
output: 'expect(x).toHaveBeenCalledExactlyOnceWith();',
output: `
expect(x).toHaveBeenCalledExactlyOnceWith('hoge');
`,
},
{
code: 'expect(x).toHaveBeenCalledWith();',
code: `
expect(x).toHaveBeenCalledWith('hoge', 123);
expect(x).toHaveBeenCalledOnce();
`,
errors: [
{
messageId: 'preferCalledExactlyOnceWith',
data: { matcherName: 'toHaveBeenCalledWith' },
column: 11,
line: 1,
data: { matcherName: 'toHaveBeenCalledOnce' },
column: 17,
line: 3,
},
],
output: `
expect(x).toHaveBeenCalledExactlyOnceWith('hoge', 123);
`,
},
{
code: `
test('example',() => {
expect(x).toHaveBeenCalledWith('hoge', 123);
expect(x).toHaveBeenCalledOnce();
});
`,
errors: [
{
messageId: 'preferCalledExactlyOnceWith',
data: { matcherName: 'toHaveBeenCalledOnce' },
column: 19,
line: 4,
},
],
output: `
test('example',() => {
expect(x).toHaveBeenCalledExactlyOnceWith('hoge', 123);
});
`,
},
{
code: `
expect(x).toHaveBeenCalledWith('hoge', 123);
expect(x).toHaveBeenCalledOnce();
expect(y).toHaveBeenCalledWith('foo', 456);
expect(y).toHaveBeenCalledOnce();
`,
errors: [
{
messageId: 'preferCalledExactlyOnceWith',
data: { matcherName: 'toHaveBeenCalledOnce' },
column: 17,
line: 3,
},
{
messageId: 'preferCalledExactlyOnceWith',
data: { matcherName: 'toHaveBeenCalledOnce' },
column: 17,
line: 5,
},
],
output: `
expect(x).toHaveBeenCalledExactlyOnceWith('hoge', 123);
expect(y).toHaveBeenCalledExactlyOnceWith('foo', 456);
`,
},
{
code: `
expect(x).toHaveBeenCalledWith('hoge', 123);
const hoge = 'foo';
expect(x).toHaveBeenCalledOnce();
`,
errors: [
{
messageId: 'preferCalledExactlyOnceWith',
data: { matcherName: 'toHaveBeenCalledOnce' },
column: 17,
line: 4,
},
],
output: 'expect(x).toHaveBeenCalledExactlyOnceWith();',
output: `
expect(x).toHaveBeenCalledExactlyOnceWith('hoge', 123);
const hoge = 'foo';
`,
},
],
})