Skip to content

Commit

Permalink
fix(mock-render): binds all inputs on no params #522
Browse files Browse the repository at this point in the history
  • Loading branch information
satanTime committed May 9, 2021
1 parent ae89cc9 commit dd5abba
Show file tree
Hide file tree
Showing 10 changed files with 424 additions and 13 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ describe('app-component', () => {
).and.returnValue(true);

// MockRender creates a wrapper component with
// a template like <app-root></app-root>
// a template like <app-root ...allInputs></app-root>
// and renders it.
// It helps to assert lifecycle hooks.
// https://ng-mocks.sudo.eu/api/MockRender
Expand Down Expand Up @@ -162,7 +162,7 @@ describe('app-component', () => {
// https://ng-mocks.sudo.eu/api/MockRender
const fixture = MockRender(AppComponent, params);

// the button should be disabled with params.check = false
// the button should be disabled with params.allowCheck = false
// https://ng-mocks.sudo.eu/api/ngMocks/find
expect(ngMocks.find('button.check').disabled).toEqual(true);

Expand Down
212 changes: 212 additions & 0 deletions docs/articles/api/MockRender.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,218 @@ it('two renders', () => {
});
```

## Testing ChangeDetectionStrategy.OnPush

Have you ever tried to use `TestBed.createComponent(OnPushComponent)`
with a `ChangeDetectionStrategy.OnPush` component?

Then you know its sad story, there is no rerender on inputs change.

`MockRender` covers this case, and you can check how changes of inputs and outputs
affect rendering of your components and directives.

```ts
const fixture = MockRender(OnPushComponent);

fixture.componentInstance.myInput = 5;
fixture.detectChanges();
expect(ngMocks.formatText(fixture)).toContain(':5:');

fixture.componentInstance.myInput = 6;
fixture.detectChanges();
expect(ngMocks.formatText(fixture)).toContain(':6:');
```

More details how inputs and outputs are handled by `MockRender` are described in the sections below.

## Params, Inputs and Outputs

`MockRender` accepts as the second parameter as `params` for the generated template.
The intention of the `params` is to provide flexibility and to allow control of `inputs`, `outputs` and template variables.

If a component or a directive has been passed into `MockRender`,
then `MockRender` generates a template based on its `selector`, `inputs`, `outputs` and provided `params`.

It is essential to know how `MockRender` handles `params` in order to understand which template is being generated.

### No params

If `MockRender` has been called with no `params` or `null` or `undefined` as `params`,
then it automatically binds all `inputs` and ignores all `outputs`.
Therefore, no default values will be used in the tested component, all `inputs` will receive `null`.

:::tip
Why `null`?

Because `Angular` uses `null` when optional chain has failed: `<my-comp [input]="data?.set?.value"></my-comp>`.
Despite its default value, if the chain has failed then `input` is `null`.

Being likewise, `MockRender` provides this behavior by default.
:::

For example, we have a component `MyComponent`
which has two `inputs`: `input1` and `input2`,
and has two `outputs`: `update1` and `update2`.

Then any call like

```ts
MockRender(MyComponent);
MockRender(MyComponent, null);
MockRender(MyComponent, undefined);
```

generates a template like

```html
<my-component [input1]="input1" [input2]="input2"></my-component>
```

where `input1` and `input2` are properties of the wrapper component and equal to `null`.

```ts
expect(fixture.componentInstance.input1).toEqual(null);
expect(fixture.componentInstance.input2).toEqual(null);

expect(fixture.point.componentInstance.input1).toEqual(null);
expect(fixture.point.componentInstance.input1).toEqual(null);
```

If we change props of `fixture.componentInstance`, then, after `fixture.detectChanges()`,
the tested component will receive updated values.

```ts
expect(fixture.componentInstance.input1).toEqual(null);
expect(fixture.point.componentInstance.input1).toEqual(null);

fixture.componentInstance.input1 = 1;
// still old value
expect(fixture.point.componentInstance.input1).toEqual(null);

fixture.detectChanges();
// now it works
expect(fixture.point.componentInstance.input1).toEqual(1);
```

Please proceed to the next section, if you want to use / test default values.

### Empty params

In order to test default values, we can provide an empty object as `params`.
In this case, `MockRender` handles `inputs` and `outputs` only if they have been set in the provided objects.

For example, we have a component `MyComponent`
which has two `inputs`: `input1` and `input2`,
and has two `outputs`: `update1` and `update2`.

Then a call like

```ts
MockRender(MyComponent, {});
```

generates a template like

```html
<my-component></my-component>
```

If we access the `inputs`, then we will get their default values:
```ts
expect(fixture.point.componentInstance.input1).toEqual('default1');
expect(fixture.point.componentInstance.input1).toEqual('default2');
```

The wrapper component is useless in this case,
and changes should be done on the instance of the tested component (`point`).

### Provided params

`MockRender` tries to generate a template for a wrapper component, based on provided `params`.
Only `params` which have the same name as `inputs` and `outputs` affect the template.

#### Inputs

It is quite simple in case of `inputs`, `MockRender` simply generates `[propName]="propName"`.

For example, we have a component `MyComponent`
which has three `inputs`: `input1`, `input2` and `input3`,

Then a call like

```ts
const params = {input1: 1, input2: 2};
const fixture = MockRender(MyComponent, params);
```

generates a template like

```html
<my-component [input1]="input1" [input2]="input2"></my-component>
```

where `input1` and `input2` belong to the passed object and any change in the object will affect values in the template,
and `input3` is ignored and will have its default value.

```ts
expect(fixture.point.componentInstance.input1).toEqual(1);

params.input1 = 3;
fixture.detectChanges();
expect(fixture.point.componentInstance.input1).toEqual(3);
```

#### Outputs

The story differs a bit with `outputs`. `MockRender` detects types of properties and generates different pieces in templates.

Currently, `MockRender` handles the next types:

- functions
- event emitters
- subjects
- literals

For example, we have a component `MyComponent`
which has four `outputs`: `o1`, `o2`, `o3` and `o4`,

Then a call like

```ts
const params = {
o1: undefined,
o2: jasmine.createSpy('o2'),
o3: new EventEmitter(),
o4: new Subject(),
};
const fixture = MockRender(MyComponent, params);
```

generates a template like

```html
<my-component
(o1)="o1=$event"
(o2)="o2($event)"
(o3)="o3.emit($event)"
(o4)="o4.next($event)"
></my-component>
```

Any emit on the `outputs` will trigger the related action:

```ts
expect(params.o1).toEqual(undefined);
expect(params.o2).not.toHaveBeenCalled();

fixture.point.componentInstance.o1.emit(1);
fixture.point.componentInstance.o2.emit(2);

expect(params.o1).toEqual(1);
expect(params.o2).toHaveBeenCalledWith(2);
```


## Example with a component

```ts
Expand Down
3 changes: 2 additions & 1 deletion libs/ng-mocks/src/lib/mock-helper/cva/mock-helper.change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { DebugElement } from '@angular/core';
import coreForm from '../../common/core.form';
import { DebugNodeSelector } from '../../common/core.types';
import { isMockControlValueAccessor } from '../../common/func.is-mock-control-value-accessor';
import helperDefinePropertyDescriptor from '../../mock-service/helper.define-property-descriptor';
import mockHelperTrigger from '../events/mock-helper.trigger';
import mockHelperFind from '../find/mock-helper.find';
import funcGetLastFixture from '../func.get-last-fixture';
Expand All @@ -20,7 +21,7 @@ const triggerInput = (el: DebugElement, value: any): void => {
mockHelperTrigger(el, 'input');
mockHelperTrigger(el, 'change');
if (descriptor) {
Object.defineProperty(el.nativeElement, 'value', descriptor);
helperDefinePropertyDescriptor(el.nativeElement, 'value', descriptor);
el.nativeElement.value = value;
}

Expand Down
7 changes: 6 additions & 1 deletion libs/ng-mocks/src/lib/mock-helper/mock-helper.faster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,13 @@ const resetFixtures = (stack: NgMocksStack) => {
}
};

let needInstall = true;
export default () => {
ngMocksStack.install();
// istanbul ignore next
if (needInstall) {
ngMocksStack.install();
needInstall = false;
}

beforeAll(() => {
if (ngMocksUniverse.global.has('bullet:customized')) {
Expand Down
13 changes: 11 additions & 2 deletions libs/ng-mocks/src/lib/mock-instance/mock-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,13 @@ ngMocksStack.subscribePop(() => {
}
});

let needInstall = true;
const restore = (declaration: any, config: any): void => {
ngMocksStack.install();
// istanbul ignore next
if (needInstall) {
ngMocksStack.install();
needInstall = false;
}
ngMocksUniverse.getLocalMocks().push([declaration, config]);
};

Expand Down Expand Up @@ -89,7 +94,11 @@ const mockInstanceMember = <T>(
stub: any,
encapsulation?: 'get' | 'set',
) => {
ngMocksStack.install();
// istanbul ignore next
if (needInstall) {
ngMocksStack.install();
needInstall = false;
}
const config = ngMocksUniverse.configInstance.has(declaration) ? ngMocksUniverse.configInstance.get(declaration) : {};
const overloads = config.overloads || [];
overloads.push([name, stub, encapsulation]);
Expand Down
5 changes: 3 additions & 2 deletions libs/ng-mocks/src/lib/mock-render/func.generate-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ const generateTemplateAttrWithParams = (params: any, prop: string, type: 'i' | '
` ${generateTemplateAttrWrap(prop, type)}="${prop}${type === 'o' ? solveOutput(params[prop]) : ''}"`;

const generateTemplateAttr = (params: any, attr: any, type: 'i' | 'o') => {
if (!params) {
// unprovided params for inputs should render empty placeholders
if (!params && type === 'o') {
return '';
}

let mockTemplate = '';
const keys = Object.getOwnPropertyNames(params);
const keys = params ? Object.getOwnPropertyNames(params) : attr;
for (const definition of attr) {
const [property, alias] = definition.split(': ');
mockTemplate +=
Expand Down
16 changes: 12 additions & 4 deletions libs/ng-mocks/src/lib/mock-render/mock-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,26 @@ import funcImportExists from '../common/func.import-exists';
import { isNgDef } from '../common/func.is-ng-def';
import ngMocksUniverse from '../common/ng-mocks-universe';
import { ngMocks } from '../mock-helper/mock-helper';
import helperDefinePropertyDescriptor from '../mock-service/helper.define-property-descriptor';
import { MockService } from '../mock-service/mock-service';

import funcGenerateTemplate from './func.generate-template';
import funcInstallPropReader from './func.install-prop-reader';
import funcReflectTemplate from './func.reflect-template';
import { IMockRenderOptions, MockedComponentFixture } from './types';

const generateFixture = ({ params, options }: any) => {
const generateFixture = ({ params, options, inputs }: any) => {
class MockRenderComponent {
public constructor() {
if (!params) {
for (const input of inputs || []) {
let value: any = null;
helperDefinePropertyDescriptor(this, input, {
get: () => value,
set: (newValue: any) => (value = newValue),
});
}
}
funcInstallPropReader(this, params);
}
}
Expand Down Expand Up @@ -53,9 +63,7 @@ const isExpectedRender = (template: any): boolean =>
const renderDeclaration = (fixture: any, template: any, params: any): void => {
fixture.point = fixture.debugElement.children[0] || fixture.debugElement.childNodes[0];
if (isNgDef(template, 'd')) {
Object.defineProperty(fixture.point, 'componentInstance', {
configurable: true,
enumerable: true,
helperDefinePropertyDescriptor(fixture.point, 'componentInstance', {
get: () => ngMocks.get(fixture.point, template),
});
}
Expand Down
2 changes: 1 addition & 1 deletion tests/issue-434/test.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe('issue-434', () => {
beforeEach(() => MockBuilder(TargetComponent));

it('keeps the default prop value on no props', () => {
const fixture = MockRender(TargetComponent);
const fixture = MockRender(TargetComponent, {});
expect(ngMocks.formatText(fixture)).toEqual('default1:default2');
});

Expand Down
Loading

0 comments on commit dd5abba

Please sign in to comment.