Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: allows mocking providers of an kept component #190

Merged
merged 1 commit into from
Sep 27, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions lib/common/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,36 @@ export const mapEntries = <K, T>(set: Map<K, T>): Array<[K, T]> => {
return result;
};

export const extendClass = <I extends object>(base: Type<I>): Type<I> => {
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<any>, type: string): boolean =>
jitReflector.annotations(object).some(annotation => annotation.ngMetadataName === type);

Expand Down
48 changes: 45 additions & 3 deletions lib/mock-builder/mock-builder.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';

Expand Down Expand Up @@ -190,6 +191,45 @@ export class MockBuilderPromise implements PromiseLike<IMockBuilderResult> {
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<Type<any> | NgModuleWithProviders> = [];

Expand Down Expand Up @@ -422,7 +462,9 @@ export class MockBuilderPromise implements PromiseLike<IMockBuilderResult> {
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);
Expand Down
18 changes: 15 additions & 3 deletions lib/mock-component/mock-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,11 @@ export function MockComponent<TComponent>(
providers: [
{
provide: component,
useExisting: forwardRef(() => ComponentMock),
useExisting: (() => {
const value: Type<any> & { __ngMocksSkip?: boolean } = forwardRef(() => ComponentMock);
value.__ngMocksSkip = true;
return value;
})(),
},
],
selector,
Expand All @@ -124,7 +128,11 @@ export function MockComponent<TComponent>(
options.providers.push({
multi: true,
provide,
useExisting: forwardRef(() => ComponentMock),
useExisting: (() => {
const value: Type<any> & { __ngMocksSkip?: boolean } = forwardRef(() => ComponentMock);
value.__ngMocksSkip = true;
return value;
})(),
});
continue;
}
Expand All @@ -133,7 +141,11 @@ export function MockComponent<TComponent>(
options.providers.push({
multi: true,
provide,
useExisting: forwardRef(() => ComponentMock),
useExisting: (() => {
const value: Type<any> & { __ngMocksSkip?: boolean } = forwardRef(() => ComponentMock);
value.__ngMocksSkip = true;
return value;
})(),
});
continue;
}
Expand Down
18 changes: 15 additions & 3 deletions lib/mock-directive/mock-directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,11 @@ export function MockDirective<TDirective>(directive: Type<TDirective>): Type<Moc
providers: [
{
provide: directive,
useExisting: forwardRef(() => DirectiveMock),
useExisting: (() => {
const value: Type<any> & { __ngMocksSkip?: boolean } = forwardRef(() => DirectiveMock);
value.__ngMocksSkip = true;
return value;
})(),
},
],
selector,
Expand All @@ -89,7 +93,11 @@ export function MockDirective<TDirective>(directive: Type<TDirective>): Type<Moc
options.providers.push({
multi: true,
provide,
useExisting: forwardRef(() => DirectiveMock),
useExisting: (() => {
const value: Type<any> & { __ngMocksSkip?: boolean } = forwardRef(() => DirectiveMock);
value.__ngMocksSkip = true;
return value;
})(),
});
continue;
}
Expand All @@ -98,7 +106,11 @@ export function MockDirective<TDirective>(directive: Type<TDirective>): Type<Moc
options.providers.push({
multi: true,
provide,
useExisting: forwardRef(() => DirectiveMock),
useExisting: (() => {
const value: Type<any> & { __ngMocksSkip?: boolean } = forwardRef(() => DirectiveMock);
value.__ngMocksSkip = true;
return value;
})(),
});
continue;
}
Expand Down
38 changes: 11 additions & 27 deletions lib/mock-module/mock-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ApplicationModule, NgModule, Provider } from '@angular/core';
import { getTestBed } from '@angular/core/testing';

import {
extendClass,
flatten,
getMockedNgDefOf,
isNgDef,
Expand All @@ -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';
Expand Down Expand Up @@ -118,37 +119,15 @@ export function MockModule(module: any): any {
}
}

const [changed, ngModuleDef] = MockNgModuleDef(meta, ngModule);
const [changed, ngModuleDef] = MockNgDef(meta, ngModule);
if (changed) {
mockModuleDef = ngModuleDef;
}
}

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);
Expand All @@ -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;
}

Expand All @@ -180,7 +159,12 @@ export function MockModule(module: any): any {

const NEVER_MOCK: Array<Type<any>> = [CommonModule, ApplicationModule];

function MockNgModuleDef(ngModuleDef: NgModule, ngModule?: Type<any>): [boolean, NgModule] {
/**
* Can be changed at any time.
*
* @internal
*/
export function MockNgDef(ngModuleDef: NgModule, ngModule?: Type<any>): [boolean, NgModule] {
let changed = !ngMocksUniverse.flags.has('skipMock');
const mockedModuleDef: NgModule = {};
const {
Expand Down
6 changes: 6 additions & 0 deletions lib/mock-service/mock-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,12 @@ const mockServiceHelperPrototype = {
resolveProvider: (def: any, resolutions: Map<any, any>, 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);
Expand Down
115 changes: 115 additions & 0 deletions tests/issues-172/test.spec.ts
Original file line number Diff line number Diff line change
@@ -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('<app-target>Target1ServiceTarget2Service</app-target>');
});
});

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('<app-target>MockServiceTarget2Service</app-target>');
});
});

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('<app-target>MockServiceTarget2Service</app-target>');
});
});

describe('issue-172:restore', () => {
beforeEach(() =>
TestBed.configureTestingModule({
imports: [TargetModule],
}).compileComponents()
);

it('renders echo', () => {
const fixture = MockRender(TargetComponent);
expect(fixture.nativeElement.innerHTML).toContain('<app-target>Target1ServiceTarget2Service</app-target>');
});
});