From 05dd90b1b17239865d7f8e275ba8327e200d7f69 Mon Sep 17 00:00:00 2001 From: MG Date: Sun, 17 May 2020 11:10:18 +0200 Subject: [PATCH] feat: original instanceof and properties closes #109 # Conflicts: # README.md --- README.md | 18 +++- jest.ts | 2 +- lib/common/Mock.spec.ts | 144 +++++++++++++++++++++++--- lib/common/Mock.ts | 49 +++++++-- lib/common/mock-of.decorator.ts | 4 - lib/mock-pipe/mock-pipe.ts | 2 +- lib/mock-service/mock-service.spec.ts | 18 +++- lib/mock-service/mock-service.ts | 129 +++++++++++++++++++---- 8 files changed, 310 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index f8a62328e2..764a9b9356 100644 --- a/README.md +++ b/README.md @@ -509,23 +509,33 @@ describe('MockService', () => { expect(mock).toBeDefined(); // Creating a mock on the getter. - MockHelper.mockService(mock, 'name', 'get').and.returnValue('mock'); + spyOnProperty(mock, 'name', 'get').and.returnValue('mock'); expect(mock.name).toEqual('mock'); // Creating a mock on the setter. - MockHelper.mockService(mock, 'name', 'set'); + spyOnProperty(mock, 'name', 'set'); mock.name = 'mock'; expect(MockHelper.mockService(mock, 'name', 'set')).toHaveBeenCalledWith('mock'); // Creating a mock on the method. - MockHelper.mockService(mock, 'nameMethod').and.returnValue('mock'); + spyOn(mock, 'nameMethod').and.returnValue('mock'); expect(mock.nameMethod('mock')).toEqual('mock'); expect(MockHelper.mockService(mock, 'nameMethod')).toHaveBeenCalledWith('mock'); // Creating a mock on the method that doesn't exist. - MockHelper.mockService(mock, 'fakeMethod').and.returnValue('mock'); + MockHelper.mockService(mock, 'fakeMethod'); + spyOn(mock as any, 'fakeMethod').and.returnValue('mock'); expect((mock as any).fakeMethod('mock')).toEqual('mock'); expect(MockHelper.mockService(mock, 'fakeMethod')).toHaveBeenCalledWith('mock'); + + // Creating a mock on the property that doesn't exist. + MockHelper.mockService(mock, 'fakeProp', 'get'); + MockHelper.mockService(mock, 'fakeProp', 'set'); + spyOnProperty(mock as any, 'fakeProp', 'get').and.returnValue('mockProp'); + spyOnProperty(mock as any, 'fakeProp', 'set'); + expect((mock as any).fakeProp).toEqual('mockProp'); + (mock as any).fakeProp = 'mockPropSet'; + expect(MockHelper.mockService(mock as any, 'fakeProp', 'set')).toHaveBeenCalledWith('mockPropSet'); }); }); ``` diff --git a/jest.ts b/jest.ts index 2bb898aa57..17c7019a49 100644 --- a/jest.ts +++ b/jest.ts @@ -2,4 +2,4 @@ import { mockServiceHelper } from './lib/mock-service'; declare const jest: any; -mockServiceHelper.registerMockFunction(() => jest.fn()); +mockServiceHelper.registerMockFunction(name => jest.fn().mockName(name)); diff --git a/lib/common/Mock.spec.ts b/lib/common/Mock.spec.ts index caf82f987a..eeddfcda50 100644 --- a/lib/common/Mock.spec.ts +++ b/lib/common/Mock.spec.ts @@ -7,8 +7,6 @@ import { MockDirective } from '../mock-directive'; import { MockModule } from '../mock-module'; import { MockPipe } from '../mock-pipe'; -import { Mock, MockControlValueAccessor } from './Mock'; - class ParentClass { protected parentValue = true; @@ -17,17 +15,60 @@ class ParentClass { } } -@NgModule({}) +@NgModule({ + providers: [ + { + provide: 'MOCK', + useValue: 'HELLO', + }, + ], +}) +class ChildModuleClass extends ParentClass implements PipeTransform { + protected childValue = true; + + public childMethod(): boolean { + return this.childValue; + } + + transform(): string { + return typeof this.childValue; + } +} + @Component({ template: '', }) +class ChildComponentClass extends ParentClass implements PipeTransform { + protected childValue = true; + + public childMethod(): boolean { + return this.childValue; + } + + transform(): string { + return typeof this.childValue; + } +} + @Directive({ selector: 'mock', }) +class ChildDirectiveClass extends ParentClass implements PipeTransform { + protected childValue = true; + + public childMethod(): boolean { + return this.childValue; + } + + transform(): string { + return typeof this.childValue; + } +} + @Pipe({ name: 'mock', }) -class ChildClass extends ParentClass implements PipeTransform { +class ChildPipeClass extends ParentClass implements PipeTransform { protected childValue = true; public childMethod(): boolean { @@ -41,32 +82,107 @@ class ChildClass extends ParentClass implements PipeTransform { describe('Mock', () => { it('should affect as MockModule', () => { - const instance = new (MockModule(ChildClass))(); - expect(instance).toEqual(jasmine.any(Mock)); + const instance = new (MockModule(ChildModuleClass))(); + expect(instance).toEqual(jasmine.any(ChildModuleClass)); + expect((instance as any).__ngMocksMock).toEqual(true); + expect((instance as any).__ngMocksMockControlValueAccessor).toEqual(undefined); expect(instance.parentMethod()).toBeUndefined('mocked to an empty function'); expect(instance.childMethod()).toBeUndefined('mocked to an empty function'); }); it('should affect as MockComponent', () => { - const instance = new (MockComponent(ChildClass))(); - expect(instance).toEqual(jasmine.any(MockControlValueAccessor)); - expect(instance).toEqual(jasmine.any(Mock)); + const instance = new (MockComponent(ChildComponentClass))(); + expect(instance).toEqual(jasmine.any(ChildComponentClass)); + expect((instance as any).__ngMocksMock).toEqual(true); + expect((instance as any).__ngMocksMockControlValueAccessor).toEqual(true); + + const spy = jasmine.createSpy('spy'); + instance.registerOnChange(spy); + instance.__simulateChange('test'); + expect(spy).toHaveBeenCalledWith('test'); + expect(instance.parentMethod()).toBeUndefined('mocked to an empty function'); expect(instance.childMethod()).toBeUndefined('mocked to an empty function'); }); it('should affect as MockDirective', () => { - const instance = new (MockDirective(ChildClass))(); - expect(instance).toEqual(jasmine.any(MockControlValueAccessor)); - expect(instance).toEqual(jasmine.any(Mock)); + const instance = new (MockDirective(ChildDirectiveClass))(); + expect(instance).toEqual(jasmine.any(ChildDirectiveClass)); + expect((instance as any).__ngMocksMock).toEqual(true); + expect((instance as any).__ngMocksMockControlValueAccessor).toEqual(true); + + const spy = jasmine.createSpy('spy'); + instance.registerOnChange(spy); + instance.__simulateChange('test'); + expect(spy).toHaveBeenCalledWith('test'); + expect(instance.parentMethod()).toBeUndefined('mocked to an empty function'); expect(instance.childMethod()).toBeUndefined('mocked to an empty function'); }); it('should affect as MockPipe', () => { - const instance = new (MockPipe(ChildClass))(); - expect(instance).toEqual(jasmine.any(Mock)); + const instance = new (MockPipe(ChildPipeClass))(); + expect(instance).toEqual(jasmine.any(ChildPipeClass)); + expect((instance as any).__ngMocksMock).toEqual(true); + expect((instance as any).__ngMocksMockControlValueAccessor).toEqual(undefined); expect(instance.parentMethod()).toBeUndefined('mocked to an empty function'); expect(instance.childMethod()).toBeUndefined('mocked to an empty function'); }); }); + +describe('Mock prototype', () => { + @Component({ + selector: 'custom', + template: '', + }) + class CustomComponent { + public test = 'custom'; + + public get __ngMocksMock(): string { + return 'IMPOSSIBLE_OVERRIDE'; + } + + public get test1(): string { + return 'test1'; + } + + public set test2(value: string) { + this.test = value; + } + + public testMethod(): string { + return this.test; + } + } + + it('should get all things mocked and in the same time respect prototype', () => { + const mockDef = MockComponent(CustomComponent); + const mock = new mockDef(); + expect(mock).toEqual(jasmine.any(CustomComponent)); + + // checking that it was processed through Mock + expect(mock.__ngMocksMock as any).toBe(true); + expect(mock.__ngMocksMockControlValueAccessor as any).toBe(true); + + // checking that it was processed through MockControlValueAccessor + const spy = jasmine.createSpy('spy'); + mock.registerOnChange(spy); + mock.__simulateChange('test'); + expect(spy).toHaveBeenCalledWith('test'); + + // properties are mocked too + expect(mock.test1).toBeUndefined(); + (mock as any).test1 = 'MyCustomValue'; + expect(mock.test1).toEqual('MyCustomValue'); + + // properties are mocked too + expect(mock.test2).toBeUndefined(); + (mock as any).test2 = 'MyCustomValue'; + expect(mock.test2).toEqual('MyCustomValue'); + + // properties are mocked too + expect(mock.test).toBeUndefined(); + (mock as any).test = 'MyCustomValue'; + expect(mock.test).toEqual('MyCustomValue'); + }); +}); diff --git a/lib/common/Mock.ts b/lib/common/Mock.ts index 9be6f307a3..6b8824d41a 100644 --- a/lib/common/Mock.ts +++ b/lib/common/Mock.ts @@ -8,22 +8,59 @@ import { mockServiceHelper } from '../mock-service'; // tslint:disable-next-line:no-unnecessary-class export class Mock { constructor() { - for (const method of (this as any).__mockedMethods) { - if ((this as any)[method]) { + // setting outputs + for (const output of (this as any).__mockedOutputs) { + if ((this as any)[output] || Object.getOwnPropertyDescriptor(this, output)) { continue; } - (this as any)[method] = mockServiceHelper.mockFunction(); + (this as any)[output] = new EventEmitter(); } - for (const output of (this as any).__mockedOutputs) { - if ((this as any)[output]) { + + // setting our mocked methods and props + const prototype = Object.getPrototypeOf(this); + for (const method of mockServiceHelper.extractMethodsFromPrototype(prototype)) { + const descriptor = mockServiceHelper.extractPropertyDescriptor(prototype, method); + if (descriptor) { + Object.defineProperty(this, method, descriptor); + } + } + for (const prop of mockServiceHelper.extractPropertiesFromPrototype(prototype)) { + const descriptor = mockServiceHelper.extractPropertyDescriptor(prototype, prop); + if (!descriptor) { continue; } - (this as any)[output] = new EventEmitter(); + Object.defineProperty(this, prop, descriptor); } + + // setting mocks for original class methods and props + for (const method of mockServiceHelper.extractMethodsFromPrototype((this.constructor as any).mockOf.prototype)) { + if ((this as any)[method] || Object.getOwnPropertyDescriptor(this, method)) { + continue; + } + mockServiceHelper.mock(this, method); + } + for (const prop of mockServiceHelper.extractPropertiesFromPrototype((this.constructor as any).mockOf.prototype)) { + if ((this as any)[prop] || Object.getOwnPropertyDescriptor(this, prop)) { + continue; + } + mockServiceHelper.mock(this, prop, 'get'); + mockServiceHelper.mock(this, prop, 'set'); + } + + // and faking prototype + Object.setPrototypeOf(this, (this.constructor as any).mockOf.prototype); + } + + get __ngMocksMock(): boolean { + return true; } } export class MockControlValueAccessor extends Mock implements ControlValueAccessor { + get __ngMocksMockControlValueAccessor(): boolean { + return true; + } + __simulateChange = (param: any) => {}; // tslint:disable-line:variable-name __simulateTouch = () => {}; // tslint:disable-line:variable-name diff --git a/lib/common/mock-of.decorator.ts b/lib/common/mock-of.decorator.ts index c237e2f18a..66e4f055ca 100644 --- a/lib/common/mock-of.decorator.ts +++ b/lib/common/mock-of.decorator.ts @@ -1,7 +1,5 @@ import { Type } from '@angular/core'; -import { mockServiceHelper } from '../mock-service'; - // This helps with debugging in the browser. Decorating mock classes with this // will change the display-name of the class to 'MockOf-` so our // debugging output (and Angular's error messages) will mention our mock classes @@ -16,8 +14,6 @@ export const MockOf = (mockClass: Type, outputs?: string[]) => (constructor nameConstructor: { value: constructor.name }, }); - constructor.prototype.__mockedMethods = mockServiceHelper.extractMethodsFromPrototype(mockClass.prototype); - const mockedOutputs = []; for (const output of outputs || []) { mockedOutputs.push(output.split(':')[0]); diff --git a/lib/mock-pipe/mock-pipe.ts b/lib/mock-pipe/mock-pipe.ts index 70cf09a65f..526c93566e 100644 --- a/lib/mock-pipe/mock-pipe.ts +++ b/lib/mock-pipe/mock-pipe.ts @@ -25,7 +25,7 @@ export function MockPipe( transform = transform || defaultTransform; } - const mockedPipe: Type = Pipe(options)(PipeMock as any); + const mockedPipe: Type> = Pipe(options)(PipeMock as any); return mockedPipe; } diff --git a/lib/mock-service/mock-service.spec.ts b/lib/mock-service/mock-service.spec.ts index 689d4f642c..28d06e16f5 100644 --- a/lib/mock-service/mock-service.spec.ts +++ b/lib/mock-service/mock-service.spec.ts @@ -200,23 +200,33 @@ describe('MockService', () => { expect(mock).toBeDefined(); // Creating a mock on the getter. - MockHelper.mockService(mock, 'name', 'get').and.returnValue('mock'); + spyOnProperty(mock, 'name', 'get').and.returnValue('mock'); expect(mock.name).toEqual('mock'); // Creating a mock on the setter. - MockHelper.mockService(mock, 'name', 'set'); + spyOnProperty(mock, 'name', 'set'); mock.name = 'mock'; expect(MockHelper.mockService(mock, 'name', 'set')).toHaveBeenCalledWith('mock'); // Creating a mock on the method. - MockHelper.mockService(mock, 'nameMethod').and.returnValue('mock'); + spyOn(mock, 'nameMethod').and.returnValue('mock'); expect(mock.nameMethod('mock')).toEqual('mock'); expect(MockHelper.mockService(mock, 'nameMethod')).toHaveBeenCalledWith('mock'); // Creating a mock on the method that doesn't exist. - MockHelper.mockService(mock, 'fakeMethod').and.returnValue('mock'); + MockHelper.mockService(mock, 'fakeMethod'); + spyOn(mock as any, 'fakeMethod').and.returnValue('mock'); expect((mock as any).fakeMethod('mock')).toEqual('mock'); expect(MockHelper.mockService(mock, 'fakeMethod')).toHaveBeenCalledWith('mock'); + + // Creating a mock on the property that doesn't exist. + MockHelper.mockService(mock, 'fakeProp', 'get'); + MockHelper.mockService(mock, 'fakeProp', 'set'); + spyOnProperty(mock as any, 'fakeProp', 'get').and.returnValue('mockProp'); + spyOnProperty(mock as any, 'fakeProp', 'set'); + expect((mock as any).fakeProp).toEqual('mockProp'); + (mock as any).fakeProp = 'mockPropSet'; + expect(MockHelper.mockService(mock as any, 'fakeProp', 'set')).toHaveBeenCalledWith('mockPropSet'); }); it('mocks injection tokens as undefined', () => { diff --git a/lib/mock-service/mock-service.ts b/lib/mock-service/mock-service.ts index b2cfd2e83d..93ec26bcb7 100644 --- a/lib/mock-service/mock-service.ts +++ b/lib/mock-service/mock-service.ts @@ -1,4 +1,4 @@ -export type MockedFunction = () => undefined; +export type MockedFunction = () => any; const isFunc = (value: any): boolean => { if (typeof value !== 'function') { @@ -35,17 +35,33 @@ const isInst = (value: any): boolean => { if (value.ngMetadataName === 'InjectionToken') { return false; } - return typeof value.__proto__ === 'object'; + return typeof Object.getPrototypeOf(value) === 'object'; }; let customMockFunction: ((mockName: string) => MockedFunction) | undefined; const mockServiceHelperPrototype = { - mockFunction: (mockName: string): MockedFunction => { - if (customMockFunction) { + mockFunction: (mockName: string, original: boolean = false): MockedFunction => { + if (customMockFunction && !original) { return customMockFunction(mockName); } - return () => undefined; + + // magic to make getters / setters working + + let value: any; + let setValue: any; + + const func: any = (val: any) => { + if (setValue) { + setValue(val); + } + return value; + }; + func.__ngMocks = true; + func.__ngMocksSet = (newSetValue: any) => (setValue = newSetValue); + func.__ngMocksGet = (newValue: any) => (value = newValue); + + return func; }, registerMockFunction: (mockFunction: typeof customMockFunction) => { @@ -63,17 +79,17 @@ const mockServiceHelperPrototype = { value[method] = mockServiceHelperPrototype.mockFunction(mockName); } if (typeof value === 'object') { - value.__proto__ = service; + Object.setPrototypeOf(value, service); } return value; }, - extractMethodsFromPrototype: (service: T): Array => { - const result: Array = []; + extractMethodsFromPrototype: (service: T): string[] => { + const result: string[] = []; let prototype = service; while (prototype && Object.getPrototypeOf(prototype) !== null) { - for (const method of Object.getOwnPropertyNames(prototype) as Array) { + for (const method of Object.getOwnPropertyNames(prototype)) { if ((method as any) === 'constructor') { continue; } @@ -90,19 +106,86 @@ const mockServiceHelperPrototype = { return result; }, - mock: (instance: any, name: string, style?: 'get' | 'set'): T => { + extractPropertiesFromPrototype: (service: T): string[] => { + const result: string[] = []; + let prototype = service; + while (prototype && Object.getPrototypeOf(prototype) !== null) { + for (const prop of Object.getOwnPropertyNames(prototype)) { + if ((prop as any) === 'constructor') { + continue; + } + + const descriptor = Object.getOwnPropertyDescriptor(prototype, prop); + const isGetterSetter = descriptor && (descriptor.get || descriptor.set); + if (!isGetterSetter || result.indexOf(prop) !== -1) { + continue; + } + result.push(prop); + } + prototype = Object.getPrototypeOf(prototype); + } + return result; + }, + + extractPropertyDescriptor: (service: T, prop: string): PropertyDescriptor | undefined => { + let prototype = service; + while (prototype && Object.getPrototypeOf(prototype) !== null) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, prop); + if (descriptor) { + return descriptor; + } + prototype = Object.getPrototypeOf(prototype); + } + }, + + // tslint:disable-next-line:cyclomatic-complexity + mock: (instance: any, name: string, accessType?: 'get' | 'set'): T => { const def = Object.getOwnPropertyDescriptor(instance, name); - if (def && def[style || 'value']) { - return def[style || 'value']; + if (def && def[accessType || 'value']) { + return def[accessType || 'value']; + } + + const mockName = `${ + typeof instance.prototype === 'function' + ? instance.prototype.name + : typeof instance.constructor === 'function' + ? instance.constructor.name + : 'unknown' + }.${name}${accessType ? `:${accessType}` : ''}`; + const mock: any = mockServiceHelperPrototype.mockFunction(mockName, !!accessType); + + const mockDef: PropertyDescriptor = { + // keeping setter if we adding getter + ...(accessType === 'get' && def && def.set + ? { + set: def.set, + } + : {}), + + // keeping getter if we adding setter + ...(accessType === 'set' && def && def.get + ? { + get: def.get, + } + : {}), + + // to allow replacement for functions + ...(accessType + ? {} + : { + writable: true, + }), + + [accessType || 'value']: mock, + configurable: true, + enumerable: true, + }; + + if (mockDef.get && mockDef.set && (mockDef.get as any).__ngMocks && (mockDef.set as any).__ngMocks) { + (mockDef.set as any).__ngMocksSet((val: any) => (mockDef.get as any).__ngMocksGet(val)); } - const mockName = `${typeof instance.prototype === 'function' ? instance.prototype.name : 'unknown'}.${name}${ - style ? `:${style}` : '' - }`; - const mock: any = mockServiceHelperPrototype.mockFunction(mockName); - Object.defineProperty(instance, name, { - [style || 'value']: mock, - }); + Object.defineProperty(instance, name, mockDef); return mock; }, }; @@ -120,9 +203,11 @@ const localHelper: typeof mockServiceHelperPrototype = ((window as any) || (glob * @internal */ export const mockServiceHelper: { - extractMethodsFromPrototype(service: any): Array; + extractMethodsFromPrototype(service: any): string[]; + extractPropertiesFromPrototype(service: any): string[]; + extractPropertyDescriptor(service: any, prop: string): PropertyDescriptor | undefined; mock(instance: any, name: string, style?: 'get' | 'set'): T; - mockFunction(): MockedFunction; + mockFunction(mockName: string): MockedFunction; registerMockFunction(mockFunction: (mockName: string) => MockedFunction | undefined): void; } = ((window as any) || (global as any)).ngMocksMockServiceHelper; @@ -148,7 +233,7 @@ export function MockService(service: any, mockNamePrefix?: string): any { value[property] = mock; } } - value.__proto__ = service.__proto__; + Object.setPrototypeOf(value, Object.getPrototypeOf(service)); } return value;