Skip to content

Commit

Permalink
fix(core): properly handling Sanitizer and DomSanitizer #538
Browse files Browse the repository at this point in the history
  • Loading branch information
satanTime committed May 13, 2021
1 parent 8195eeb commit fb51bb4
Show file tree
Hide file tree
Showing 8 changed files with 237 additions and 5 deletions.
4 changes: 2 additions & 2 deletions README.md
Expand Up @@ -90,11 +90,11 @@ describe('app-component', () => {
// mock declarations and providers.
beforeEach(() => {
// A spy on a method which returns children.
// When an instance of ChildCompnent is being created,
// When an instance of ChildComponent is being created,
// the method will be replaced with the spy.
// https://ng-mocks.sudo.eu/api/MockInstance
MockInstance(
ChildCompnent,
ChildComponent,
'list',
jasmine.createSpy(),
).and.returnValue([]);
Expand Down
63 changes: 63 additions & 0 deletions docs/articles/extra/sanitizer.md
@@ -0,0 +1,63 @@
---
title: Mocking DomSanitizer
description: Information how to test usage of DomSanitizer in Angular
sidebar_label: Mocking DomSanitizer
---

This article explains how to mock `DomSanitizer` in Angular tests properly.

The biggest issue is that `DomSanitizer` are used internally by Angular.
Therefore, mocking them can cause unpredictable errors such as:

- TypeError: view.root.sanitizer.sanitize is not a function
- TypeError: _co.domSanitizer.bypassSecurityTrustHtml is not a function

Another problem is that both of the class is abstract and there is no way to detect their abstract methods in javascript runtime
in order to provide mock functions or spies instead.

However, `ng-mocks` contains [`MockRender`](../api/MockRender.md) which supports additional providers for rendered things.
Therefore, if we use [`MockRender`](../api/MockRender.md) and [`MockProvider`](../api/MockProvider.md), we can achieve desired environment and behavior:

```ts
// rendering TargetComponent component
MockRender(TargetComponent, null, {
// providing special overrides for TargetComponent
providers: [
// Mocking DomSanitizer with a fake method
MockProvider(DomSanitizer, {
// the override should be provided explicitly
// because sanitize method is abstract
sanitize: jasmine.createSpy('sanitize'),
}),
],
});
```

Profit.

## Full example

A full example of **mocking DomSanitizer** in Angular tests.

- [Try it on StackBlitz](https://stackblitz.com/github/ng-mocks/examples?file=src/examples/MockSanitizer/test.spec.ts&initialpath=%3Fspec%3DMockSanitizer)
- [Try it on CodeSandbox](https://codesandbox.io/s/github/ng-mocks/examples?file=/src/examples/MockSanitizer/test.spec.ts&initialpath=%3Fspec%3DMockSanitizer)

```ts
describe('MockSanitizer', () => {
beforeEach(() => MockBuilder(TargetComponent));

it('renders expected mock values', () => {
MockRender(TargetComponent, null, {
providers: [
MockProvider(DomSanitizer, {
sanitize: (context: SecurityContext, value: string) =>
`sanitized:${context}:${value.length}`,
}),
],
});

expect(ngMocks.formatHtml(ngMocks.find('div')))
.toEqual('sanitized:1:23');
});
});
```
27 changes: 26 additions & 1 deletion docs/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion docs/sidebars.js
Expand Up @@ -87,7 +87,13 @@ module.exports = {
{
type: 'category',
label: 'Extra',
items: ['extra/customize-mocks', 'extra/mock-observables', 'extra/mock-form-controls', 'extra/with-3rd-party'],
items: [
'extra/customize-mocks',
'extra/mock-observables',
'extra/mock-form-controls',
'extra/sanitizer',
'extra/with-3rd-party',
],
},
{
type: 'category',
Expand Down
39 changes: 39 additions & 0 deletions examples/MockSanitizer/test.spec.ts
@@ -0,0 +1,39 @@
import { Component, SecurityContext } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import {
MockBuilder,
MockProvider,
MockRender,
ngMocks,
} from 'ng-mocks';

@Component({
selector: 'target',
template: `
<div
[innerHTML]="sanitizer.sanitize(1, '<strong>value1</strong>')"
></div>
`,
})
class TargetComponent {
public constructor(public readonly sanitizer: DomSanitizer) {}
}

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

it('renders expected mock values', () => {
MockRender(TargetComponent, null, {
providers: [
MockProvider(DomSanitizer, {
sanitize: (context: SecurityContext, value: string) =>
`sanitized:${context}:${value.length}`,
}),
],
});

expect(ngMocks.formatHtml(ngMocks.find('div'))).toEqual(
'sanitized:1:23',
);
});
});
4 changes: 4 additions & 0 deletions libs/ng-mocks/src/lib/common/core.config.ts
Expand Up @@ -9,6 +9,10 @@ export default {
'EventManager',
'Injector', // ivy only
'RendererFactory2',

// https://github.com/ike18t/ng-mocks/issues/538
'Sanitizer',
'DomSanitizer',
],
neverMockToken: [
'InjectionToken Set Injector scope.', // INJECTOR_SCOPE // ivy only
Expand Down
@@ -1,8 +1,32 @@
const sanitizerMethods = [
'sanitize',
'bypassSecurityTrustHtml',
'bypassSecurityTrustStyle',
'bypassSecurityTrustScript',
'bypassSecurityTrustUrl',
'bypassSecurityTrustResourceUrl',
];

const extraMethods: Record<string, undefined | string[]> = {
DomSanitizer: sanitizerMethods,
Sanitizer: sanitizerMethods,
};

const getOwnPropertyNames = (prototype: any): string[] => {
const result: string[] = Object.getOwnPropertyNames(prototype);
for (const method of extraMethods[prototype.constructor.name] ?? []) {
result.push(method);
}

return result;
};

export default <T>(service: T): string[] => {
const result: string[] = [];

let prototype = service;
while (prototype && Object.getPrototypeOf(prototype) !== null) {
for (const method of Object.getOwnPropertyNames(prototype)) {
for (const method of getOwnPropertyNames(prototype)) {
if ((method as any) === 'constructor') {
continue;
}
Expand Down
71 changes: 71 additions & 0 deletions tests/issue-538/test.spec.ts
@@ -0,0 +1,71 @@
import { Component } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import {
MockBuilder,
MockProvider,
MockRender,
ngMocks,
} from 'ng-mocks';

@Component({
selector: 'target',
template: `
<div
[innerHTML]="
domSanitizer.bypassSecurityTrustHtml(
'<strong>value1</strong>'
)
"
></div>
`,
})
class TargetComponent {
public constructor(public readonly domSanitizer: DomSanitizer) {}
}

// TypeError: view.root.sanitizer.sanitize is not a function
describe('issue-538', () => {
describe('keep', () => {
beforeEach(() => MockBuilder(TargetComponent));

it('renders expected real values', () => {
MockRender(TargetComponent);

expect(ngMocks.formatHtml(ngMocks.find('div'))).toEqual(
'<strong>value1</strong>',
);
});
});

describe('mock', () => {
beforeEach(() =>
MockBuilder(TargetComponent).mock(DomSanitizer, {
bypassSecurityTrustHtml: (value: string) => `${value.length}`,
sanitize: (_: any, value: string) => `${value}`,
}),
);

it('renders mess due to internal injections', () => {
MockRender(TargetComponent);

expect(ngMocks.formatHtml(ngMocks.find('div'))).toEqual('23');
});
});

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

it('renders expected mock values', () => {
MockRender(TargetComponent, null, {
providers: [
MockProvider(DomSanitizer, {
bypassSecurityTrustHtml: (value: string) =>
`${value.length}`,
}),
],
});

expect(ngMocks.formatHtml(ngMocks.find('div'))).toEqual('23');
});
});
});

0 comments on commit fb51bb4

Please sign in to comment.