diff --git a/README.md b/README.md index 77b4ac0a60..a2683bf96f 100644 --- a/README.md +++ b/README.md @@ -2472,7 +2472,7 @@ import 'ng-mocks/dist/jest'; We may encounter different unpleasant issues, when we mock declarations in testing environment. There is a list of most common issues and their solutions below, -feel free to [contact us](#find-an-issue-or-have-a-question-or-a-request) if you are facing or struggling with anything else. +feel free to [contact us](#find-an-issue-or-have-a-question-or-a-request) if you are facing or struggling with them or anything else. - [`TypeError: Cannot read property 'subscribe' of undefined`](#how-to-fix-typeerror-cannot-read-property-subscribe-of-undefined) - [`Error: Type is part of the declarations of 2 modules`](#how-to-fix-error-type-is-part-of-the-declarations-of-2-modules) @@ -2481,6 +2481,119 @@ feel free to [contact us](#find-an-issue-or-have-a-question-or-a-request) if you ### How to fix `TypeError: Cannot read property 'subscribe' of undefined` +This issue means that something has been mocked and returns a dummy result (`undefined`) instead of observable streams. + +For example, if we have `TodoService.list$()`, +that returns a type of `Observable>`, +and a component, +that fetches the list in `OnInit` via `subscribe` method: + +```typescript +class TodoComponent implements OnInit { + public list: Observable>; + + constructor(protected service: TodoService) {} + + ngOnInit(): void { + // Never do like that. + // It is just for the demonstration purposes. + this.service.list$().subscribe(list => (this.list = list)); + } +} +``` + +If we wanted to test the component, we would like to mock its dependencies. In our case it is `TodoService`. + +```typescript +TestBed.configureTestingModule({ + declarations: [TodoComponent], + providers: [MockProvider(TodoService)], +}); +``` + +If we created a fixture, we would face the error. This happens because a mocked copy of `TodoService.list$` +returns a spy, if [auto spy](#auto-spy) has been configured, or `undefined`. Therefore, neither has the `subscribe` property. + +Obviously, to solve this, we need to get the method to return an observable stream. +For that, we could create a mocked copy via [`MockService`](#how-to-mock-a-service) and to pass it as the second parameter into [`MockProvider`](#how-to-mock-a-provider). + +```typescript +const todoServiceMock = MockService(TodoService); +ngMocks.stub(todoServiceMock, { + list$: () => EMPTY, +}); + +TestBed.configureTestingModule({ + declarations: [TodoComponent], + providers: [MockProvider(TodoService, todoServiceMock)], +}); +``` + +Profit, now initialization of the component with the mocked service does not throw the error anymore. + +Nevertheless, usually, we want not only to stub the result with an `EMPTY` observable stream, +but also to provide a fake subject, that would simulate its calls. + +A possible solution is to create a context variable of `Subject` type for that. + +```typescript +let todoServiceList$: Subject; // <- a context variable. + +beforeEach(() => { + todoServiceList$ = new Subject(); // <- create the subject. + const todoServiceMock = MockService(TodoService); + ngMocks.stub(todoServiceMock, { + list$: () => todoServiceList$, + }); + + TestBed.configureTestingModule({ + declarations: [TodoComponent], + providers: [MockProvider(TodoService, todoServiceMock)], + }); +}); + +it('test', () => { + const fixture = TestBed.createComponent(TodoComponent); + // Let's simulate emits. + todoServiceList$.next([]); + // Here we can do some assertions. +}); +``` + +A solution for [`MockBuilder`](#mockbuilder) is quite similar. + +```typescript +let todoServiceList$: Subject; // <- a context variable. + +beforeEach(() => { + todoServiceList$ = new Subject(); // <- create the subject. + const todoServiceMock = MockService(TodoService); + ngMocks.stub(todoServiceMock, { + list$: () => todoServiceList$, + }); + + return MockBuilder(TodoComponent).mock( + TodoService, + todoServiceMock + ); +}); + +it('test', () => { + const fixture = MockRender(TodoComponent); + todoServiceList$.next([]); + // some assertions. +}); +``` + +This all might be implemented with [`MockInstance`](#mockinstance) too, +but it goes beyond the topic. + +The source file is here: +[MockObservable](https://github.com/ike18t/ng-mocks/blob/master/examples/MockObservable/test.spec.ts).
+Prefix it with `fdescribe` or `fit` on +[codesandbox.io](https://codesandbox.io/s/github/satanTime/ng-mocks-cs?file=/src/examples/MockObservable/test.spec.ts) +to play with. + [to the top](#content) --- @@ -2507,7 +2620,7 @@ TestBed.configureTestingModule({ The problem is clear: when we mock the module, [`MockModule`](#how-to-mock-a-module) recursively mocks its dependencies, and, therefore, it creates a mocked copy of `SharedModule`. Now imported and mocked declarations are part of 2 modules. -To solve it we need to let [`MockModule`](#how-to-mock-a-module) know, that `SharedModule` should not be mocked. +To solve this, we need to let [`MockModule`](#how-to-mock-a-module) know, that `SharedModule` should not be mocked. There are good and bad news. Bad news is that [`MockModule`](#how-to-mock-a-module) does not support that, but good news is that `ngMocks` has [`MockBuilder`](#mockbuilder) for such a complicated case. The only problem now is to rewrite `beforeEach` to use [`MockBuilder`](#mockbuilder) instead of [`MockModule`](#how-to-mock-a-module). diff --git a/examples/MockObservable/test.spec.ts b/examples/MockObservable/test.spec.ts new file mode 100644 index 0000000000..c1dc0aba4a --- /dev/null +++ b/examples/MockObservable/test.spec.ts @@ -0,0 +1,83 @@ +import { CommonModule } from '@angular/common'; +import { Component, Injectable, NgModule } from '@angular/core'; +import { MockBuilder, MockInstance, MockRender, ngMocks } from 'ng-mocks'; +import { Observable, Subject } from 'rxjs'; + +// A simple service, might have contained more logic, +// but it is redundant for the test demonstration. +@Injectable() +class TargetService { + public readonly value$: Observable = new Observable(); +} + +@Component({ + selector: 'target', + template: `{{ list | json }}`, +}) +class TargetComponent { + public list: number[]; + + constructor(service: TargetService) { + service.value$.subscribe(list => (this.list = list)); + } +} + +@NgModule({ + declarations: [TargetComponent], + imports: [CommonModule], + providers: [TargetService], +}) +class TargetModule {} + +describe('MockObservable', () => { + // Because we want to test the component, we pass it as the first + // parameter of MockBuilder. To mock its dependencies we pass its + // module as the second parameter. + beforeEach(() => MockBuilder(TargetComponent, TargetModule)); + + // Now we need to customize the mocked copy of the service. + // value$ is our access point to the stream. + const value$: Subject = new Subject(); + beforeAll(() => { + // MockInstance helps to override mocked instances. + MockInstance(TargetService, { + init: instance => + ngMocks.stub(instance, { + value$, // even it is a read-only property we can override it in a type-safe way. + }), + }); + }); + + // Cleanup after tests. + afterAll(() => { + value$.complete(); + }); + + it('has access to the service via a component', () => { + // Let's render the component. + const fixture = MockRender(TargetComponent); + + // We haven't emitted anything yet, let's check the template. + expect(fixture.nativeElement.innerHTML).not.toContain('1'); + expect(fixture.nativeElement.innerHTML).not.toContain('2'); + expect(fixture.nativeElement.innerHTML).not.toContain('3'); + + // Let's simulate an emit. + value$.next([1, 2, 3]); + fixture.detectChanges(); + + // The template should contain the emitted numbers. + expect(fixture.nativeElement.innerHTML).toContain('1'); + expect(fixture.nativeElement.innerHTML).toContain('2'); + expect(fixture.nativeElement.innerHTML).toContain('3'); + + // Let's simulate an emit. + value$.next([]); + fixture.detectChanges(); + + // The numbers should disappear. + expect(fixture.nativeElement.innerHTML).not.toContain('1'); + expect(fixture.nativeElement.innerHTML).not.toContain('2'); + expect(fixture.nativeElement.innerHTML).not.toContain('3'); + }); +});