diff --git a/packages/jest-mock/src/__tests__/class-mocks-dual-import.test.ts b/packages/jest-mock/src/__tests__/class-mocks-dual-import.test.ts index 3bcc759e6d68..610469e9deba 100644 --- a/packages/jest-mock/src/__tests__/class-mocks-dual-import.test.ts +++ b/packages/jest-mock/src/__tests__/class-mocks-dual-import.test.ts @@ -35,4 +35,96 @@ describe('Testing the mocking of a class hierarchy defined in multiple imports', expect(testClassInstance.testMethod()).toBe('mockTestMethod'); expect(mockTestMethod).toHaveBeenCalledTimes(1); }); + + it('can read a value from an instance getter - Auto-mocked class', () => { + const mockTestMethod = jest + .spyOn(SuperTestClass.prototype, 'testAccessor', 'get') + .mockImplementation(() => { + return 'mockTestAccessor'; + }); + const testClassInstance = new SuperTestClass(); + expect(testClassInstance.testAccessor).toBe('mockTestAccessor'); + expect(mockTestMethod).toHaveBeenCalledTimes(1); + + mockTestMethod.mockClear(); + }); + + it('can read a value from a superclass instance getter - Auto-mocked class', () => { + const mockTestMethod = jest + .spyOn(TestClass.prototype, 'testAccessor', 'get') + .mockImplementation(() => { + return 'mockTestAccessor'; + }); + const testClassInstance = new TestClass(); + expect(testClassInstance.testAccessor).toBe('mockTestAccessor'); + expect(mockTestMethod).toHaveBeenCalledTimes(1); + }); + + it('can write a value to an instance setter - Auto-mocked class', () => { + const mockTestMethod = jest + .spyOn(SuperTestClass.prototype, 'testAccessor', 'set') + .mockImplementation((_x: string) => { + return () => {}; + }); + const testClassInstance = new SuperTestClass(); + testClassInstance.testAccessor = ''; + expect(mockTestMethod).toHaveBeenCalledTimes(1); + + mockTestMethod.mockClear(); + }); + + it('can write a value to a superclass instance setter - Auto-mocked class', () => { + const mockTestMethod = jest + .spyOn(TestClass.prototype, 'testAccessor', 'set') + .mockImplementation((_x: string) => { + return () => {}; + }); + const testClassInstance = new TestClass(); + testClassInstance.testAccessor = ''; + expect(mockTestMethod).toHaveBeenCalledTimes(1); + }); + + it('can read a value from a static getter - Auto-mocked class', () => { + const mockTestMethod = jest + .spyOn(SuperTestClass, 'staticTestAccessor', 'get') + .mockImplementation(() => { + return 'mockStaticTestAccessor'; + }); + expect(SuperTestClass.staticTestAccessor).toBe('mockStaticTestAccessor'); + expect(mockTestMethod).toHaveBeenCalledTimes(1); + + mockTestMethod.mockClear(); + }); + + it('can read a value from a superclass static getter - Auto-mocked class', () => { + const mockTestMethod = jest + .spyOn(TestClass, 'staticTestAccessor', 'get') + .mockImplementation(() => { + return 'mockStaticTestAccessor'; + }); + expect(TestClass.staticTestAccessor).toBe('mockStaticTestAccessor'); + expect(mockTestMethod).toHaveBeenCalledTimes(1); + }); + + it('can write a value to a static setter - Auto-mocked class', () => { + const mockTestMethod = jest + .spyOn(SuperTestClass, 'staticTestAccessor', 'set') + .mockImplementation((_x: string) => { + return () => {}; + }); + SuperTestClass.staticTestAccessor = ''; + expect(mockTestMethod).toHaveBeenCalledTimes(1); + + mockTestMethod.mockClear(); + }); + + it('can write a value to a superclass static setter - Auto-mocked class', () => { + const mockTestMethod = jest + .spyOn(TestClass, 'staticTestAccessor', 'set') + .mockImplementation((_x: string) => { + return () => {}; + }); + TestClass.staticTestAccessor = ''; + expect(mockTestMethod).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/jest-mock/src/__tests__/class-mocks-single-import.test.ts b/packages/jest-mock/src/__tests__/class-mocks-single-import.test.ts index 078c0531ddf2..1d587ef2b156 100644 --- a/packages/jest-mock/src/__tests__/class-mocks-single-import.test.ts +++ b/packages/jest-mock/src/__tests__/class-mocks-single-import.test.ts @@ -109,6 +109,54 @@ describe('Testing the mocking of a class hierarchy defined in a single import', mockTestMethod.mockClear(); }); + it('can read a value from an instance getter - Auto-mocked class', () => { + const mockTestMethod = jest + .spyOn(SuperTestClass.prototype, 'testAccessor', 'get') + .mockImplementation(() => { + return 'mockTestAccessor'; + }); + const testClassInstance = new SuperTestClass(); + expect(testClassInstance.testAccessor).toBe('mockTestAccessor'); + expect(mockTestMethod).toHaveBeenCalledTimes(1); + + mockTestMethod.mockClear(); + }); + + it('can read a value from a superclass instance getter - Auto-mocked class', () => { + const mockTestMethod = jest + .spyOn(testTypes.TestClass.prototype, 'testAccessor', 'get') + .mockImplementation(() => { + return 'mockTestAccessor'; + }); + const testClassInstance = new testTypes.TestClass(); + expect(testClassInstance.testAccessor).toBe('mockTestAccessor'); + expect(mockTestMethod).toHaveBeenCalledTimes(1); + }); + + it('can write a value to an instance setter - Auto-mocked class', () => { + const mockTestMethod = jest + .spyOn(SuperTestClass.prototype, 'testAccessor', 'set') + .mockImplementation((_x: string) => { + return () => {}; + }); + const testClassInstance = new SuperTestClass(); + testClassInstance.testAccessor = ''; + expect(mockTestMethod).toHaveBeenCalledTimes(1); + + mockTestMethod.mockClear(); + }); + + it('can write a value to a superclass instance setter - Auto-mocked class', () => { + const mockTestMethod = jest + .spyOn(testTypes.TestClass.prototype, 'testAccessor', 'set') + .mockImplementation((_x: string) => { + return () => {}; + }); + const testClassInstance = new testTypes.TestClass(); + testClassInstance.testAccessor = ''; + expect(mockTestMethod).toHaveBeenCalledTimes(1); + }); + it('can call a static method - Auto-mocked class', () => { const mockTestMethod = jest .spyOn(SuperTestClass, 'staticTestMethod') @@ -178,4 +226,50 @@ describe('Testing the mocking of a class hierarchy defined in a single import', mockTestMethod.mockClear(); }); + + it('can read a value from a static getter - Auto-mocked class', () => { + const mockTestMethod = jest + .spyOn(SuperTestClass, 'staticTestAccessor', 'get') + .mockImplementation(() => { + return 'mockStaticTestAccessor'; + }); + expect(SuperTestClass.staticTestAccessor).toBe('mockStaticTestAccessor'); + expect(mockTestMethod).toHaveBeenCalledTimes(1); + + mockTestMethod.mockClear(); + }); + + it('can read a value from a superclass static getter - Auto-mocked class', () => { + const mockTestMethod = jest + .spyOn(testTypes.TestClass, 'staticTestAccessor', 'get') + .mockImplementation(() => { + return 'mockStaticTestAccessor'; + }); + expect(testTypes.TestClass.staticTestAccessor).toBe( + 'mockStaticTestAccessor', + ); + expect(mockTestMethod).toHaveBeenCalledTimes(1); + }); + + it('can write a value to a static setter - Auto-mocked class', () => { + const mockTestMethod = jest + .spyOn(SuperTestClass, 'staticTestAccessor', 'set') + .mockImplementation((_x: string) => { + return () => {}; + }); + SuperTestClass.staticTestAccessor = ''; + expect(mockTestMethod).toHaveBeenCalledTimes(1); + + mockTestMethod.mockClear(); + }); + + it('can write a value to a superclass static setter - Auto-mocked class', () => { + const mockTestMethod = jest + .spyOn(testTypes.TestClass, 'staticTestAccessor', 'set') + .mockImplementation((_x: string) => { + return () => {}; + }); + testTypes.TestClass.staticTestAccessor = ''; + expect(mockTestMethod).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/jest-mock/src/__tests__/class-mocks.test.ts b/packages/jest-mock/src/__tests__/class-mocks.test.ts index ea6b92f66465..0909e2040f4f 100644 --- a/packages/jest-mock/src/__tests__/class-mocks.test.ts +++ b/packages/jest-mock/src/__tests__/class-mocks.test.ts @@ -5,7 +5,6 @@ * LICENSE file in the root directory of this source tree. * */ - describe('Testing the mocking of a class', () => { it('can call an instance method', () => { class TestClass { @@ -169,6 +168,8 @@ describe('Testing the mocking of a class', () => { mockFn.mockRestore(); expect(testClassInstance.testMethod).toBe('testMethod'); + // eslint-disable-next-line no-prototype-builtins + expect(TestClass.prototype.hasOwnProperty('testMethod')).toBe(false); }); it('can write a value to an instance setter', () => { @@ -215,6 +216,8 @@ describe('Testing the mocking of a class', () => { mocktestMethod.mockRestore(); testClassInstance.testMethod = ''; expect(mocktestMethod).toHaveBeenCalledTimes(0); + // eslint-disable-next-line no-prototype-builtins + expect(TestClass.prototype.hasOwnProperty('testMethod')).toBe(false); }); it('can call a static method', () => { @@ -253,6 +256,8 @@ describe('Testing the mocking of a class', () => { mockFn.mockRestore(); expect(TestClass.testMethod()).toBe('testMethod'); + // eslint-disable-next-line no-prototype-builtins + expect(TestClass.hasOwnProperty('testMethod')).toBe(false); }); it('can call a static method named "get"', () => { @@ -363,6 +368,8 @@ describe('Testing the mocking of a class', () => { mockFn.mockRestore(); expect(TestClass.testMethod).toBe('testMethod'); + // eslint-disable-next-line no-prototype-builtins + expect(TestClass.hasOwnProperty('testMethod')).toBe(false); }); it('can write a value to a static setter', () => { @@ -402,5 +409,10 @@ describe('Testing the mocking of a class', () => { }); TestClass.testMethod = ''; expect(mocktestMethod).toHaveBeenCalledTimes(1); + + mocktestMethod.mockRestore(); + expect(mocktestMethod).toHaveBeenCalledTimes(0); + // eslint-disable-next-line no-prototype-builtins + expect(TestClass.hasOwnProperty('testMethod')).toBe(false); }); }); diff --git a/packages/jest-mock/src/index.ts b/packages/jest-mock/src/index.ts index 3c0b6f108bd5..ec0eebef3a42 100644 --- a/packages/jest-mock/src/index.ts +++ b/packages/jest-mock/src/index.ts @@ -535,7 +535,10 @@ export class ModuleMocker { if (!isReadonlyProp(object, prop)) { const propDesc = Object.getOwnPropertyDescriptor(object, prop); - if ((propDesc !== undefined && !propDesc.get) || object.__esModule) { + if ( + propDesc !== undefined && + !(propDesc.get && prop == '__proto__') + ) { slots.add(prop); } } @@ -925,7 +928,9 @@ export class ModuleMocker { } this._getSlots(metadata.members).forEach(slot => { + let slotMock: Mocked; const slotMetadata = (metadata.members && metadata.members[slot]) || {}; + if (slotMetadata.ref != null) { callbacks.push( (function (ref) { @@ -933,7 +938,40 @@ export class ModuleMocker { })(slotMetadata.ref), ); } else { - mock[slot] = this._generateMock(slotMetadata, callbacks, refs); + slotMock = this._generateMock(slotMetadata, callbacks, refs); + + // For superclass accessor properties the subclass metadata contains the definitions + // for the getter and setter methods, and the superclass refs to them. + // The mock implementations are not available until the callbacks have been executed. + // Missing getter and setter refs will be resolved as their callbacks have been + // stacked before the setting of the accessor definition is stacked. + + // In some cases, e.g. third-party APIs, a 'prototype' ancestor to be + // mocked has a function property called 'get'. In this circumstance + // the 'prototype' property cannot be redefined and doing so causes an + // exception. Checks have been added for the 'configurable' and + // 'enumberable' properties present on true accessor property + // descriptors to prevent the attempt to replace the API. + if ( + (slotMetadata.members?.get?.ref !== undefined || + slotMetadata.members?.set?.ref !== undefined) && + slotMetadata.members?.configurable && + slotMetadata.members?.enumerable + ) { + callbacks.push( + (function (ref) { + return () => Object.defineProperty(mock, slot, ref); + })(slotMock as PropertyDescriptor), + ); + } else if ( + (slotMetadata.members?.get || slotMetadata.members?.set) && + slotMetadata.members?.configurable && + slotMetadata.members?.enumerable + ) { + Object.defineProperty(mock, slot, slotMock as PropertyDescriptor); + } else { + mock[slot] = slotMock; + } } }); @@ -1017,8 +1055,33 @@ export class ModuleMocker { ) { return; } - // @ts-expect-error no index signature - const slotMetadata = this.getMetadata(component[slot], refs); + + let descriptor = Object.getOwnPropertyDescriptor(component, slot); + let proto = Object.getPrototypeOf(component); + while (!descriptor && proto !== null) { + descriptor = Object.getOwnPropertyDescriptor(proto, slot); + proto = Object.getPrototypeOf(proto); + } + + let slotMetadata: MockMetadata | null = null; + if (descriptor?.get || descriptor?.set) { + // Specific case required for mocking class definitions imported via modules. + // In this case the class definitions are stored in accessor properties. + // All getters were previously ignored except where the containing object had __esModule == true + // Now getters are mocked the class definitions must still be read. + // @ts-expect-error ignore type mismatch + if (component.__esModule) { + // @ts-expect-error no index signature + slotMetadata = this.getMetadata(component[slot], refs); + } else { + // @ts-expect-error ignore type mismatch + slotMetadata = this.getMetadata(descriptor, refs); + } + } else { + // @ts-expect-error no index signature + slotMetadata = this.getMetadata(component[slot], refs); + } + if (slotMetadata) { if (!members) { members = {}; @@ -1088,15 +1151,15 @@ export class ModuleMocker { methodKey: K, accessType?: 'get' | 'set', ) { - if (typeof object !== 'object' && typeof object !== 'function') { + if (!object) { throw new Error( - `Cannot spyOn on a primitive value; ${this._typeOf(object)} given`, + `spyOn could not find an object to spy upon for ${String(methodKey)}`, ); } - if (!object) { + if (typeof object !== 'object' && typeof object !== 'function') { throw new Error( - `spyOn could not find an object to spy upon for ${String(methodKey)}`, + `Cannot spyOn on a primitive value; ${this._typeOf(object)} given`, ); } @@ -1104,10 +1167,16 @@ export class ModuleMocker { throw new Error('No property name supplied'); } + if (accessType && accessType != 'get' && accessType != 'set') { + throw new Error('Invalid accessType supplied'); + } + if (accessType) { return this._spyOnProperty(object, methodKey, accessType); } + //some properties do not return a property descriptor but do return + //a fn definition when read, e.g. window.dispatchEvent const original = object[methodKey]; if (!this.isMockFunction(original)) { @@ -1121,22 +1190,16 @@ export class ModuleMocker { ); } - const isMethodOwner = Object.prototype.hasOwnProperty.call( - object, - methodKey, - ); - - let descriptor = Object.getOwnPropertyDescriptor(object, methodKey); - let proto = Object.getPrototypeOf(object); - - while (!descriptor && proto !== null) { - descriptor = Object.getOwnPropertyDescriptor(proto, methodKey); - proto = Object.getPrototypeOf(proto); - } - let mock: Mock; + //This descriptor is used to determine if a function/class import is exposed via a getter. + //No inheritance is possible, and no conditional check is made on ownership during restore, + //thus the property is expected directly on the object. + const descriptor = Object.getOwnPropertyDescriptor(object, methodKey); if (descriptor && descriptor.get) { + if (!descriptor.configurable) { + throw new Error(`${String(methodKey)} is not declared configurable`); + } const originalGet = descriptor.get; mock = this._makeComponent({type: 'function'}, () => { descriptor!.get = originalGet; @@ -1145,6 +1208,11 @@ export class ModuleMocker { descriptor.get = () => mock; Object.defineProperty(object, methodKey, descriptor); } else { + const isMethodOwner = Object.prototype.hasOwnProperty.call( + object, + methodKey, + ); + mock = this._makeComponent({type: 'function'}, () => { if (isMethodOwner) { object[methodKey] = original; @@ -1198,18 +1266,26 @@ export class ModuleMocker { if (!this.isMockFunction(original)) { if (typeof original !== 'function') { throw new Error( - `Cannot spy the ${String( + `Cannot spy the ${String(accessType)} ${String( propertyKey, - )} property because it is not a function; ${this._typeOf( - original, - )} given instead`, + )} property because it is not a function; + ${this._typeOf(descriptor.get)} given instead`, ); } + const isMethodOwner = Object.prototype.hasOwnProperty.call( + obj, + propertyKey, + ); + descriptor[accessType] = this._makeComponent({type: 'function'}, () => { - // @ts-expect-error: mock is assignable - descriptor![accessType] = original; - Object.defineProperty(obj, propertyKey, descriptor!); + if (isMethodOwner) { + // @ts-expect-error: mock is assignable + descriptor![accessType] = original; + Object.defineProperty(obj, propertyKey, descriptor!); + } else { + delete obj[propertyKey]; + } }); (descriptor[accessType] as Mock<() => T>).mockImplementation(function (