Skip to content

Commit

Permalink
Merge pull request #4743 from satanTime/issues/4693
Browse files Browse the repository at this point in the history
feat(core): ViewContainerRef.createComponent respects mocks #4742
  • Loading branch information
satanTime committed Jan 21, 2023
2 parents 2abd719 + bd93b7b commit 91c24e7
Show file tree
Hide file tree
Showing 5 changed files with 194 additions and 21 deletions.
89 changes: 89 additions & 0 deletions docs/articles/guides/mock/dynamic-components.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
title: How to mock a dynamic component
description: Mocking an Angular dynamic component
sidebar_label: Dynamic Components
---

Angular has introduced a way how to render components dynamically.
Now, it can be done via `ViewContainerRef.createComponent(DynamicComponent)`,
and components, which are rendering dynamic components, usually, look like:

```ts
@Component({
standalone: true,
selector: 'main',
template: '',
})
export class MainComponent implements OnInit {
// ViewContainerRef is needed to manager rendering
constructor(public readonly containerRef: ViewContainerRef) {}

async ngOnInit() {
// loading DynamicComponent
const { DynamicComponent } = await import('./dynamic.component');

// rendering DynamicComponent
this.containerRef.createComponent(DynamicComponent);
}
}
```

In unit tests, developers might need to mock `DynamicComponent` to relieve testing.
Their goal is to assert that `MainComponent` has rendered `DynamicComponent` under defined circumstances
and suppress what `DynamicComponent` does under the hood.

This can be achieved with help of `ng-mocks` and [`MockBuilder`](../../api/MockBuilder.md),
simply pass `DynamicComponent` as mock dependency:

```ts
beforeEach(() => MockBuilder(MainComponent, DynamicComponent));
```

In this case, `ng-mocks` will mock `DynamicComponent` and render its stub.

:::tip
`ng-mocks` intercepts the call of `ViewContainerRef.createComponent()`, not `import()`.
:::


## An example how to mock dynamic components

```ts
import { Component, OnInit, ViewContainerRef } from '@angular/core';
import { isMockOf, MockBuilder, MockRender, ngMocks } from 'ng-mocks';

import { DynamicComponent } from './dynamic.component';

@Component({
standalone: true,
selector: 'main',
template: '',
})
class MainComponent implements OnInit {
// ViewContainerRef is needed to manager rendering
constructor(public readonly containerRef: ViewContainerRef) {}

async ngOnInit() {
// loading DynamicComponent
const { DynamicComponent } = await import('./dynamic.component');

// rendering DynamicComponent
this.containerRef.createComponent(DynamicComponent);
}
}

describe('suite', () => {
beforeEach(() => MockBuilder(MainComponent, DynamicComponent));

it('loads lazy component as a mock', async () => {
// loading the MainComponent and waiting for its initialization
const fixture = MockRender(MainComponent);
await fixture.whenStable();

// asserting that DynamicComponent has been rendered
const el = ngMocks.find(DynamicComponent, undefined);
expect(el).toBeDefined();
});
});

```
6 changes: 5 additions & 1 deletion docs/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,11 @@ module.exports = {
type: 'category',
label: 'How to mock',
collapsed: false,
items: ['guides/mock/directive-structural-let-of', 'guides/mock/activated-route'],
items: [
'guides/mock/directive-structural-let-of',
'guides/mock/activated-route',
'guides/mock/dynamic-components',
],
},
{
type: 'category',
Expand Down
64 changes: 44 additions & 20 deletions libs/ng-mocks/src/lib/common/ng-mocks-global-overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,48 @@ const resetTestingModule =
return original.call(instance);
};

// Monkey-patching ViewContainerRef.createComponent to replace dynamic imports with mocked declarations.
const patchVcrInstance = (vcrInstance: ViewContainerRef) => {
if (!(ViewContainerRef as any).ngMocksOverridesPatched) {
coreDefineProperty(ViewContainerRef, 'ngMocksOverridesPatched', true);

// istanbul ignore else
if (vcrInstance.createComponent) {
const createComponent = vcrInstance.createComponent;
const patchedCreateComponent = helperCreateClone(
createComponent,
undefined,
undefined,
function (component: any, ...createComponentArgs: any[]) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const thisVrc: ViewContainerRef = this;
const map = coreInjector(NG_MOCKS, thisVrc.injector);

return createComponent.apply(thisVrc, [map?.get(component) ?? component, ...createComponentArgs] as any);
},
);

coreDefineProperty(vcrInstance.constructor.prototype, 'createComponent', patchedCreateComponent, true);
coreDefineProperty(vcrInstance, 'createComponent', patchedCreateComponent, true);
}
}
};

const createComponent =
(original: TestBedStatic['createComponent'], instance: TestBedStatic): TestBedStatic['createComponent'] =>
component => {
const fixture = original.call(instance, component);
try {
const vcr = fixture.debugElement.injector.get(ViewContainerRef);
patchVcrInstance(vcr);
} catch {
// nothing to do
}

return fixture as never;
};

const viewContainerInstall = () => {
const vcr: any = ViewContainerRef;

Expand All @@ -289,32 +331,14 @@ const viewContainerInstall = () => {
'__NG_ELEMENT_ID__',
helperCreateClone(ngElementId, undefined, undefined, (...ngElementIdArgs: any[]) => {
const vcrInstance = ngElementId.apply(ngElementId, ngElementIdArgs);

const createComponent = vcrInstance.createComponent;
coreDefineProperty(
vcrInstance,
'createComponent',
helperCreateClone(
createComponent,
undefined,
undefined,
(component: any, ...createComponentArgs: any[]) => {
const map = coreInjector(NG_MOCKS, vcrInstance.injector);

return createComponent.apply(vcrInstance, [
map?.get(component) ?? component,
...createComponentArgs,
] as any);
},
),
true,
);
patchVcrInstance(vcrInstance);

return vcrInstance;
}),
true,
);
}
coreDefineProperty(TestBed, 'createComponent', createComponent(TestBed.createComponent as never, TestBed as never));

coreDefineProperty(ViewContainerRef, 'ngMocksOverridesInstalled', true);
}
Expand Down
8 changes: 8 additions & 0 deletions tests-e2e/src/issue-4693/child.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Component } from '@angular/core';

@Component({
standalone: true,
selector: 'child',
template: 'child',
})
export class ChildComponent {}
48 changes: 48 additions & 0 deletions tests-e2e/src/issue-4693/test.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Component, OnInit, ViewContainerRef } from '@angular/core';
import { isMockOf, MockBuilder, MockRender, ngMocks } from 'ng-mocks';

import { ChildComponent } from './child.component';

@Component({
standalone: true,
selector: 'target',
template: '',
})
class TargetComponent implements OnInit {
constructor(public readonly containerRef: ViewContainerRef) {}

async ngOnInit() {
const { ChildComponent } = await import('./child.component');
this.containerRef.createComponent(ChildComponent);
}
}

describe('issue-4693', () => {
describe('real', () => {
beforeEach(() => MockBuilder(TargetComponent));

it('loads lazy component', async () => {
const fixture = MockRender(TargetComponent);
await fixture.whenStable();
const el = ngMocks.find(ChildComponent);
expect(ngMocks.formatText(el)).toEqual('child');
expect(isMockOf(el.componentInstance, ChildComponent)).toEqual(
false,
);
});
});

describe('mock', () => {
beforeEach(() => MockBuilder(TargetComponent, ChildComponent));

it('loads lazy component as a mock', async () => {
const fixture = MockRender(TargetComponent);
await fixture.whenStable();
const el = ngMocks.find(ChildComponent);
expect(ngMocks.formatText(el)).toEqual('');
expect(isMockOf(el.componentInstance, ChildComponent)).toEqual(
true,
);
});
});
});

0 comments on commit 91c24e7

Please sign in to comment.