diff --git a/src/rules/__tests__/unbound-method.test.ts b/src/rules/__tests__/unbound-method.test.ts index 036ec9234..8d58664a7 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,19 @@ 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];', + + 'jest.mocked(service.method).mock;', + + 'const mockProp = jest.mocked(service.method).mock;', + 'const result = jest.mocked(service.method, true);', + 'jest.mocked(service.method, { shallow: true });', + ].map(code => [ServiceClassAndMethodCode, code].join('\n')), ]; const invalidTestCases: Array> = [ @@ -128,6 +149,52 @@ const invalidTestCases: Array> = [ }, ], })), + // Ensure that accessing .mock on non-jest.mocked() results still reports errors + // Note: These cases might not report errors if the base rule doesn't consider + // property access as unbound method access, so we'll remove them for now + // and focus on cases that should definitely report errors + // Ensure that service.method as non-argument still reports errors + { + code: dedent` + ${ServiceClassAndMethodCode} + + const method = service.method; + jest.mocked(method); + `, + errors: [ + { + line: 7, + messageId: 'unboundWithoutThisAnnotation', + }, + ], + }, + // Ensure that regular unbound method access still reports errors + { + code: dedent` + ${ServiceClassAndMethodCode} + + const method = service.method; + `, + errors: [ + { + line: 7, + messageId: 'unboundWithoutThisAnnotation', + }, + ], + }, + { + code: dedent` + ${ServiceClassAndMethodCode} + + Promise.resolve().then(service.method); + `, + errors: [ + { + line: 7, + messageId: 'unboundWithoutThisAnnotation', + }, + ], + }, // toThrow matchers call the expected value (which is expected to be a function) ...toThrowMatchers.map(matcher => ({ code: dedent` @@ -235,6 +302,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..21dbce9dc 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,58 @@ 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 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; + } + } + + return false; + + // 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') + // ); + }; + return { ...baseSelectors, MemberExpression(node: TSESTree.MemberExpression): void { + // Check if this MemberExpression is an argument to jest.mocked() + if (isArgumentToJestMocked(node)) { + return; + } + if (node.parent?.type === AST_NODE_TYPES.CallExpression) { const jestFnCall = parseJestFnCall( findTopMostCallExpression(node.parent),