From 0306643c8bf44b4876aedf88b9375cda8490566f Mon Sep 17 00:00:00 2001 From: MG Date: Wed, 20 Jan 2021 21:06:55 +0100 Subject: [PATCH] feat(mock-instance): simpler interface --- docs/articles/api/MockInstance.md | 76 +++++--- docs/articles/api/ngMocks.md | 2 +- lib/common/core.helpers.ts | 2 + lib/common/decorate.mock.ts | 7 +- lib/common/mock.ts | 5 +- lib/mock-instance/mock-instance-apply.ts | 20 +++ lib/mock-instance/mock-instance.ts | 150 +++++++++++++--- lib/mock-service/helper.use-factory.ts | 5 +- tests-failures/mock-instance-members.ts | 38 ++++ tests/mock-instance-member/reset.spec.ts | 34 ++++ tests/mock-instance-member/test.spec.ts | 210 +++++++++++++++++++++++ 11 files changed, 494 insertions(+), 55 deletions(-) create mode 100644 lib/mock-instance/mock-instance-apply.ts create mode 100644 tests-failures/mock-instance-members.ts create mode 100644 tests/mock-instance-member/reset.spec.ts create mode 100644 tests/mock-instance-member/test.spec.ts diff --git a/docs/articles/api/MockInstance.md b/docs/articles/api/MockInstance.md index 3b78b3fcb7..caf0578251 100644 --- a/docs/articles/api/MockInstance.md +++ b/docs/articles/api/MockInstance.md @@ -4,26 +4,58 @@ description: Information how to customize mock components, directives, services sidebar_label: MockInstance --- -A mock instance of declarations or providers in tests may be customized via `MockInstance`. -It is useful, when we want to configure spies before its usage. +**MockInstance** helps to define **customizations for declarations** and providers in test suites +before the desired instance have been created. + +It is useful, when we want to configure spies before their usage. It supports: modules, components, directives, pipes, services and tokens. -There are two ways how to customize a mock instance: +There are **three ways how to customize** a mock instance: + +- set desired values +- manipulate the instance (with access to injector) +- return the desired shape (with access to injector) + +## Set desired values + +It helps to provide a predefined spy or value. -- directly define properties and methods -- return the desired shape +```ts +MockInstance(Service, 'methodName', () => 'fake'); +MockInstance(Service, 'propName', 'fake'); +MockInstance(Service, 'propName', () => 'fake', 'get'); +MockInstance(Service, 'propName', () => undefined, 'set'); +``` -## Customizing classes +It returns the provided value, that allows to customize spies. + +```ts +MockInstance(Service, 'methodName', jasmine.createSpy()) + .and.returnValue('fake'); +MockInstance(Service, 'propName', jest.fn(), 'get') + .mockReturnValue('fake'); +``` + +## Manipulate the instance + +If we pass a callback as the second parameter to **MockInstance**, +then we have access to the instance and to the related injector. ```ts -// setting values to instance MockInstance(Service, (instance, injector) => { instance.prop1 = injector.get(SOME_TOKEN); instance.method1 = jasmine.createSpy().and.returnValue(5); instance.method2 = value => (instance.prop2 = value); }); +``` -// returning a custom shape +## Return the desired shape + +If the callback of the second parameter of **MockInstance** returns something, +then the returned value will be applied to the instance. + +```ts +// with injector and spies MockInstance(Service, (instance, injector) => ({ prop1: injector.get(SOME_TOKEN), method1: jasmine.createSpy().and.returnValue(5), @@ -40,7 +72,7 @@ MockInstance(Service, () => ({ ## Customizing tokens -In case of tokens, the handler should return the token value. +In case of tokens, a callback should return the token value. ```ts MockInstance(TOKEN, (instance, injector) => { @@ -51,21 +83,22 @@ MockInstance(TOKEN, () => true); ## Resetting customization -In order to reset the handler, `MockInstance` should be called without it. +In order to reset the provided callback, `MockInstance` should be called without it. ```ts MockInstance(Service); MockInstance(TOKEN); // Or simply one call. -// It resets all handlers for all declarations. +// It resets all customizations for all declarations. MockReset(); ``` ## Overriding customization -Every call of `MockInstance` overrides the previous handler. +Every call of `MockInstance` overrides the previous callback. `MockInstance` can be called anywhere, -but if it is called in `beforeEach` or in `it`, then the handler has its effect only during the current spec. +but **suggested usage** is to call `MockInstance` in `beforeEach` or in `it`, +then the callback has its effect only during the current spec. ```ts beforeAll(() => MockInstance(TOKEN, () => true)); @@ -127,19 +160,14 @@ a solution here. That is where `ng-mocks` helps again with the `MockInstance` he It accepts a class as the first parameter, and a tiny callback describing how to customize its instances as the second one. ```ts -beforeEach(() => - MockInstance(ChildComponent, () => ({ - // Now we can customize a mock object - // of ChildComponent in its ctor call. - // The object will be extended - // with the returned object. - update$: EMPTY, - })), -); +// Now we can customize a mock object. +// The update$ property of the object +// will be set to EMPTY in its ctor call. +beforeEach(() => MockInstance(ChildComponent, 'update$', EMPTY)); ``` -Profit. When Angular creates an instance of `ChildComponent`, the callback is called in its ctor, and `update$` property -of the instance is an `Observable` instead of `undefined`. +Profit. When Angular creates an instance of `ChildComponent`, the rule is applied in its ctor, and `update$` property +of the instance is not `undefined`, but an `Observable`. ## Advanced example diff --git a/docs/articles/api/ngMocks.md b/docs/articles/api/ngMocks.md index 53e00beef9..eeb54b1960 100644 --- a/docs/articles/api/ngMocks.md +++ b/docs/articles/api/ngMocks.md @@ -4,7 +4,7 @@ description: Introduction to ngMocks object and its purpose sidebar_label: Introduction --- -`ngMocks` is a namespace which provides variety of helper functions which help to customize mocks, +**ngMocks** is a namespace which provides variety of helper functions which help to customize mocks, access desired elements and instances in fixtures. ## Customizing mock behavior diff --git a/lib/common/core.helpers.ts b/lib/common/core.helpers.ts index 7f8c847d4a..dd7d0f8a8f 100644 --- a/lib/common/core.helpers.ts +++ b/lib/common/core.helpers.ts @@ -106,6 +106,7 @@ export const extendClass = (base: AnyType): Type => { const child: Type = extendClassicClass(base); Object.defineProperty(child, 'name', { configurable: true, + enumerable: true, value: `MockMiddleware${base.name}`, writable: true, }); @@ -114,6 +115,7 @@ export const extendClass = (base: AnyType): Type => { if (parameters.length) { Object.defineProperty(child, 'parameters', { configurable: true, + enumerable: false, value: [...parameters], writable: true, }); diff --git a/lib/common/decorate.mock.ts b/lib/common/decorate.mock.ts index b2e3f80fa5..997de08081 100644 --- a/lib/common/decorate.mock.ts +++ b/lib/common/decorate.mock.ts @@ -7,5 +7,10 @@ export default function (mock: AnyType, source: AnyType, config: ngMoc name: { value: `MockOf${source.name}` }, nameConstructor: { value: mock.name }, }); - mock.prototype.__ngMocksConfig = config; + Object.defineProperty(mock.prototype, '__ngMocksConfig', { + configurable: true, + enumerable: false, + value: config, + writable: true, + }); } diff --git a/lib/common/mock.ts b/lib/common/mock.ts index 5e5c6a9a25..86269f98fc 100644 --- a/lib/common/mock.ts +++ b/lib/common/mock.ts @@ -6,6 +6,7 @@ import { mapValues } from '../common/core.helpers'; import { AnyType } from '../common/core.types'; import { IMockBuilderConfig } from '../mock-builder/types'; import mockHelperStub from '../mock-helper/mock-helper.stub'; +import mockInstanceApply from '../mock-instance/mock-instance-apply'; import helperMockService from '../mock-service/helper.mock-service'; import funcIsMock from './func.is-mock'; @@ -155,9 +156,7 @@ const applyOverrides = (instance: any, mockOf: any, injector?: Injector): void = if (instance.__ngMocksConfig.init) { callbacks.push(instance.__ngMocksConfig.init); } - if (ngMocksUniverse.configInstance.get(mockOf)?.init) { - callbacks.push(ngMocksUniverse.configInstance.get(mockOf).init); - } + callbacks.push(...mockInstanceApply(mockOf)); for (const callback of callbacks) { const overrides = callback(instance, injector); diff --git a/lib/mock-instance/mock-instance-apply.ts b/lib/mock-instance/mock-instance-apply.ts new file mode 100644 index 0000000000..904acdb11d --- /dev/null +++ b/lib/mock-instance/mock-instance-apply.ts @@ -0,0 +1,20 @@ +import ngMocksUniverse from '../common/ng-mocks-universe'; +import mockHelperStubMember from '../mock-helper/mock-helper.stub-member'; + +export default (def: any): any[] => { + const callbacks = []; + + const config = ngMocksUniverse.configInstance.get(def); + if (config?.init) { + callbacks.push(config.init); + } + if (config?.overloads) { + for (const [name, stub, encapsulation] of config.overloads) { + callbacks.push((instance: any) => { + mockHelperStubMember(instance, name, stub, encapsulation); + }); + } + } + + return callbacks; +}; diff --git a/lib/mock-instance/mock-instance.ts b/lib/mock-instance/mock-instance.ts index d4e56f9611..57349b430e 100644 --- a/lib/mock-instance/mock-instance.ts +++ b/lib/mock-instance/mock-instance.ts @@ -3,14 +3,27 @@ import { InjectionToken, Injector } from '@angular/core'; import { AbstractType, Type } from '../common/core.types'; import ngMocksUniverse from '../common/ng-mocks-universe'; -let installReporter = true; -const restore = (declaration: any, config: any): void => { - if (installReporter) { - jasmine.getEnv().addReporter(reporter); - installReporter = false; +const stack: any[][] = [[]]; +const stackPush = () => { + stack.push([]); +}; +const stackPop = () => { + for (const declaration of stack.pop() || /* istanbul ignore next */ []) { + ngMocksUniverse.configInstance.get(declaration)?.overloads?.pop(); + } + // istanbul ignore if + if (stack.length === 0) { + stack.push([]); } +}; - ngMocksUniverse.getLocalMocks().push([declaration, config]); +const reporterStack: jasmine.CustomReporter = { + jasmineDone: stackPop, + jasmineStarted: stackPush, + specDone: stackPop, + specStarted: stackPush, + suiteDone: stackPop, + suiteStarted: stackPush, }; const reporter: jasmine.CustomReporter = { @@ -35,6 +48,108 @@ const reporter: jasmine.CustomReporter = { }, }; +let installReporter = true; +const restore = (declaration: any, config: any): void => { + if (installReporter) { + jasmine.getEnv().addReporter(reporter); + installReporter = false; + } + + ngMocksUniverse.getLocalMocks().push([declaration, config]); +}; + +interface MockInstanceArgs { + accessor?: 'get' | 'set'; + data?: any; + key?: string; + value?: any; +} + +const parseMockInstanceArgs = (args: any[]): MockInstanceArgs => { + const set: MockInstanceArgs = {}; + + if (typeof args[0] === 'string') { + set.key = args[0]; + set.value = args[1]; + set.accessor = args[2]; + } else { + set.data = args[0]; + } + + return set; +}; + +const mockInstanceConfig = (declaration: Type | AbstractType | InjectionToken, data?: any): void => { + const config = typeof data === 'function' ? { init: data } : data; + const universeConfig = ngMocksUniverse.configInstance.has(declaration) + ? ngMocksUniverse.configInstance.get(declaration) + : {}; + restore(declaration, universeConfig); + + if (config) { + ngMocksUniverse.configInstance.set(declaration, { + ...universeConfig, + ...config, + }); + } else { + ngMocksUniverse.configInstance.set(declaration, { + ...universeConfig, + init: undefined, + overloads: undefined, + }); + } +}; + +let installStackReporter = true; +const mockInstanceMember = ( + declaration: Type | AbstractType | InjectionToken, + name: string, + stub: any, + encapsulation?: 'get' | 'set', +) => { + if (installStackReporter) { + jasmine.getEnv().addReporter(reporterStack); + installStackReporter = false; + } + const config = ngMocksUniverse.configInstance.has(declaration) ? ngMocksUniverse.configInstance.get(declaration) : {}; + const overloads = config.overloads || []; + overloads.push([name, stub, encapsulation]); + config.overloads = overloads; + ngMocksUniverse.configInstance.set(declaration, config); + stack[stack.length - 1].push(declaration); + + return stub; +}; + +/** + * @see https://github.com/ike18t/ng-mocks#ngmocksstubmember + */ +export function MockInstance T[K]>( + instance: Type | AbstractType, + name: K, + stub: S, + encapsulation: 'get', +): S; + +/** + * @see https://github.com/ike18t/ng-mocks#ngmocksstubmember + */ +export function MockInstance void>( + instance: Type | AbstractType, + name: K, + stub: S, + encapsulation: 'set', +): S; + +/** + * @see https://github.com/ike18t/ng-mocks#ngmocksstubmember + */ +export function MockInstance( + instance: Type | AbstractType, + name: K, + stub: S, +): S; + /** * @see https://github.com/ike18t/ng-mocks#mockinstance */ @@ -71,24 +186,13 @@ export function MockInstance( }, ): void; -export function MockInstance(declaration: Type | AbstractType | InjectionToken, data?: any) { - const config = typeof data === 'function' ? { init: data } : data; - const universeConfig = ngMocksUniverse.configInstance.has(declaration) - ? ngMocksUniverse.configInstance.get(declaration) - : {}; - restore(declaration, universeConfig); - - if (config) { - ngMocksUniverse.configInstance.set(declaration, { - ...universeConfig, - ...config, - }); - } else { - ngMocksUniverse.configInstance.set(declaration, { - ...universeConfig, - init: undefined, - }); +export function MockInstance(declaration: Type | AbstractType | InjectionToken, ...args: any[]) { + const { key, value, accessor, data } = parseMockInstanceArgs(args); + if (key) { + return mockInstanceMember(declaration, key, value, accessor); } + + mockInstanceConfig(declaration, data); } export function MockReset() { diff --git a/lib/mock-service/helper.use-factory.ts b/lib/mock-service/helper.use-factory.ts index a829533f44..5f8b400cb2 100644 --- a/lib/mock-service/helper.use-factory.ts +++ b/lib/mock-service/helper.use-factory.ts @@ -4,6 +4,7 @@ import { mapValues } from '../common/core.helpers'; import { isNgInjectionToken } from '../common/func.is-ng-injection-token'; import ngMocksUniverse from '../common/ng-mocks-universe'; import mockHelperStub from '../mock-helper/mock-helper.stub'; +import mockInstanceApply from '../mock-instance/mock-instance-apply'; import { MockService } from '../mock-service/mock-service'; const applyCallbackToken = (def: any): boolean => isNgInjectionToken(def) || typeof def === 'string'; @@ -49,9 +50,7 @@ export default ( if (overrides) { callbacks.push(overrides); } - if (ngMocksUniverse.configInstance.get(def)?.init) { - callbacks.push(ngMocksUniverse.configInstance.get(def).init); - } + callbacks.push(...mockInstanceApply(def)); return applyCallback(def, instance, callbacks, injector, overrides); }, diff --git a/tests-failures/mock-instance-members.ts b/tests-failures/mock-instance-members.ts new file mode 100644 index 0000000000..1769c1398f --- /dev/null +++ b/tests-failures/mock-instance-members.ts @@ -0,0 +1,38 @@ +import { InjectionToken } from '@angular/core'; +import { MockInstance } from 'ng-mocks'; + +const TOKEN = new InjectionToken('token'); + +class TargetService { + public name = 'target'; + + public echo1(): string { + return this.name; + } +} + +// @ts-expect-error: tokens can be set only by a callback +MockInstance(TOKEN, '123'); +// accepts callbacks +MockInstance(TOKEN, () => '123'); + +// @ts-expect-error: wrong type +MockInstance(TargetService, 'name', 123); +MockInstance(TargetService, 'name', 'mock'); +// @ts-expect-error: expects a getter +MockInstance(TargetService, 'name', 'mock', 'get'); +MockInstance(TargetService, 'name', () => 'mock', 'get'); +// @ts-expect-error: expects a setter +MockInstance(TargetService, 'name', 'mock', 'set'); +MockInstance(TargetService, 'name', () => {}, 'set'); + +// allows chaining +MockInstance(TargetService, 'echo1', jasmine.createSpy()).and.returnValue('123'); +MockInstance(TargetService, 'echo1', jest.fn()).mockReturnValue(123); +MockInstance(TargetService, 'name', jasmine.createSpy(), 'get').and.returnValue('123'); +MockInstance(TargetService, 'name', jest.fn(), 'get').mockReturnValue(123); + +// @ts-expect-error: knows its type +MockInstance(TargetService, 'name', jasmine.createSpy(), 'get').mockReturnValue(123); +// @ts-expect-error: knows its type +MockInstance(TargetService, 'name', jest.fn(), 'get').and.returnValue('123'); diff --git a/tests/mock-instance-member/reset.spec.ts b/tests/mock-instance-member/reset.spec.ts new file mode 100644 index 0000000000..b00908db47 --- /dev/null +++ b/tests/mock-instance-member/reset.spec.ts @@ -0,0 +1,34 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + MockComponent, + MockInstance, + MockRender, + MockReset, +} from 'ng-mocks'; + +@Component({ + selector: 'target', + template: '', +}) +class TargetComponent { + public global = ''; +} + +describe('mock-instance-member:reset', () => { + beforeEach(() => + TestBed.configureTestingModule({ + declarations: [MockComponent(TargetComponent)], + }), + ); + + it('does not fail', () => { + MockInstance(TargetComponent, 'global', 'mock'); + const component = MockRender(TargetComponent).point + .componentInstance; + expect(component.global).toEqual('mock'); + // Because of this call it resets all overloads + // and would proper handling can cause failures in reporter. + MockReset(); + }); +}); diff --git a/tests/mock-instance-member/test.spec.ts b/tests/mock-instance-member/test.spec.ts new file mode 100644 index 0000000000..ae3e3e7aa1 --- /dev/null +++ b/tests/mock-instance-member/test.spec.ts @@ -0,0 +1,210 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + MockComponent, + MockInstance, + MockRender, + ngMocks, +} from 'ng-mocks'; + +@Component({ + selector: 'target', + template: '', +}) +class TargetComponent { + public beforeAll1 = ''; + public beforeAll2 = ''; + public beforeEach1 = ''; + public beforeEach2 = ''; + public beforeEach3 = ''; + public describe1 = ''; + public describe2 = ''; + public global = ''; + public it1 = ''; + public it2 = ''; +} + +MockInstance(TargetComponent, 'global', 'mock'); + +describe('mock-instance-member', () => { + MockInstance(TargetComponent, 'describe1', 'mock'); + + beforeAll(() => { + ngMocks.reset(); + MockInstance(TargetComponent, 'beforeAll1', 'mock'); + }); + + beforeEach(() => + MockInstance(TargetComponent, 'beforeEach1', 'mock'), + ); + beforeEach(() => + TestBed.configureTestingModule({ + declarations: [MockComponent(TargetComponent)], + }), + ); + beforeEach(() => + MockInstance(TargetComponent, 'beforeEach2', 'mock'), + ); + + it('gets right stubs #1', () => { + const component = MockRender(TargetComponent).point + .componentInstance; + expect(component).toEqual( + jasmine.objectContaining({ + beforeAll1: 'mock', + beforeEach1: 'mock', + beforeEach2: 'mock', + }), + ); + expect(component.beforeAll2).toBeUndefined(); + expect(component.beforeEach3).toBeUndefined(); + expect(component.it1).toBeUndefined(); + expect(component.it2).toBeUndefined(); + + // caused by reset in beforeAll + expect(component.describe1).toBeUndefined(); + expect(component.describe2).toBeUndefined(); + expect(component.global).toBeUndefined(); + }); + + it('gets right stubs #2', () => { + const component = MockRender(TargetComponent).point + .componentInstance; + expect(component).toEqual( + jasmine.objectContaining({ + beforeAll1: 'mock', + beforeEach1: 'mock', + beforeEach2: 'mock', + }), + ); + expect(component.beforeAll2).toBeUndefined(); + expect(component.beforeEach3).toBeUndefined(); + expect(component.it1).toBeUndefined(); + expect(component.it2).toBeUndefined(); + + // caused by reset in beforeAll + expect(component.describe1).toBeUndefined(); + expect(component.describe2).toBeUndefined(); + expect(component.global).toBeUndefined(); + }); + + describe('nested w/ overrides', () => { + MockInstance(TargetComponent, 'describe2', 'mock'); + + beforeAll(() => + MockInstance(TargetComponent, 'beforeAll2', 'mock'), + ); + beforeEach(() => + MockInstance(TargetComponent, 'beforeEach3', 'mock'), + ); + + it('gets right stubs #1', () => { + const component = MockRender(TargetComponent).point + .componentInstance; + expect(component).toEqual( + jasmine.objectContaining({ + beforeAll1: 'mock', + beforeAll2: 'mock', + beforeEach1: 'mock', + beforeEach2: 'mock', + beforeEach3: 'mock', + }), + ); + expect(component.it1).toBeUndefined(); + expect(component.it2).toBeUndefined(); + + // caused by reset in beforeAll + expect(component.describe1).toBeUndefined(); + expect(component.describe2).toBeUndefined(); + expect(component.global).toBeUndefined(); + }); + + it('gets right stubs #2', () => { + MockInstance(TargetComponent, 'it1', () => 'mock', 'get'); + const component = MockRender(TargetComponent).point + .componentInstance; + expect(component).toEqual( + jasmine.objectContaining({ + beforeAll1: 'mock', + beforeAll2: 'mock', + beforeEach1: 'mock', + beforeEach2: 'mock', + beforeEach3: 'mock', + it1: 'mock', + }), + ); + expect(component.it2).toBeUndefined(); + + // caused by reset in beforeAll + expect(component.describe1).toBeUndefined(); + expect(component.describe2).toBeUndefined(); + expect(component.global).toBeUndefined(); + }); + }); + + describe('nested w/o overrides', () => { + it('gets right stubs #1', () => { + MockInstance(TargetComponent, 'it2', () => 'mock', 'get'); + const component = MockRender(TargetComponent).point + .componentInstance; + expect(component).toEqual( + jasmine.objectContaining({ + beforeAll1: 'mock', + beforeEach1: 'mock', + beforeEach2: 'mock', + it2: 'mock', + }), + ); + expect(component.beforeAll2).toBeUndefined(); + expect(component.beforeEach3).toBeUndefined(); + expect(component.it1).toBeUndefined(); + + // caused by reset in beforeAll + expect(component.describe1).toBeUndefined(); + expect(component.describe2).toBeUndefined(); + expect(component.global).toBeUndefined(); + }); + + it('gets right stubs #2', () => { + const component = MockRender(TargetComponent).point + .componentInstance; + expect(component).toEqual( + jasmine.objectContaining({ + beforeAll1: 'mock', + beforeEach1: 'mock', + beforeEach2: 'mock', + }), + ); + expect(component.beforeAll2).toBeUndefined(); + expect(component.beforeEach3).toBeUndefined(); + expect(component.it1).toBeUndefined(); + expect(component.it2).toBeUndefined(); + + // caused by reset in beforeAll + expect(component.describe1).toBeUndefined(); + expect(component.describe2).toBeUndefined(); + expect(component.global).toBeUndefined(); + }); + }); + + it('gets right stubs #3', () => { + const component = MockRender(TargetComponent).point + .componentInstance; + expect(component).toEqual( + jasmine.objectContaining({ + beforeAll1: 'mock', + beforeEach1: 'mock', + beforeEach2: 'mock', + }), + ); + expect(component.beforeAll2).toBeUndefined(); + expect(component.beforeEach3).toBeUndefined(); + expect(component.it1).toBeUndefined(); + expect(component.it2).toBeUndefined(); + + // caused by reset in beforeAll + expect(component.describe1).toBeUndefined(); + expect(component.describe2).toBeUndefined(); + expect(component.global).toBeUndefined(); + }); +});