From 062d147c0ccadce2621003c7e0c6f6143acc80b8 Mon Sep 17 00:00:00 2001 From: MG Date: Sat, 26 Sep 2020 17:36:13 +0200 Subject: [PATCH] feat: mocked providers for kept declarations closes #172 --- lib/common/lib.ts | 30 +++++++ lib/mock-builder/mock-builder.ts | 48 ++++++++++- lib/mock-component/mock-component.ts | 18 ++++- lib/mock-directive/mock-directive.ts | 18 ++++- lib/mock-module/mock-module.ts | 38 +++------ lib/mock-service/mock-service.ts | 6 ++ tests/issues-172/test.spec.ts | 115 +++++++++++++++++++++++++++ 7 files changed, 237 insertions(+), 36 deletions(-) create mode 100644 tests/issues-172/test.spec.ts diff --git a/lib/common/lib.ts b/lib/common/lib.ts index 3eeec86e29..786bebf84b 100644 --- a/lib/common/lib.ts +++ b/lib/common/lib.ts @@ -72,6 +72,36 @@ export const mapEntries = (set: Map): Array<[K, T]> => { return result; }; +export const extendClass = (base: Type): Type => { + let child: any; + const parent: any = base; + + // first we try to eval es2015 style and if it fails to use es5 transpilation in the catch block. + (window as any).ngMocksParent = parent; + try { + // tslint:disable-next-line:no-eval + eval(` + class child extends window.ngMocksParent { + } + window.ngMocksResult = child + `); + child = (window as any).ngMocksResult; + } catch (e) { + class ClassEs5 extends parent {} + child = ClassEs5; + } + (window as any).ngMocksParent = undefined; + + // the next step is to respect constructor parameters as the parent class. + if (child) { + child.parameters = jitReflector + .parameters(parent) + .map(parameter => ngMocksUniverse.cacheMocks.get(parameter) || parameter); + } + + return child; +}; + export const isNgType = (object: Type, type: string): boolean => jitReflector.annotations(object).some(annotation => annotation.ngMetadataName === type); diff --git a/lib/mock-builder/mock-builder.ts b/lib/mock-builder/mock-builder.ts index 4328857819..0be5c65ca8 100644 --- a/lib/mock-builder/mock-builder.ts +++ b/lib/mock-builder/mock-builder.ts @@ -1,5 +1,6 @@ import { InjectionToken, NgModule, PipeTransform, Provider } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; +import { MetadataOverride, TestBed } from '@angular/core/testing'; +import { directiveResolver, ngModuleResolver } from 'ng-mocks/dist/lib/common/reflect'; import { flatten, @@ -15,7 +16,7 @@ import { import { ngMocksUniverse } from '../common/ng-mocks-universe'; import { MockComponent } from '../mock-component'; import { MockDirective } from '../mock-directive'; -import { MockModule, MockProvider } from '../mock-module'; +import { MockModule, MockNgDef, MockProvider } from '../mock-module'; import { MockPipe } from '../mock-pipe'; import { mockServiceHelper } from '../mock-service'; @@ -190,6 +191,45 @@ export class MockBuilderPromise implements PromiseLike { ngMocksUniverse.touches.delete(def); } + // Redefining providers for kept declarations. + for (const value of mapValues(ngMocksUniverse.builder)) { + let meta: NgModule | undefined; + if (isNgDef(value, 'm')) { + meta = ngModuleResolver.resolve(value); + } else if (isNgDef(value, 'c')) { + meta = directiveResolver.resolve(value); + } else if (isNgDef(value, 'd')) { + meta = directiveResolver.resolve(value); + } else { + continue; + } + + const skipMock = ngMocksUniverse.flags.has('skipMock'); + if (!skipMock) { + ngMocksUniverse.flags.add('skipMock'); + } + const [changed, def] = MockNgDef({ providers: meta.providers }); + if (!skipMock) { + ngMocksUniverse.flags.delete('skipMock'); + } + if (!changed) { + continue; + } + const override: MetadataOverride<{ providers: Provider[] | undefined }> = { + set: { + providers: def.providers, + }, + }; + + if (isNgDef(value, 'm')) { + TestBed.overrideModule(value, override); + } else if (isNgDef(value, 'c')) { + TestBed.overrideComponent(value, override); + } else if (isNgDef(value, 'd')) { + TestBed.overrideDirective(value, override); + } + } + // Setting up TestBed. const imports: Array | NgModuleWithProviders> = []; @@ -422,7 +462,9 @@ export class MockBuilderPromise implements PromiseLike { this.mockDef.pipe.delete(source); this.replaceDef.pipe.set(source, destination); } else { - throw new Error('cannot replace the source by destination destination, wrong types'); + throw new Error( + 'Cannot replace the declaration, both have to be a Module, a Component, a Directive or a Pipe, for Providers use `.mock` or `.provide`' + ); } if (config) { this.configDef.set(source, config); diff --git a/lib/mock-component/mock-component.ts b/lib/mock-component/mock-component.ts index 2534ec620c..95b8085016 100644 --- a/lib/mock-component/mock-component.ts +++ b/lib/mock-component/mock-component.ts @@ -106,7 +106,11 @@ export function MockComponent( providers: [ { provide: component, - useExisting: forwardRef(() => ComponentMock), + useExisting: (() => { + const value: Type & { __ngMocksSkip?: boolean } = forwardRef(() => ComponentMock); + value.__ngMocksSkip = true; + return value; + })(), }, ], selector, @@ -124,7 +128,11 @@ export function MockComponent( options.providers.push({ multi: true, provide, - useExisting: forwardRef(() => ComponentMock), + useExisting: (() => { + const value: Type & { __ngMocksSkip?: boolean } = forwardRef(() => ComponentMock); + value.__ngMocksSkip = true; + return value; + })(), }); continue; } @@ -133,7 +141,11 @@ export function MockComponent( options.providers.push({ multi: true, provide, - useExisting: forwardRef(() => ComponentMock), + useExisting: (() => { + const value: Type & { __ngMocksSkip?: boolean } = forwardRef(() => ComponentMock); + value.__ngMocksSkip = true; + return value; + })(), }); continue; } diff --git a/lib/mock-directive/mock-directive.ts b/lib/mock-directive/mock-directive.ts index 26134ea38a..5ccb160c0a 100644 --- a/lib/mock-directive/mock-directive.ts +++ b/lib/mock-directive/mock-directive.ts @@ -72,7 +72,11 @@ export function MockDirective(directive: Type): Type DirectiveMock), + useExisting: (() => { + const value: Type & { __ngMocksSkip?: boolean } = forwardRef(() => DirectiveMock); + value.__ngMocksSkip = true; + return value; + })(), }, ], selector, @@ -89,7 +93,11 @@ export function MockDirective(directive: Type): Type DirectiveMock), + useExisting: (() => { + const value: Type & { __ngMocksSkip?: boolean } = forwardRef(() => DirectiveMock); + value.__ngMocksSkip = true; + return value; + })(), }); continue; } @@ -98,7 +106,11 @@ export function MockDirective(directive: Type): Type DirectiveMock), + useExisting: (() => { + const value: Type & { __ngMocksSkip?: boolean } = forwardRef(() => DirectiveMock); + value.__ngMocksSkip = true; + return value; + })(), }); continue; } diff --git a/lib/mock-module/mock-module.ts b/lib/mock-module/mock-module.ts index 87d214c0a1..3d762ad3ca 100644 --- a/lib/mock-module/mock-module.ts +++ b/lib/mock-module/mock-module.ts @@ -4,6 +4,7 @@ import { ApplicationModule, NgModule, Provider } from '@angular/core'; import { getTestBed } from '@angular/core/testing'; import { + extendClass, flatten, getMockedNgDefOf, isNgDef, @@ -14,7 +15,7 @@ import { Type, } from '../common'; import { ngMocksUniverse } from '../common/ng-mocks-universe'; -import { jitReflector, ngModuleResolver } from '../common/reflect'; +import { ngModuleResolver } from '../common/reflect'; import { MockComponent } from '../mock-component'; import { MockDirective } from '../mock-directive'; import { MockPipe } from '../mock-pipe'; @@ -118,7 +119,7 @@ export function MockModule(module: any): any { } } - const [changed, ngModuleDef] = MockNgModuleDef(meta, ngModule); + const [changed, ngModuleDef] = MockNgDef(meta, ngModule); if (changed) { mockModuleDef = ngModuleDef; } @@ -126,29 +127,7 @@ export function MockModule(module: any): any { if (mockModuleDef) { const parent = ngMocksUniverse.flags.has('skipMock') ? ngModule : Mock; - - // first we try to eval es2015 style and if it fails to use es5 transpilation in the catch block. - (window as any).ngMocksParent = parent; - try { - // tslint:disable-next-line:no-eval - eval(` - class mockModule extends window.ngMocksParent { - } - window.ngMocksResult = mockModule - `); - mockModule = (window as any).ngMocksResult; - } catch (e) { - class ClassEs5 extends parent {} - mockModule = ClassEs5; - } - (window as any).ngMocksParent = undefined; - - // the next step is to respect constructor parameters as the parent class. - if (mockModule) { - (mockModule as any).parameters = jitReflector - .parameters(parent) - .map(parameter => ngMocksUniverse.cacheMocks.get(parameter) || parameter); - } + mockModule = extendClass(parent); // the last thing is to apply decorators. NgModule(mockModuleDef)(mockModule as any); @@ -163,7 +142,7 @@ export function MockModule(module: any): any { } if (ngModuleProviders) { - const [changed, ngModuleDef] = MockNgModuleDef({ providers: ngModuleProviders }); + const [changed, ngModuleDef] = MockNgDef({ providers: ngModuleProviders }); mockModuleProviders = changed ? ngModuleDef.providers : ngModuleProviders; } @@ -180,7 +159,12 @@ export function MockModule(module: any): any { const NEVER_MOCK: Array> = [CommonModule, ApplicationModule]; -function MockNgModuleDef(ngModuleDef: NgModule, ngModule?: Type): [boolean, NgModule] { +/** + * Can be changed at any time. + * + * @internal + */ +export function MockNgDef(ngModuleDef: NgModule, ngModule?: Type): [boolean, NgModule] { let changed = !ngMocksUniverse.flags.has('skipMock'); const mockedModuleDef: NgModule = {}; const { diff --git a/lib/mock-service/mock-service.ts b/lib/mock-service/mock-service.ts index 8aef28ef5c..48244b5fd5 100644 --- a/lib/mock-service/mock-service.ts +++ b/lib/mock-service/mock-service.ts @@ -252,6 +252,12 @@ const mockServiceHelperPrototype = { resolveProvider: (def: any, resolutions: Map, changed?: (flag: boolean) => void) => { const provider = typeof def === 'object' && def.provide ? def.provide : def; const multi = def !== provider && !!def.multi; + + // we shouldn't touch our system providers at all. + if (typeof def === 'object' && def.useExisting && def.useExisting.__ngMocksSkip) { + return def; + } + let mockedDef: typeof def; if (resolutions.has(provider)) { mockedDef = resolutions.get(provider); diff --git a/tests/issues-172/test.spec.ts b/tests/issues-172/test.spec.ts new file mode 100644 index 0000000000..81ac0872bc --- /dev/null +++ b/tests/issues-172/test.spec.ts @@ -0,0 +1,115 @@ +import { Component, Injectable, NgModule, OnInit } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MockBuilder, MockRender } from 'ng-mocks'; + +@Injectable() +class Target1Service { + protected readonly name = 'Target1Service'; + + public echo(): string { + return this.name; + } +} + +@Injectable() +class Target2Service { + protected readonly name = 'Target2Service'; + + public echo(): string { + return this.name; + } +} + +@Component({ + providers: [Target1Service, Target2Service], + selector: 'app-target', + template: '{{echo}}', +}) +class TargetComponent implements OnInit { + public echo = ''; + + protected readonly target1Service: Target1Service; + protected readonly target2Service: Target2Service; + + constructor(target1Service: Target1Service, target2Service: Target2Service) { + this.target1Service = target1Service; + this.target2Service = target2Service; + } + + public ngOnInit(): void { + this.echo = `${this.target1Service.echo()}${this.target2Service.echo()}`; + } +} + +@NgModule({ + declarations: [TargetComponent], + exports: [TargetComponent], +}) +class TargetModule {} + +describe('issue-172:real', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [TargetModule], + }).compileComponents() + ); + + it('renders echo', () => { + const fixture = MockRender(TargetComponent); + expect(fixture.nativeElement.innerHTML).toContain('Target1ServiceTarget2Service'); + }); +}); + +describe('issue-172:test', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [TargetModule], + }).compileComponents() + ); + + it('renders echo', () => { + TestBed.overrideComponent(TargetComponent, { + add: { + providers: [ + { + provide: Target1Service, + useValue: { + echo: () => 'MockService', + }, + }, + ], + }, + remove: { + providers: [Target1Service], + }, + }); + const fixture = MockRender(TargetComponent); + expect(fixture.nativeElement.innerHTML).toContain('MockServiceTarget2Service'); + }); +}); + +describe('issue-172:mock', () => { + beforeEach(() => + MockBuilder(TargetComponent, TargetModule).mock(Target1Service, { + echo: () => 'MockService', + }) + ); + + it('renders mocked echo', () => { + const fixture = MockRender(TargetComponent); + expect(fixture.nativeElement.innerHTML).toContain('MockServiceTarget2Service'); + }); +}); + +describe('issue-172:restore', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [TargetModule], + }).compileComponents() + ); + + it('renders echo', () => { + const fixture = MockRender(TargetComponent); + expect(fixture.nativeElement.innerHTML).toContain('Target1ServiceTarget2Service'); + }); +});