diff --git a/src/rules/__tests__/unbound-method.test.ts b/src/rules/__tests__/unbound-method.test.ts index 036ec9234..47593bca2 100644 --- a/src/rules/__tests__/unbound-method.test.ts +++ b/src/rules/__tests__/unbound-method.test.ts @@ -49,6 +49,14 @@ const ConsoleClassAndVariableCode = dedent` const console = new Console(); `; +const ServiceClassAndMethodCode = dedent` + class Service { + method() {} + } + + const service = new Service(); +`; + const toThrowMatchers = [ 'toThrow', 'toThrowError', @@ -73,6 +81,13 @@ const validTestCases: string[] = [ 'expect(() => Promise.resolve().then(console.log)).not.toThrow();', ...toThrowMatchers.map(matcher => `expect(console.log).not.${matcher}();`), ...toThrowMatchers.map(matcher => `expect(console.log).${matcher}();`), + // https://github.com/jest-community/eslint-plugin-jest/issues/1800 + ...[ + 'const parameter = jest.mocked(service.method).mock.calls[0][0];', + 'const calls = jest.mocked(service.method).mock.calls;', + 'const lastCall = jest.mocked(service.method).mock.calls[0];', + 'const mockedMethod = jest.mocked(service.method); const parameter = mockedMethod.mock.calls[0][0];', + ].map(code => [ServiceClassAndMethodCode, code].join('\n')), ]; const invalidTestCases: Array> = [ @@ -235,6 +250,7 @@ const arith = { ${code} `; } + function addContainsMethodsClassInvalid( code: string[], ): Array> { diff --git a/src/rules/unbound-method.ts b/src/rules/unbound-method.ts index ddb7587e4..6e5736c8b 100644 --- a/src/rules/unbound-method.ts +++ b/src/rules/unbound-method.ts @@ -8,6 +8,7 @@ import { findTopMostCallExpression, getAccessorValue, isIdentifier, + isSupportedAccessor, parseJestFnCall, } from './utils'; @@ -73,9 +74,116 @@ export default createRule({ return {}; } + /** + * Checks if a MemberExpression is an argument to a `jest.mocked()` call. + * This handles cases like `jest.mocked(service.method)` where `service.method` + * should not be flagged as an unbound method. + */ + const isArgumentToJestMocked = ( + node: TSESTree.MemberExpression, + ): boolean => { + // Check if the immediate parent is a CallExpression + if (node.parent?.type !== AST_NODE_TYPES.CallExpression) { + return false; + } + + const parentCall = node.parent; + + // Check if this node is an argument to the call + if (!parentCall.arguments.some(arg => arg === node)) { + return false; + } + + // Check if the call is jest.mocked() by examining the callee + if ( + parentCall.callee.type === AST_NODE_TYPES.MemberExpression && + isSupportedAccessor(parentCall.callee.object) && + isSupportedAccessor(parentCall.callee.property) + ) { + const objectName = getAccessorValue(parentCall.callee.object); + const propertyName = getAccessorValue(parentCall.callee.property); + + if (objectName === 'jest' && propertyName === 'mocked') { + return true; + } + } + + // Also try using parseJestFnCall as a fallback + const jestFnCall = parseJestFnCall( + findTopMostCallExpression(parentCall), + context, + ); + + return ( + jestFnCall?.type === 'jest' && + jestFnCall.members.length >= 1 && + isIdentifier(jestFnCall.members[0], 'mocked') + ); + }; + + /** + * Checks if a MemberExpression is accessing `.mock` property on a + * `jest.mocked()` result. This handles cases like: + * - `jest.mocked(service.method).mock.calls[0][0]` + * - `jest.mocked(service.method).mock.calls` + */ + const isAccessingMockProperty = ( + node: TSESTree.MemberExpression, + ): boolean => { + // Check if we're accessing `.mock` property + if (!isSupportedAccessor(node.property)) { + return false; + } + + const propertyName = getAccessorValue(node.property); + + if (propertyName !== 'mock') { + return false; + } + + // Traverse up the chain to find if the object is a jest.mocked() call + let current: TSESTree.Node = node.object; + + while (current) { + if (current.type === AST_NODE_TYPES.CallExpression) { + const jestFnCall = parseJestFnCall( + findTopMostCallExpression(current), + context, + ); + + if ( + jestFnCall?.type === 'jest' && + jestFnCall.members.length >= 1 && + isIdentifier(jestFnCall.members[0], 'mocked') + ) { + return true; + } + } + + // Continue traversing up if the current node is part of a member chain + if (current.type === AST_NODE_TYPES.MemberExpression) { + current = current.object; + } else { + break; + } + } + + return false; + }; + return { ...baseSelectors, MemberExpression(node: TSESTree.MemberExpression): void { + // Check if this MemberExpression is an argument to jest.mocked() + if (isArgumentToJestMocked(node)) { + return; + } + + // Check if accessing .mock property on jest.mocked() result + if (isAccessingMockProperty(node)) { + return; + } + if (node.parent?.type === AST_NODE_TYPES.CallExpression) { const jestFnCall = parseJestFnCall( findTopMostCallExpression(node.parent),