diff --git a/README.md b/README.md index a8d83641c5..645b26da09 100644 --- a/README.md +++ b/README.md @@ -439,9 +439,56 @@ returns attribute or structural directive which belongs to current element. `MockHelper.findDirective(fixture.debugElement, Directive)` - returns first found attribute or structural directive which belongs to current element or any child. -`MockHelper.findDirectives(fixture.debugElement, Directive)` +`MockHelper.findDirectives(fixture.debugElement, Directive)` - returns all found attribute or structural directives which belong to current element and all its child. +`MockHelper.mockService(instance, methodName)` - +returns a mocked function / spy of the method. If the method hasn't been mocked yet - mocks it. + +`MockHelper.mockService(instance, propertyName, 'get' | 'set')` - +returns a mocked function / spy of the property. If the property hasn't been mocked yet - mocks it. + +```typescript +// The example below uses auto spy. +it('mocks getters, setters and methods in a way that jasmine can mock them w/o an issue', () => { + const mock: GetterSetterMethodHuetod = MockService(GetterSetterMethodHuetod); + expect(mock).toBeDefined(); + + // Creating a mock on the getter. + MockHelper.mockService(mock, 'name', 'get').and.returnValue('mock'); + expect(mock.name).toEqual('mock'); + + // Creating a mock on the setter. + MockHelper.mockService(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'); + 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'); + expect((mock as any).fakeMethod('mock')).toEqual('mock'); + expect(MockHelper.mockService(mock, 'fakeMethod')).toHaveBeenCalledWith('mock'); +}); +``` + +## Auto Spy + +Add the next code to `src/test.ts` if you want all mocked methods and functions to be a jasmine spy. + +```typescript +import 'ng-mocks/dist/jasmine'; +``` + +In case of jest. + +```typescript +import 'ng-mocks/dist/jest'; +``` + ## Other examples of tests More detailed examples can be found in diff --git a/e2e/spies/fixtures.components.ts b/e2e/spies/fixtures.components.ts new file mode 100644 index 0000000000..21dfbc0b96 --- /dev/null +++ b/e2e/spies/fixtures.components.ts @@ -0,0 +1,19 @@ +import { Component } from '@angular/core'; +import { TargetService } from './fixtures.providers'; + +@Component({ + selector: 'target', + template: '', +}) +export class TargetComponent { + protected service: TargetService; + + constructor(service: TargetService) { + this.service = service; + this.service.echo('constructor'); + } + + public echo(): string { + return this.service.echo('TargetComponent'); + } +} diff --git a/e2e/spies/fixtures.modules.ts b/e2e/spies/fixtures.modules.ts new file mode 100644 index 0000000000..0e3f916cac --- /dev/null +++ b/e2e/spies/fixtures.modules.ts @@ -0,0 +1,7 @@ +import { NgModule } from '@angular/core'; +import { TargetService } from './fixtures.providers'; + +@NgModule({ + providers: [TargetService], +}) +export class TargetModule {} diff --git a/e2e/spies/fixtures.providers.ts b/e2e/spies/fixtures.providers.ts new file mode 100644 index 0000000000..ed3362df27 --- /dev/null +++ b/e2e/spies/fixtures.providers.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@angular/core'; + +@Injectable() +export class TargetService { + protected value = 'TargetService'; + + public echo(value?: string): string { + return value ? value : this.value; + } +} diff --git a/e2e/spies/test.spec.ts b/e2e/spies/test.spec.ts new file mode 100644 index 0000000000..43ecb97cd1 --- /dev/null +++ b/e2e/spies/test.spec.ts @@ -0,0 +1,73 @@ +import { inject, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { MockModule, MockRender } from 'ng-mocks'; + +import { TargetComponent } from './fixtures.components'; +import { TargetModule } from './fixtures.modules'; +import { TargetService } from './fixtures.providers'; +import createSpyObj = jasmine.createSpyObj; +import Spy = jasmine.Spy; + +describe('spies:real', () => { + beforeEach(() => + TestBed.configureTestingModule({ + declarations: [TargetComponent], + imports: [TargetModule], + }).compileComponents() + ); + + it('should render', () => { + const fixture = MockRender(TargetComponent); + const component = fixture.debugElement.query(By.directive(TargetComponent)).componentInstance as TargetComponent; + expect(component).toBeDefined(); + expect(component.echo()).toEqual('TargetComponent'); + }); +}); + +describe('spies:manual-mock', () => { + beforeEach(() => { + const spy = createSpyObj('TargetService', ['echo']); + spy.echo.and.returnValue('fake'); + + return TestBed.configureTestingModule({ + declarations: [TargetComponent], + imports: [MockModule(TargetModule)], + providers: [ + { + provide: TargetService, + useValue: spy, + }, + ], + }).compileComponents(); + }); + + it('should get manually mocked service', inject([TargetService], (targetService: TargetService) => { + const fixture = MockRender(TargetComponent); + const component = fixture.debugElement.query(By.directive(TargetComponent)).componentInstance as TargetComponent; + expect(component).toBeDefined(); + expect(targetService.echo).toHaveBeenCalledTimes(1); + expect(targetService.echo).toHaveBeenCalledWith('constructor'); + expect(component.echo()).toEqual('fake'); + expect(targetService.echo).toHaveBeenCalledTimes(2); // tslint:disable-line:no-magic-numbers + })); +}); + +describe('spies:auto-mock', () => { + beforeEach(() => + TestBed.configureTestingModule({ + declarations: [TargetComponent], + imports: [MockModule(TargetModule)], + }).compileComponents() + ); + + it('should get already mocked service', inject([TargetService], (targetService: TargetService) => { + const fixture = MockRender(TargetComponent); + const component = fixture.debugElement.query(By.directive(TargetComponent)).componentInstance as TargetComponent; + expect(component).toBeDefined(); + expect(targetService.echo).toHaveBeenCalledTimes(1); + expect(targetService.echo).toHaveBeenCalledWith('constructor'); + (targetService.echo as Spy).and.returnValue('faked'); + expect(component.echo()).toEqual('faked'); + expect(targetService.echo).toHaveBeenCalledTimes(2); // tslint:disable-line:no-magic-numbers + })); +}); diff --git a/jasmine.ts b/jasmine.ts new file mode 100644 index 0000000000..79db3d5f40 --- /dev/null +++ b/jasmine.ts @@ -0,0 +1,5 @@ +import { mockServiceHelper } from './lib/mock-service'; + +declare const jasmine: any; + +mockServiceHelper.registerMockFunction(mockName => jasmine.createSpy(mockName)); diff --git a/jest.ts b/jest.ts new file mode 100644 index 0000000000..2bb898aa57 --- /dev/null +++ b/jest.ts @@ -0,0 +1,5 @@ +import { mockServiceHelper } from './lib/mock-service'; + +declare const jest: any; + +mockServiceHelper.registerMockFunction(() => jest.fn()); diff --git a/karma.conf.ts b/karma.conf.ts index d51422cbc0..f065253de6 100644 --- a/karma.conf.ts +++ b/karma.conf.ts @@ -29,6 +29,7 @@ module.exports = (config: any) => { 'node_modules/zone.js/dist/fake-async-test.js', 'karma-test-shim.ts', 'index.ts', + 'jasmine.ts', { pattern: 'lib/**/*.ts' }, { pattern: 'e2e/**/*.ts' }, { pattern: 'examples/**/*.ts' }, diff --git a/lib/common/Mock.ts b/lib/common/Mock.ts index 066cce297e..9be6f307a3 100644 --- a/lib/common/Mock.ts +++ b/lib/common/Mock.ts @@ -12,7 +12,7 @@ export class Mock { if ((this as any)[method]) { continue; } - (this as any)[method] = mockServiceHelper.mockFunction(this, method); + (this as any)[method] = mockServiceHelper.mockFunction(); } for (const output of (this as any).__mockedOutputs) { if ((this as any)[output]) { diff --git a/lib/mock-component/mock-component.spec.ts b/lib/mock-component/mock-component.spec.ts index 0416baffca..94d932b1c7 100644 --- a/lib/mock-component/mock-component.spec.ts +++ b/lib/mock-component/mock-component.spec.ts @@ -145,7 +145,7 @@ describe('MockComponent', () => { }); it('should allow spying of viewchild component methods', () => { - const spy = spyOn(component.childComponent, 'performAction'); + const spy = component.childComponent.performAction; component.performActionOnChild('test'); expect(spy).toHaveBeenCalledWith('test'); }); diff --git a/lib/mock-directive/mock-directive.spec.ts b/lib/mock-directive/mock-directive.spec.ts index 1990bbb33f..82eb77e649 100644 --- a/lib/mock-directive/mock-directive.spec.ts +++ b/lib/mock-directive/mock-directive.spec.ts @@ -149,7 +149,7 @@ describe('MockDirective', () => { }); it('should allow spying of viewchild directive methods', () => { - const spy = spyOn(component.childDirective, 'performAction'); + const spy = component.childDirective.performAction; component.performActionOnChild('test'); expect(spy).toHaveBeenCalledWith('test'); }); diff --git a/lib/mock-helper/mock-helper.ts b/lib/mock-helper/mock-helper.ts index 233d236078..910a474774 100644 --- a/lib/mock-helper/mock-helper.ts +++ b/lib/mock-helper/mock-helper.ts @@ -2,6 +2,8 @@ import { DebugNode, Type } from '@angular/core'; +import { MockedFunction, mockServiceHelper } from '../mock-service'; + interface INestedNodes extends DebugNode { childNodes?: INestedNodes[]; } @@ -70,4 +72,7 @@ export const MockHelper = { }); return result; }, + + mockService: (instance: any, name: string, style?: 'get' | 'set'): T => + mockServiceHelper.mock(instance, name, style), }; diff --git a/lib/mock-service/mock-service.spec.ts b/lib/mock-service/mock-service.spec.ts index ac31e1bad1..b67b55701f 100644 --- a/lib/mock-service/mock-service.spec.ts +++ b/lib/mock-service/mock-service.spec.ts @@ -1,4 +1,5 @@ -import { MockService } from 'ng-mocks'; +import { InjectionToken } from '@angular/core'; +import { MockHelper, MockService } from 'ng-mocks'; // tslint:disable:max-classes-per-file class DeepParentClass { @@ -35,6 +36,25 @@ class ChildClass extends ParentClass { } } +class GetterSetterMethodHuetod { + public nameValue = 'nameValue'; + + get name(): string { + return `${this.nameValue}${this.nameValue}`; + } + + set name(value: string) { + this.nameValue = value; + } + + public nameMethod(value?: string): string { + if (value) { + this.name = value; + } + return this.name; + } +} + // tslint:enable:max-classes-per-file describe('MockService', () => { @@ -55,14 +75,23 @@ describe('MockService', () => { expect(MockService([new DeepParentClass()])).toEqual([]); }); - it('should convert functions to () => undefined', () => { + it('should convert arrow functions to () => undefined', () => { const mockedService = MockService(() => 0); expect(mockedService).toEqual(jasmine.any(Function), 'mockedService'); expect(mockedService()).toBeUndefined(); + expect(mockedService.and.identity()).toBe('func:arrow-function'); + }); + + it('should convert normal functions to an empty object because it is a class signature', () => { + const mockedService = MockService(function test() { + return 0; + }); + expect(mockedService).toEqual(jasmine.any(Object), 'mockedService'); }); it('should mock own methods of a class without a parent', () => { const mockedService = MockService(DeepParentClass); + expect(mockedService).toEqual(jasmine.any(Object)); // all properties should be undefined, maybe defined as getters and setters. expect(mockedService.deepParentMethodName).toBeUndefined('deepParentMethodName'); @@ -70,10 +99,12 @@ describe('MockService', () => { // all methods should be defined as functions which return undefined. expect(mockedService.deepParentMethod).toEqual(jasmine.any(Function), 'deepParentMethod'); expect(mockedService.deepParentMethod()).toBeUndefined('deepParentMethod()'); + expect(mockedService.deepParentMethod.and.identity()).toBe('DeepParentClass.deepParentMethod'); }); it('should mock own and parent methods of a class', () => { const mockedService = MockService(ChildClass); + expect(mockedService).toEqual(jasmine.any(ChildClass)); // all properties should be undefined, maybe defined as getters and setters. expect(mockedService.deepParentMethodName).toBeUndefined('deepParentMethodName'); @@ -84,16 +115,21 @@ describe('MockService', () => { // all methods should be defined as functions which return undefined. expect(mockedService.deepParentMethod).toEqual(jasmine.any(Function), 'deepParentMethod'); expect(mockedService.deepParentMethod()).toBeUndefined('deepParentMethod()'); + expect(mockedService.deepParentMethod.and.identity()).toBe('ChildClass.deepParentMethod'); expect(mockedService.parentMethod).toEqual(jasmine.any(Function), 'parentMethod'); expect(mockedService.parentMethod()).toBeUndefined('parentMethod()'); + expect(mockedService.parentMethod.and.identity()).toBe('ChildClass.parentMethod'); expect(mockedService.overrideMe).toEqual(jasmine.any(Function), 'overrideMe'); expect(mockedService.overrideMe()).toBeUndefined('overrideMe()'); + expect(mockedService.overrideMe.and.identity()).toBe('ChildClass.overrideMe'); expect(mockedService.childMethod).toEqual(jasmine.any(Function), 'childMethod'); expect(mockedService.childMethod()).toBeUndefined('childMethod()'); + expect(mockedService.childMethod.and.identity()).toBe('ChildClass.childMethod'); }); it('should mock an instance of a class as an object', () => { const mockedService = MockService(new ChildClass()); + expect(mockedService).toEqual(jasmine.any(ChildClass)); // all properties should be undefined, maybe defined as getters and setters. expect(mockedService.deepParentMethodName).toBeUndefined('deepParentMethodName'); @@ -104,12 +140,16 @@ describe('MockService', () => { // all methods should be defined as functions which return undefined. expect(mockedService.deepParentMethod).toEqual(jasmine.any(Function), 'deepParentMethod'); expect(mockedService.deepParentMethod()).toBeUndefined('deepParentMethod()'); + expect(mockedService.deepParentMethod.and.identity()).toBe('ChildClass.deepParentMethod'); expect(mockedService.parentMethod).toEqual(jasmine.any(Function), 'parentMethod'); expect(mockedService.parentMethod()).toBeUndefined('parentMethod()'); + expect(mockedService.parentMethod.and.identity()).toBe('ChildClass.parentMethod'); expect(mockedService.overrideMe).toEqual(jasmine.any(Function), 'overrideMe'); expect(mockedService.overrideMe()).toBeUndefined('overrideMe()'); + expect(mockedService.overrideMe.and.identity()).toBe('ChildClass.overrideMe'); expect(mockedService.childMethod).toEqual(jasmine.any(Function), 'childMethod'); expect(mockedService.childMethod()).toBeUndefined('childMethod()'); + expect(mockedService.childMethod.and.identity()).toBe('ChildClass.childMethod'); }); it('should mock own and nested properties of an object', () => { @@ -144,7 +184,39 @@ describe('MockService', () => { }); expect(mockedService.child1.child11.func1()).toBeUndefined('func1()'); + expect(mockedService.child1.child11.func1.and.identity()).toBe('func:instance.child1.child11.func1'); expect(mockedService.func2()).toBeUndefined('func2()'); + expect(mockedService.func2.and.identity()).toBe('func:instance.func2'); expect(mockedService.func3()).toBeUndefined('func3()'); + expect(mockedService.func3.and.identity()).toBe('func:instance.func3'); + }); + + it('mocks getters, setters and methods in a way that jasmine can mock them w/o an issue', () => { + const mock: GetterSetterMethodHuetod = MockService(GetterSetterMethodHuetod); + expect(mock).toBeDefined(); + + // Creating a mock on the getter. + MockHelper.mockService(mock, 'name', 'get').and.returnValue('mock'); + expect(mock.name).toEqual('mock'); + + // Creating a mock on the setter. + MockHelper.mockService(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'); + 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'); + expect((mock as any).fakeMethod('mock')).toEqual('mock'); + expect(MockHelper.mockService(mock, 'fakeMethod')).toHaveBeenCalledWith('mock'); + }); + + it('mocks injection tokens as undefined', () => { + const token1 = MockService(new InjectionToken('hello')); + expect(token1).toBeUndefined(); }); }); diff --git a/lib/mock-service/mock-service.ts b/lib/mock-service/mock-service.ts index 92cc0a8a48..b2cfd2e83d 100644 --- a/lib/mock-service/mock-service.ts +++ b/lib/mock-service/mock-service.ts @@ -1,63 +1,154 @@ export type MockedFunction = () => undefined; -/** - * @internal - */ -export const mockServiceHelper = { - mockFunction: (object?: {}, method?: string): MockedFunction => () => undefined, +const isFunc = (value: any): boolean => { + if (typeof value !== 'function') { + return false; + } + const proto = value.toString(); + if (proto.match(/^\(/) !== null) { + return true; + } + return proto.match(/^function\s*\(/) !== null; +}; + +const isClass = (value: any): boolean => { + if (typeof value !== 'function') { + return false; + } + if (isFunc(value)) { + return false; + } + const proto = value.toString(); + if (proto.match(/^class\b/) !== null) { + return true; + } + return proto.match(/^function\s*\(/) === null; +}; + +const isInst = (value: any): boolean => { + if (value === null) { + return false; + } + if (typeof value !== 'object') { + return false; + } + if (value.ngMetadataName === 'InjectionToken') { + return false; + } + return typeof value.__proto__ === 'object'; +}; + +let customMockFunction: ((mockName: string) => MockedFunction) | undefined; + +const mockServiceHelperPrototype = { + mockFunction: (mockName: string): MockedFunction => { + if (customMockFunction) { + return customMockFunction(mockName); + } + return () => undefined; + }, - createMockFromPrototype: (service: any): { [key: string]: MockedFunction } => { - const methods = mockServiceHelper.extractMethodsFromPrototype(service); - const value: { [key: string]: MockedFunction } = {}; + registerMockFunction: (mockFunction: typeof customMockFunction) => { + customMockFunction = mockFunction; + }, + + createMockFromPrototype: (service: any): { [key in keyof any]: MockedFunction } => { + const methods = mockServiceHelperPrototype.extractMethodsFromPrototype(service); + const value: any = {}; for (const method of methods) { - value[method] = mockServiceHelper.mockFunction(value, method); + if (value[method]) { + continue; + } + const mockName = `${service.constructor ? service.constructor.name : 'unknown'}.${method as any}`; + value[method] = mockServiceHelperPrototype.mockFunction(mockName); } + if (typeof value === 'object') { + value.__proto__ = service; + } + return value; }, - extractMethodsFromPrototype: (service: any): string[] => { - const result: string[] = []; + extractMethodsFromPrototype: (service: T): Array => { + const result: Array = []; let prototype = service; while (prototype && Object.getPrototypeOf(prototype) !== null) { - for (const method of Object.getOwnPropertyNames(prototype)) { - if (method === 'constructor') { + for (const method of Object.getOwnPropertyNames(prototype) as Array) { + if ((method as any) === 'constructor') { continue; } const descriptor = Object.getOwnPropertyDescriptor(prototype, method); const isGetterSetter = descriptor && (descriptor.get || descriptor.set); - if (!isGetterSetter && result.indexOf(method) === -1) { - result.push(method); + if (isGetterSetter || result.indexOf(method) !== -1) { + continue; } + result.push(method); } prototype = Object.getPrototypeOf(prototype); } return result; }, + + mock: (instance: any, name: string, style?: 'get' | 'set'): T => { + const def = Object.getOwnPropertyDescriptor(instance, name); + if (def && def[style || 'value']) { + return def[style || 'value']; + } + + 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, + }); + return mock; + }, }; -export function MockService(service?: boolean | number | string | null): undefined; -export function MockService(service: T): any; -export function MockService(service: any): any { +// We need a single pointer to the object among all environments. +((window as any) || (global as any)).ngMocksMockServiceHelper = + ((window as any) || (global as any)).ngMocksMockServiceHelper || mockServiceHelperPrototype; + +const localHelper: typeof mockServiceHelperPrototype = ((window as any) || (global as any)).ngMocksMockServiceHelper; + +/** + * DO NOT USE this object outside of the library. + * It can be changed any time without a notice. + * + * @internal + */ +export const mockServiceHelper: { + extractMethodsFromPrototype(service: any): Array; + mock(instance: any, name: string, style?: 'get' | 'set'): T; + mockFunction(): MockedFunction; + registerMockFunction(mockFunction: (mockName: string) => MockedFunction | undefined): void; +} = ((window as any) || (global as any)).ngMocksMockServiceHelper; + +export function MockService(service?: boolean | number | string | null, mockNamePrefix?: string): undefined; +export function MockService(service: T, mockNamePrefix?: string): any; +export function MockService(service: any, mockNamePrefix?: string): any { // mocking all methods / properties of a class / object. let value: any; - if (typeof service === 'function' && service.prototype) { - value = mockServiceHelper.createMockFromPrototype(service.prototype); - } else if (typeof service === 'function') { - value = mockServiceHelper.mockFunction(); + if (isClass(service)) { + value = localHelper.createMockFromPrototype(service.prototype); + } else if (isFunc(service)) { + value = localHelper.mockFunction(`func:${mockNamePrefix ? mockNamePrefix : service.name || 'arrow-function'}`); } else if (Array.isArray(service)) { value = []; - } else if (typeof service === 'object' && service !== null && service.ngMetadataName !== 'InjectionToken') { + } else if (isInst(service)) { value = - typeof service.constructor === 'function' && service.constructor.prototype - ? mockServiceHelper.createMockFromPrototype(service.constructor.prototype) + typeof service.constructor === 'function' + ? localHelper.createMockFromPrototype(service.constructor.prototype) : {}; for (const property of Object.keys(service)) { - const mock = MockService(service[property]); + const mock = MockService(service[property], `${mockNamePrefix ? mockNamePrefix : 'instance'}.${property}`); if (mock !== undefined) { value[property] = mock; } } + value.__proto__ = service.__proto__; } return value; diff --git a/tsconfig.json b/tsconfig.json index cf876657fd..97d39281d0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,5 +23,5 @@ }, "skipLibCheck": true }, - "include": ["index.ts"] + "include": ["index.ts", "jasmine.ts", "jest.ts"] }