Skip to content

Commit

Permalink
feat: mock-render tries to mirror passed component
Browse files Browse the repository at this point in the history
closes #137
  • Loading branch information
satanTime committed Jun 10, 2020
1 parent cb79c50 commit 66c5782
Show file tree
Hide file tree
Showing 6 changed files with 202 additions and 14 deletions.
8 changes: 6 additions & 2 deletions lib/mock-helper/mock-helper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,19 @@ describe('MockHelper:getDirective', () => {
const componentA = ngMocks.find(fixture.debugElement, AComponent);
expect(componentA.componentInstance).toEqual(jasmine.any(AComponent));

expect(() => ngMocks.find(componentA, BComponent)).toThrowError('Cannot find an element via ngMocks.find');
expect(() => ngMocks.find(componentA, BComponent)).toThrowError(
'Cannot find an element via ngMocks.find(BComponent)'
);
});

it('find selector: string', () => {
const fixture = MockRender(`<component-b></component-b>`);
const componentB = ngMocks.find(fixture.debugElement, 'component-b');
expect(componentB.componentInstance).toEqual(jasmine.any(BComponent));

expect(() => ngMocks.find(componentB, AComponent)).toThrowError('Cannot find an element via ngMocks.find');
expect(() => ngMocks.find(componentB, AComponent)).toThrowError(
'Cannot find an element via ngMocks.find(AComponent)'
);
});

it('find selector: T', () => {
Expand Down
4 changes: 2 additions & 2 deletions lib/mock-helper/mock-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export const ngMocks: {
} = {
find: (...args: any[]) => {
const el: MockedDebugElement = args[0];
const sel: any = args[1];
const sel: string | Type<any> = args[1];
const notFoundValue: any = args.length === 3 ? args[2] : defaultNotFoundValue;

const term = typeof sel === 'string' ? By.css(sel) : By.directive(getSourceOfMock(sel));
Expand All @@ -136,7 +136,7 @@ export const ngMocks: {
return notFoundValue;
}
if (!result) {
throw new Error(`Cannot find an element via ngMocks.find`);
throw new Error(`Cannot find an element via ngMocks.find(${typeof sel === 'string' ? sel : sel.name})`);
}
},

Expand Down
2 changes: 1 addition & 1 deletion lib/mock-render/mock-render.fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Component, EventEmitter, Inject, Input, Output } from '@angular/core';

@Component({
selector: 'render-real-component',
template: '<span (click)="click.emit($event)">{{ content }}</span>',
template: '<span (click)="click.emit(true)">{{ content }}</span>',
})
export class RenderRealComponent {
@Output() click = new EventEmitter<{}>();
Expand Down
29 changes: 25 additions & 4 deletions lib/mock-render/mock-render.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import createSpy = jasmine.createSpy;
import { DOCUMENT } from '@angular/common';
import { DebugElement, DebugNode, EventEmitter } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { first } from 'rxjs/operators';

import { ngMocks } from '../mock-helper';
import { MockService } from '../mock-service';

import { MockedComponentFixture, MockedDebugElement, MockedDebugNode, MockRender } from './mock-render';
import { RenderRealComponent, WithoutSelectorComponent } from './mock-render.fixtures';
import { DebugElement, DebugNode } from '@angular/core';

describe('MockRender', () => {
beforeEach(() => {
Expand All @@ -17,7 +18,7 @@ describe('MockRender', () => {
});

it('renders any template and respects dynamic params', () => {
const spy = createSpy('mockClick');
const spy = jasmine.createSpy('mockClick');
const assertPayload = {
magic: Math.random(),
};
Expand Down Expand Up @@ -69,7 +70,7 @@ describe('MockRender', () => {
});

it('binds inputs and outputs with a provided component', () => {
const spy = createSpy('click');
const spy = jasmine.createSpy('click');
const fixture = MockRender(RenderRealComponent, {
click: spy,
content: 'content',
Expand Down Expand Up @@ -119,6 +120,26 @@ describe('MockRender', () => {
expect(fixture.debugElement.nativeElement.innerHTML).toEqual('');
});

it('assigns outputs to a literals', () => {
const fixture = MockRender(RenderRealComponent, {
click: undefined,
});

ngMocks.find(fixture.debugElement, 'span').triggerEventHandler('click', null);
expect(fixture.componentInstance.click as any).toEqual(true);
});

it('assigns outputs to an EventEmitter', () => {
const fixture = MockRender(RenderRealComponent, {
click: new EventEmitter(),
});

let actual: any;
fixture.componentInstance.click.pipe(first()).subscribe(value => (actual = value));
ngMocks.find(fixture.debugElement, 'span').triggerEventHandler('click', null);
expect(actual).toEqual(true);
});

it('assigns DebugNodes and DebugElements to Mocks and back', () => {
const debugNode = ({} as any) as DebugNode;
const debugElement = ({} as any) as DebugElement;
Expand Down
90 changes: 85 additions & 5 deletions lib/mock-render/mock-render.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { core } from '@angular/compiler';
import { Component, DebugElement, DebugNode, Provider } from '@angular/core';
import { Component, DebugElement, DebugNode, EventEmitter, Provider } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Subject } from 'rxjs';

import { Type } from '../common';
import { directiveResolver } from '../common/reflect';
import { ngMocks } from '../mock-helper';
import { mockServiceHelper } from '../mock-service';

// tslint:disable-next-line:interface-name
export interface MockedDebugNode<T = any> extends DebugNode {
Expand All @@ -26,14 +28,34 @@ export interface MockedComponentFixture<C = any, F = undefined> extends Componen
point: MockedDebugElement<C>;
}

// tslint:disable-next-line:interface-name
export type DefaultRenderComponent<MComponent extends object> = {
-readonly [K in keyof MComponent]: MComponent[K];
};

function solveOutput(output: any): string {
if (typeof output === 'function') {
return '($event)';
}
if (output && typeof output === 'object' && output instanceof EventEmitter) {
return '.emit($event)';
}
if (output && typeof output === 'object' && output instanceof Subject) {
return '.next($event)';
}
return '=$event';
}

function MockRender<MComponent, TComponent extends { [key: string]: any }>(
template: Type<MComponent>,
params: TComponent,
detectChanges?: boolean | IMockRenderOptions
): MockedComponentFixture<MComponent, TComponent>;

// without params we shouldn't autocomplete any keys of any types.
function MockRender<MComponent>(template: Type<MComponent>): MockedComponentFixture<MComponent>;
function MockRender<MComponent extends object>(
template: Type<MComponent>
): MockedComponentFixture<MComponent, DefaultRenderComponent<MComponent>>;

function MockRender<MComponent = any, TComponent extends { [key: string]: any } = { [key: string]: any }>(
template: string,
Expand All @@ -50,7 +72,12 @@ function MockRender<MComponent, TComponent extends { [key: string]: any }>(
flags: boolean | IMockRenderOptions = true
): MockedComponentFixture<MComponent, TComponent> {
const flagsObject: IMockRenderOptions = typeof flags === 'boolean' ? { detectChanges: flags } : flags;
const isComponent = typeof template !== 'string';
const noParams = !params;

let inputs: string[] | undefined = [];
let outputs: string[] | undefined = [];
let selector: string | undefined = '';
let mockedTemplate = '';
if (typeof template === 'string') {
mockedTemplate = template;
Expand All @@ -64,7 +91,10 @@ function MockRender<MComponent, TComponent extends { [key: string]: any }>(
}
}

const { inputs, outputs, selector } = meta;
inputs = meta.inputs;
outputs = meta.outputs;
selector = meta.selector;

mockedTemplate += selector ? `<${selector}` : '';
if (selector && inputs) {
inputs.forEach((definition: string) => {
Expand All @@ -73,16 +103,24 @@ function MockRender<MComponent, TComponent extends { [key: string]: any }>(
mockedTemplate += ` [${alias}]="${alias}"`;
} else if (property && params && typeof params[property]) {
mockedTemplate += ` [${property}]="${property}"`;
} else if (alias && noParams) {
mockedTemplate += ` [${alias}]="${property}"`;
} else if (noParams) {
mockedTemplate += ` [${property}]="${property}"`;
}
});
}
if (selector && outputs) {
outputs.forEach((definition: string) => {
const [property, alias] = definition.split(': ');
if (alias && params && typeof params[alias]) {
mockedTemplate += ` (${alias})="${alias}($event)"`;
mockedTemplate += ` (${alias})="${alias}${solveOutput(params[alias])}"`;
} else if (property && params && typeof params[property]) {
mockedTemplate += ` (${property})="${property}($event)"`;
mockedTemplate += ` (${property})="${property}${solveOutput(params[property])}"`;
} else if (alias && noParams) {
mockedTemplate += ` (${alias})="${property}.emit($event)"`;
} else if (noParams) {
mockedTemplate += ` (${property})="${property}.emit($event)"`;
}
});
}
Expand All @@ -98,6 +136,18 @@ function MockRender<MComponent, TComponent extends { [key: string]: any }>(
class MockRenderComponent {
constructor() {
Object.assign(this, params);
if (noParams && isComponent && inputs) {
for (const definition of inputs) {
const [property] = definition.split(': ');
(this as any)[property] = undefined;
}
}
if (noParams && isComponent && outputs) {
for (const definition of outputs) {
const [property] = definition.split(': ');
(this as any)[property] = new EventEmitter();
}
}
}
} as Type<TComponent>
);
Expand All @@ -117,6 +167,36 @@ function MockRender<MComponent, TComponent extends { [key: string]: any }>(
}

fixture.point = fixture.debugElement.children[0];
if (noParams && typeof template === 'function') {
const properties = mockServiceHelper.extractPropertiesFromPrototype(template.prototype);
const exists = Object.getOwnPropertyNames(fixture.componentInstance);
for (const property of properties) {
if (exists.indexOf(property) !== -1) {
continue;
}
Object.defineProperty(fixture.componentInstance, property, {
get: () => fixture.point.componentInstance[property],
set: (v: any) => (fixture.point.componentInstance[property] = v),

configurable: true,
enumerable: true,
});
}
const methods = mockServiceHelper.extractMethodsFromPrototype(template.prototype);
for (const method of methods) {
if (exists.indexOf(method) !== -1) {
continue;
}
Object.defineProperty(fixture.componentInstance, method, {
value: (...args: any[]) => fixture.point.componentInstance[method](...args),

configurable: true,
enumerable: true,
writable: true,
});
}
}

return fixture;
}

Expand Down
83 changes: 83 additions & 0 deletions tests/mock-render-mirrors-component/test.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Component, EventEmitter, Input, NgModule, Output } from '@angular/core';
import { MockBuilder, MockRender, ngMocks } from 'ng-mocks';
import { first } from 'rxjs/operators';

@Component({
selector: 'target',
template: `
<div data-role="input1">{{ input1 || 'input1' }}</div>
<div data-role="input2">{{ input2 || 'input2' }}</div>
<div data-role="output1" (click)="output1.emit()">output1</div>
<div data-role="output2" (click)="output2.emit()">output2</div>
<div data-role="var1">{{ var1 || 'var1' }}</div>
<div data-role="var2">{{ var2 || 'var2' }}</div>
`,
})
export class TargetComponent {
@Input() public readonly input1: string | null = null;
@Input('input') public readonly input2: string | null = null;

@Output() public readonly output1: EventEmitter<void> = new EventEmitter();
@Output('output') public readonly output2: EventEmitter<void> = new EventEmitter();

public var1 = '';
public var2 = '';

public test(var2: string): void {
this.var2 = var2;
}
}

@NgModule({
declarations: [TargetComponent],
})
export class TargetModule {}

describe('mock-render-mirrors-component', () => {
beforeEach(() => MockBuilder(TargetComponent, TargetModule));

it('mirrors the desired component if no params were passed', () => {
const fixture = MockRender(TargetComponent);

const input1 = ngMocks.find(fixture.debugElement, '[data-role="input1"]');
const input2 = ngMocks.find(fixture.debugElement, '[data-role="input2"]');
const output1 = ngMocks.find(fixture.debugElement, '[data-role="output1"]');
const output2 = ngMocks.find(fixture.debugElement, '[data-role="output2"]');
const var1 = ngMocks.find(fixture.debugElement, '[data-role="var1"]');
const var2 = ngMocks.find(fixture.debugElement, '[data-role="var2"]');

// initial state
expect(input1.nativeElement.innerHTML).toEqual('input1');
expect(input2.nativeElement.innerHTML).toEqual('input2');
expect(output1.nativeElement.innerHTML).toEqual('output1');
expect(output2.nativeElement.innerHTML).toEqual('output2');
expect(var1.nativeElement.innerHTML).toEqual('var1');
expect(var2.nativeElement.innerHTML).toEqual('var2');

// updating inputs and properties, calling methods.
fixture.componentInstance.input1 = 'updatedInput1';
fixture.componentInstance.input2 = 'updatedInput2';
fixture.componentInstance.var1 = 'updatedVar1';
fixture.componentInstance.test('updatedVar2');
fixture.detectChanges();

// checking that the data has been proxied correctly
expect(input1.nativeElement.innerHTML).toEqual('updatedInput1');
expect(input2.nativeElement.innerHTML).toEqual('updatedInput2');
// doesn't work because we can't correctly detect it via defineProperty.
// expect(var1.nativeElement.innerHTML).toEqual('updatedVar1');
expect(var2.nativeElement.innerHTML).toEqual('updatedVar2');

// checking output1
let updatedOutput1 = false;
fixture.componentInstance.output1.pipe(first()).subscribe(() => (updatedOutput1 = true));
output1.triggerEventHandler('click', null);
expect(updatedOutput1).toBe(true);

// checking output2
let updatedOutput2 = false;
fixture.componentInstance.output2.pipe(first()).subscribe(() => (updatedOutput2 = true));
output2.triggerEventHandler('click', null);
expect(updatedOutput2).toBe(true);
});
});

0 comments on commit 66c5782

Please sign in to comment.