diff --git a/CHANGELOG.md b/CHANGELOG.md index 87393bcd6c9c..fd028883f897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ ### Fixes +- `[jest-mock]` Improve `spyOn` typings to handle optional properties ([#13247](https://github.com/facebook/jest/pull/13247)) + ### Chore & Maintenance ### Performance diff --git a/packages/jest-mock/__typetests__/mock-functions.test.ts b/packages/jest-mock/__typetests__/mock-functions.test.ts index 6a7d6b82d0b6..0c747c2642e0 100644 --- a/packages/jest-mock/__typetests__/mock-functions.test.ts +++ b/packages/jest-mock/__typetests__/mock-functions.test.ts @@ -383,3 +383,79 @@ expectType void>>( expectError(spyOn(indexSpiedObject, 'propertyA')); expectError(spyOn(indexSpiedObject, 'notThere')); + +// interface with optional properties + +class SomeClass { + constructor(one: string, two?: boolean) {} + + methodA() { + return true; + } + methodB(a: string, b?: number) { + return; + } +} + +interface OptionalInterface { + constructorA?: (new (one: string) => SomeClass) | undefined; + constructorB: new (one: string, two: boolean) => SomeClass; + + propertyA?: number | undefined; + propertyB?: number; + propertyC: number | undefined; + propertyD: string; + + methodA?: ((a: boolean) => void) | undefined; + methodB: (b: string) => boolean; +} + +const optionalSpiedObject = {} as OptionalInterface; + +expectType SomeClass>>( + spyOn(optionalSpiedObject, 'constructorA'), +); +expectType SomeClass>>( + spyOn(optionalSpiedObject, 'constructorB'), +); + +expectError(spyOn(optionalSpiedObject, 'constructorA', 'get')); +expectError(spyOn(optionalSpiedObject, 'constructorA', 'set')); + +expectType void>>( + spyOn(optionalSpiedObject, 'methodA'), +); +expectType boolean>>( + spyOn(optionalSpiedObject, 'methodB'), +); + +expectError(spyOn(optionalSpiedObject, 'methodA', 'get')); +expectError(spyOn(optionalSpiedObject, 'methodA', 'set')); + +expectType number>>( + spyOn(optionalSpiedObject, 'propertyA', 'get'), +); +expectType void>>( + spyOn(optionalSpiedObject, 'propertyA', 'set'), +); +expectType number>>( + spyOn(optionalSpiedObject, 'propertyB', 'get'), +); +expectType void>>( + spyOn(optionalSpiedObject, 'propertyB', 'set'), +); +expectType number | undefined>>( + spyOn(optionalSpiedObject, 'propertyC', 'get'), +); +expectType void>>( + spyOn(optionalSpiedObject, 'propertyC', 'set'), +); +expectType string>>( + spyOn(optionalSpiedObject, 'propertyD', 'get'), +); +expectType void>>( + spyOn(optionalSpiedObject, 'propertyD', 'set'), +); + +expectError(spyOn(optionalSpiedObject, 'propertyA')); +expectError(spyOn(optionalSpiedObject, 'propertyB')); diff --git a/packages/jest-mock/__typetests__/utility-types.test.ts b/packages/jest-mock/__typetests__/utility-types.test.ts index 1a991b0b4d5b..895b5f77defd 100644 --- a/packages/jest-mock/__typetests__/utility-types.test.ts +++ b/packages/jest-mock/__typetests__/utility-types.test.ts @@ -62,6 +62,19 @@ class IndexClass { } } +interface OptionalInterface { + constructorA?: (new (one: string) => SomeClass) | undefined; + constructorB: new (one: string, two: boolean) => SomeClass; + + propertyA?: number | undefined; + propertyB?: number; + propertyC: number | undefined; + propertyD: string; + + methodA?: ((a: boolean) => void) | undefined; + methodB: (b: string) => boolean; +} + const someObject = { SomeClass, @@ -118,19 +131,23 @@ expectNotAssignable(someObject); // ConstructorKeys -declare const constructorKeys: ConstructorLikeKeys; +declare const interfaceConstructorKeys: ConstructorLikeKeys; +declare const objectConstructorKeys: ConstructorLikeKeys; -expectType<'SomeClass'>(constructorKeys); +expectType<'constructorA' | 'constructorB'>(interfaceConstructorKeys); +expectType<'SomeClass'>(objectConstructorKeys); // MethodKeys declare const classMethods: MethodLikeKeys; declare const indexClassMethods: MethodLikeKeys; +declare const interfaceMethods: MethodLikeKeys; declare const objectMethods: MethodLikeKeys; declare const indexObjectMethods: MethodLikeKeys; expectType<'methodA' | 'methodB'>(classMethods); expectType<'methodA' | 'methodB'>(indexClassMethods); +expectType<'methodA' | 'methodB'>(interfaceMethods); expectType<'methodA' | 'methodB' | 'methodC'>(objectMethods); expectType<'methodA' | 'methodB' | 'methodC'>(indexObjectMethods); @@ -138,10 +155,14 @@ expectType<'methodA' | 'methodB' | 'methodC'>(indexObjectMethods); declare const classProperties: PropertyLikeKeys; declare const indexClassProperties: PropertyLikeKeys; +declare const interfaceProperties: PropertyLikeKeys; declare const objectProperties: PropertyLikeKeys; declare const indexObjectProperties: PropertyLikeKeys; expectType<'propertyA' | 'propertyB' | 'propertyC'>(classProperties); expectType(indexClassProperties); +expectType<'propertyA' | 'propertyB' | 'propertyC' | 'propertyD'>( + interfaceProperties, +); expectType<'propertyA' | 'propertyB' | 'someClassInstance'>(objectProperties); expectType(indexObjectProperties); diff --git a/packages/jest-mock/src/index.ts b/packages/jest-mock/src/index.ts index 224ac7e9c70a..69319a05c583 100644 --- a/packages/jest-mock/src/index.ts +++ b/packages/jest-mock/src/index.ts @@ -41,11 +41,11 @@ export type ClassLike = {new (...args: any): any}; export type FunctionLike = (...args: any) => any; export type ConstructorLikeKeys = keyof { - [K in keyof T as T[K] extends ClassLike ? K : never]: T[K]; + [K in keyof T as Required[K] extends ClassLike ? K : never]: T[K]; }; export type MethodLikeKeys = keyof { - [K in keyof T as T[K] extends FunctionLike ? K : never]: T[K]; + [K in keyof T as Required[K] extends FunctionLike ? K : never]: T[K]; }; export type PropertyLikeKeys = Exclude< @@ -1008,40 +1008,48 @@ export class ModuleMocker { return fn; } - spyOn>( + spyOn< + T extends object, + K extends PropertyLikeKeys, + V extends Required[K], + >(object: T, methodKey: K, accessType: 'get'): SpyInstance<() => V>; + + spyOn< + T extends object, + K extends PropertyLikeKeys, + V extends Required[K], + >(object: T, methodKey: K, accessType: 'set'): SpyInstance<(arg: V) => void>; + + spyOn< + T extends object, + K extends ConstructorLikeKeys, + V extends Required[K], + >( object: T, - methodName: M, - accessType: 'get', - ): SpyInstance<() => T[M]>; - - spyOn>( - object: T, - methodName: M, - accessType: 'set', - ): SpyInstance<(arg: T[M]) => void>; - - spyOn>( - object: T, - methodName: M, - ): T[M] extends ClassLike - ? SpyInstance<(...args: ConstructorParameters) => InstanceType> + methodKey: K, + ): V extends ClassLike + ? SpyInstance<(...args: ConstructorParameters) => InstanceType> : never; - spyOn>( + spyOn< + T extends object, + K extends MethodLikeKeys, + V extends Required[K], + >( object: T, - methodName: M, - ): T[M] extends FunctionLike - ? SpyInstance<(...args: Parameters) => ReturnType> + methodKey: K, + ): V extends FunctionLike + ? SpyInstance<(...args: Parameters) => ReturnType> : never; // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - spyOn>( + spyOn>( object: T, - methodName: M, + methodKey: K, accessType?: 'get' | 'set', ) { if (accessType) { - return this._spyOnProperty(object, methodName, accessType); + return this._spyOnProperty(object, methodKey, accessType); } if (typeof object !== 'object' && typeof object !== 'function') { @@ -1050,13 +1058,13 @@ export class ModuleMocker { ); } - const original = object[methodName]; + const original = object[methodKey]; if (!this.isMockFunction(original)) { if (typeof original !== 'function') { throw new Error( `Cannot spy the ${String( - methodName, + methodKey, )} property because it is not a function; ${this._typeOf( original, )} given instead`, @@ -1065,14 +1073,14 @@ export class ModuleMocker { const isMethodOwner = Object.prototype.hasOwnProperty.call( object, - methodName, + methodKey, ); - let descriptor = Object.getOwnPropertyDescriptor(object, methodName); + let descriptor = Object.getOwnPropertyDescriptor(object, methodKey); let proto = Object.getPrototypeOf(object); while (!descriptor && proto !== null) { - descriptor = Object.getOwnPropertyDescriptor(proto, methodName); + descriptor = Object.getOwnPropertyDescriptor(proto, methodKey); proto = Object.getPrototypeOf(proto); } @@ -1082,20 +1090,20 @@ export class ModuleMocker { const originalGet = descriptor.get; mock = this._makeComponent({type: 'function'}, () => { descriptor!.get = originalGet; - Object.defineProperty(object, methodName, descriptor!); + Object.defineProperty(object, methodKey, descriptor!); }); descriptor.get = () => mock; - Object.defineProperty(object, methodName, descriptor); + Object.defineProperty(object, methodKey, descriptor); } else { mock = this._makeComponent({type: 'function'}, () => { if (isMethodOwner) { - object[methodName] = original; + object[methodKey] = original; } else { - delete object[methodName]; + delete object[methodKey]; } }); // @ts-expect-error overriding original method with a Mock - object[methodName] = mock; + object[methodKey] = mock; } mock.mockImplementation(function (this: unknown) { @@ -1103,13 +1111,13 @@ export class ModuleMocker { }); } - return object[methodName]; + return object[methodKey]; } - private _spyOnProperty>( + private _spyOnProperty>( obj: T, - propertyName: M, - accessType: 'get' | 'set' = 'get', + propertyKey: K, + accessType: 'get' | 'set', ): Mock<() => T> { if (typeof obj !== 'object' && typeof obj !== 'function') { throw new Error( @@ -1119,36 +1127,34 @@ export class ModuleMocker { if (!obj) { throw new Error( - `spyOn could not find an object to spy upon for ${String( - propertyName, - )}`, + `spyOn could not find an object to spy upon for ${String(propertyKey)}`, ); } - if (!propertyName) { + if (!propertyKey) { throw new Error('No property name supplied'); } - let descriptor = Object.getOwnPropertyDescriptor(obj, propertyName); + let descriptor = Object.getOwnPropertyDescriptor(obj, propertyKey); let proto = Object.getPrototypeOf(obj); while (!descriptor && proto !== null) { - descriptor = Object.getOwnPropertyDescriptor(proto, propertyName); + descriptor = Object.getOwnPropertyDescriptor(proto, propertyKey); proto = Object.getPrototypeOf(proto); } if (!descriptor) { - throw new Error(`${String(propertyName)} property does not exist`); + throw new Error(`${String(propertyKey)} property does not exist`); } if (!descriptor.configurable) { - throw new Error(`${String(propertyName)} is not declared configurable`); + throw new Error(`${String(propertyKey)} is not declared configurable`); } if (!descriptor[accessType]) { throw new Error( `Property ${String( - propertyName, + propertyKey, )} does not have access type ${accessType}`, ); } @@ -1159,7 +1165,7 @@ export class ModuleMocker { if (typeof original !== 'function') { throw new Error( `Cannot spy the ${String( - propertyName, + propertyKey, )} property because it is not a function; ${this._typeOf( original, )} given instead`, @@ -1169,7 +1175,7 @@ export class ModuleMocker { descriptor[accessType] = this._makeComponent({type: 'function'}, () => { // @ts-expect-error: mock is assignable descriptor![accessType] = original; - Object.defineProperty(obj, propertyName, descriptor!); + Object.defineProperty(obj, propertyKey, descriptor!); }); (descriptor[accessType] as Mock<() => T>).mockImplementation(function ( @@ -1180,7 +1186,7 @@ export class ModuleMocker { }); } - Object.defineProperty(obj, propertyName, descriptor); + Object.defineProperty(obj, propertyKey, descriptor); return descriptor[accessType] as Mock<() => T>; }