Skip to content

Commit

Permalink
fix(MockService): respects prototypes of customizations #5989
Browse files Browse the repository at this point in the history
  • Loading branch information
satanTime committed Jun 10, 2023
1 parent 5ddf52f commit e9945fe
Show file tree
Hide file tree
Showing 8 changed files with 155 additions and 15 deletions.
1 change: 1 addition & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,7 @@ jobs:
rm $P/tests/issue-4282/global.spec.ts
rm $P/examples/TestRoutingGuard/can-*.spec.ts
rm $P/examples/TestRoutingResolver/fn.spec.ts
rm $P/tests/mock-service/observable.spec.ts
- run:
name: Unit Tests
command: |
Expand Down
2 changes: 1 addition & 1 deletion docs/articles/extra/mock-form-controls.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: How to mock form controls in Angular tests
description: Information how to use ng-mocks in order to interact with mock form controls in Angular tests
sidebar_label: Mock form controls
sidebar_label: Form Controls
---

`ng-mocks` respects `ControlValueAccessor` interface if [a directive](/api/MockDirective.md),
Expand Down
81 changes: 80 additions & 1 deletion docs/articles/extra/mock-observables.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: How to mock observable streams in Angular tests
description: Information how to mock observables in Angular tests
sidebar_label: Mock observables
sidebar_label: Observables
---

**A mock observable in Angular tests** can be created by
Expand Down Expand Up @@ -73,6 +73,85 @@ ngMocks.defaultMock(TodoService, () => ({

Then, every time tests need a mock object of `TodoService`, its `list$()` will return `EMPTY`.

## Mock `Subject`

`EMPTY` is a very default good mock to suppress observables correctly.
However, if you need to mock `Subject<T>` and its specializations such as
`BehaviorSubject`, `ReplaySubject` and `AsyncSubject`,
then `EMPTY`, as a type, cannot satisfy that due to the lack of required methods:
`.next()`, `.error()` and `.complete()`.

Let's assume we have a service like that:

```ts
class TodoService {
subject: Subject<boolean>;
behavior: BehaviorSubject<boolean>;
replay: ReplaySubject<boolean>;
async: AsyncSubject<boolean>;
}
```

And we want all these properties to be `EMPTY` in our tests:

```ts
ngMocks.defaultMock(TodoService, () => ({
subject: EMPTY,
behavior: EMPTY,
replay: EMPTY,
async: EMPTY,
}));
```

However, it won't work out of the box, and it will throw a type error:

```text
TS2769: No overload matches this call.
The last overload gave the following error.
Argument of type 'typeof TodoService' is not assignable to parameter of type
'AnyDeclaration<{
subject: Observable<never>;
behavior: Observable<never>;
replay: Observable<never>;
async: Observable<never>;
}>[]'.
```

And this makes sense, because indeed `Observable<never>` is not `Subject<boolean>` etc.

To fix that, we need to have a mock of `Subject`, `BehaviorSubject`, `ReplaySubject`, and `AsyncSubject`,
but it should behave as `EMPTY`: simply complete on subscribe.

`ng-mocks` has [`MockService`](../api/MockService.md) which can take a class and provide a mock instance of it.
Even more, its second parameter allows to customize the mock instance.
Therefore, we can use it to mock `Subject` and to apply `EMPTY` logic like that:

```ts
ngMocks.defaultMock(TodoService, () => ({
subject: MockService(Subject, EMPTY),
behavior: MockService(BehaviorSubject, EMPTY),
replay: MockService(ReplaySubject, EMPTY),
async: MockService(AsyncSubject, EMPTY),
}));
```

Profit, now all properties complete on subscribe and satisfy the required types.

## Mock the first emit of `BehaviorSubject`

Continuing the said above, it might be needed not only to suppress observables,
but also to stub the first emit of `BehaviorSubject`.

In this case, `of()` with the desired value should be used instead of `EMPTY`:

```ts
ngMocks.defaultMock(TodoService, () => ({
behavior: MockService(BehaviorSubject, of(false)),
}));
```

Profit! now `TodoService.behavior` emits `false` on subscribe and completes the subscription.

## Customizing observable streams

Nevertheless, usually, we want not only to return a stub result as `EMPTY` observable stream,
Expand Down
2 changes: 1 addition & 1 deletion docs/articles/extra/sanitizer.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Mocking DomSanitizer
description: Information how to test usage of DomSanitizer in Angular
sidebar_label: Mocking DomSanitizer
sidebar_label: DomSanitizer
---

This article explains how to mock `DomSanitizer` in Angular tests properly.
Expand Down
13 changes: 5 additions & 8 deletions docs/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,7 @@ module.exports = {
{
type: 'category',
label: 'Extra',
items: [
'extra/customize-mocks',
'extra/mock-observables',
'extra/mock-form-controls',
'extra/sanitizer',
'extra/with-3rd-party',
],
items: ['extra/customize-mocks', 'extra/with-3rd-party'],
},
{
type: 'category',
Expand Down Expand Up @@ -172,10 +166,13 @@ module.exports = {
collapsed: false,
items: [
'guides/mock/initialization-logic',
'guides/mock/dynamic-components',
'guides/mock/directive-structural-let-of',
'guides/mock/host-directive',
'guides/mock/activated-route',
'guides/mock/dynamic-components',
'extra/sanitizer',
'extra/mock-observables',
'extra/mock-form-controls',
],
},
{
Expand Down
11 changes: 9 additions & 2 deletions libs/ng-mocks/src/lib/mock-helper/mock-helper.stub.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import helperExtractMethodsFromPrototype from '../mock-service/helper.extract-methods-from-prototype';
import helperExtractPropertiesFromPrototype from '../mock-service/helper.extract-properties-from-prototype';
import helperExtractPropertyDescriptor from '../mock-service/helper.extract-property-descriptor';
import helperMockService from '../mock-service/helper.mock-service';
import { MockedFunction } from '../mock-service/types';

Expand All @@ -17,8 +20,12 @@ export default <T = MockedFunction>(instance: any, override: any, style?: 'get'
skipProps.push(...Object.getOwnPropertyNames(correctInstance));
}

for (const key of Object.getOwnPropertyNames(applyOverrides)) {
const desc = skipProps.indexOf(key) === -1 ? Object.getOwnPropertyDescriptor(applyOverrides, key) : undefined;
const keys = [
...helperExtractMethodsFromPrototype(applyOverrides),
...helperExtractPropertiesFromPrototype(applyOverrides),
];
for (const key of keys) {
const desc = skipProps.indexOf(key) === -1 ? helperExtractPropertyDescriptor(applyOverrides, key) : undefined;
if (desc && Object.prototype.hasOwnProperty.call(desc, 'value') && desc.value === undefined) {
continue;
}
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@
"clean:nx": "rm -Rf e2e/nx/node_modules/ng-mocks && rm -Rf e2e/nx/apps/a-nx/src/test",
"s:test:e2e": "npm run s:test:a5 && npm run s:test:a6 && npm run s:test:a7 && npm run s:test:a8 && npm run s:test:a9 && npm run s:test:a10 && npm run s:test:a11 && npm run s:test:a12 && npm run s:test:a13 && npm run s:test:a14 && npm run s:test:a15 && npm run s:test:a16 && npm run s:test:nx",
"s:test:a5": "npm run s:test:a5es5 && npm run s:test:a5es2015",
"s:test:a5es5": "P=e2e/a5es5/src/test && rm -Rf $P && mkdir -p $P && cp -R tests $P && cp -R examples $P && rm $P/examples/TestRoutingGuard/test.spec.ts && rm $P/examples/TestRoutingResolver/test.spec.ts && rm $P/tests/issue-4282/test.spec.ts && rm $P/tests/issue-4282/global.spec.ts && rm $P/examples/TestRoutingGuard/can-*.spec.ts && rm $P/examples/TestRoutingResolver/fn.spec.ts",
"s:test:a5es2015": "P=e2e/a5es2015/src/test && rm -Rf $P && mkdir -p $P && cp -R tests $P && cp -R examples $P && rm $P/examples/TestRoutingGuard/test.spec.ts && rm $P/examples/TestRoutingResolver/test.spec.ts && rm $P/tests/issue-4282/test.spec.ts && rm $P/tests/issue-4282/global.spec.ts && rm $P/examples/TestRoutingGuard/can-*.spec.ts && rm $P/examples/TestRoutingResolver/fn.spec.ts",
"s:test:a5es5": "P=e2e/a5es5/src/test && rm -Rf $P && mkdir -p $P && cp -R tests $P && cp -R examples $P && rm $P/examples/TestRoutingGuard/test.spec.ts && rm $P/examples/TestRoutingResolver/test.spec.ts && rm $P/tests/issue-4282/test.spec.ts && rm $P/tests/issue-4282/global.spec.ts && rm $P/examples/TestRoutingGuard/can-*.spec.ts && rm $P/examples/TestRoutingResolver/fn.spec.ts && rm $P/tests/mock-service/observable.spec.ts",
"s:test:a5es2015": "P=e2e/a5es2015/src/test && rm -Rf $P && mkdir -p $P && cp -R tests $P && cp -R examples $P && rm $P/examples/TestRoutingGuard/test.spec.ts && rm $P/examples/TestRoutingResolver/test.spec.ts && rm $P/tests/issue-4282/test.spec.ts && rm $P/tests/issue-4282/global.spec.ts && rm $P/examples/TestRoutingGuard/can-*.spec.ts && rm $P/examples/TestRoutingResolver/fn.spec.ts && rm $P/tests/mock-service/observable.spec.ts",
"s:test:a6": "P=e2e/a6/src/test && rm -Rf $P && mkdir -p $P && cp -R tests $P && cp -R examples $P && rm $P/examples/TestRoutingGuard/can-*.spec.ts && rm $P/examples/TestRoutingResolver/fn.spec.ts",
"s:test:a7": "P=e2e/a7/src/test && rm -Rf $P && mkdir -p $P && cp -R tests $P && cp -R examples $P && rm $P/examples/TestRoutingGuard/can-*.spec.ts && rm $P/examples/TestRoutingResolver/fn.spec.ts",
"s:test:a8": "P=e2e/a8/src/test && rm -Rf $P && mkdir -p $P && cp -R tests $P && cp -R examples $P && rm $P/examples/TestRoutingGuard/can-*.spec.ts && rm $P/examples/TestRoutingResolver/fn.spec.ts",
Expand Down
56 changes: 56 additions & 0 deletions tests/mock-service/observable.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Injectable } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Observable, of } from 'rxjs';

import {
isMockOf,
MockProvider,
MockService,
ngMocks,
} from 'ng-mocks';

@Injectable()
class CustomObservable<T> extends Observable<T> {
custom() {}
}

@Injectable()
class TodoService {
constructor(
public readonly observable: CustomObservable<boolean>,
) {}
}

ngMocks.defaultMock(TodoService, () => ({
observable: MockService(CustomObservable, of(true)),
}));

// Tests insures that MockService applies `of` to the initial class correctly.
// So, if someone subscribes to it, it emits its values.
// And, in the same time it provides correct customizations of the initial class.
describe('MockService:observable', () => {
beforeEach(() =>
TestBed.configureTestingModule({
providers: [
MockProvider(CustomObservable),
MockProvider(TodoService),
],
}).compileComponents(),
);

it('mocks class and of correctly', () => {
const instance = ngMocks.get(TodoService);

// The custom method is a mock.
expect(instance.observable.custom).toBeDefined();
// The instance is a mock.
expect(isMockOf(instance.observable, CustomObservable)).toEqual(
true,
);

// The instance behaves as `of`.
let actual = false;
instance.observable.subscribe(value => (actual = value));
expect(actual).toEqual(true);
});
});

0 comments on commit e9945fe

Please sign in to comment.