Trouble with Angular Integration Testing: Unable to mock a service method that returns an Observable using jasmine Spy

After diving into integration testing, the whole process seems quite perplexing to me.

In my initial attempt, it appears that my spy is not returning the data as expected. The error states: Expected 0 to be 3. It would be immensely helpful if someone could guide me on where I might be going wrong.

Below are my service, page, spec files along with the template:

MyService


    import { Data } from './../data/data.model';
    import { Injectable } from '@angular/core';
    import { BehaviorSubject, of } from 'rxjs';
    import { tap } from 'rxjs/operators';

    @Injectable({
      providedIn: 'root',
    })
    export class MyService {
      private _data = new BehaviorSubject<Data[]>([]);

      get data() {
        return this._data;
      }

      constructor() {}

      getAllData() {
        return of([
          {
            id: '1',
            title: 'Rice',
          },
          {
            id: '2',
            title: 'Wheat',
          },
          {
            id: '33',
            title: 'Water',
          },
        ]).pipe(
          tap((data) => {
            this._data.next(data);
          })
        );
      }
    }

DataPage Component


    import { Component, OnInit } from '@angular/core';
    import { BehaviorSubject, Observable, of, Subscription } from 'rxjs';
    import { MyService } from '../services/my.service';
    import { Data } from './data.model';

    @Component({
      selector: 'app-data',
      templateUrl: './data.page.html',
      styleUrls: ['./data.page.scss'],
    })
    export class DataPage implements OnInit {
      allData: Data[];
      dataServiceSub: Subscription;
      isLoading: boolean;

      constructor(private myService: MyService) {}

      ngOnInit() {
        this.dataServiceSub = this.myService.data.subscribe(
          (data) => {
            console.log(data);
            this.allData = data;
          }
        );
      }

      ngOnDestroy() {
        if (this.dataServiceSub) {
          console.log('ngOnDestroy');
          this.dataServiceSub.unsubscribe();
        }
      }

      ionViewWillEnter() {
        this.isLoading = true;
        this.myService.getAllData().subscribe(() => {
          console.log('ionViewWillEnter');
          this.isLoading = false;
        });
      }
    }

DataPage.spec

    import { MyService } from '../services/my.service';
    import { async, ComponentFixture, TestBed } from '@angular/core/testing';
    import { IonicModule } from '@ionic/angular';

    import { DataPage } from './data.page';
    import { of } from 'rxjs';

    describe('DataPage', () => {
      let component: DataPage;
      let fixture: ComponentFixture<DataPage>;
      let serviceSpy: jasmine.SpyObj<MyService>;

      beforeEach(async(() => {
        TestBed.configureTestingModule({
          declarations: [DataPage],
          providers: [
            {
              provide: MyService,
              useClass: MyService
            },
          ],
          imports: [IonicModule.forRoot()],
        }).compileComponents();

        fixture = TestBed.createComponent(DataPage);
        component = fixture.componentInstance;
        fixture.detectChanges();
      }));

      fit('Should show list of data if data is available', () => {
        serviceSpy = TestBed.get(MyService);
        spyOn(serviceSpy, 'getAllData').and.returnValue(of([
          {
            id: '1',
            title: 'Rice',
          },
          {
            id: '2',
            title: 'Wheat',
          },
          {
            id: '33',
            title: 'Water',
          },
        ]));
        fixture.detectChanges();
        const element = fixture.nativeElement.querySelectorAll(
          '[test-tag="dataList"] ion-item'
        );
        console.log(
          fixture.nativeElement.querySelectorAll('[test-tag="dataList"]')
        );
        expect(element.length).toBe(3);
      });
    });

HTML


    <ion-content>
      <div test-tag="empty" class="ion-text-center">
        <ion-text color="danger">
          <h1>No data</h1>
        </ion-text>
      </div>
      <div test-tag="dataList">
        <ion-list>
          <ion-item *ngFor="let data of allData">
            <ion-label test-tag="title">{{data.title}}</ion-label>
          </ion-item>
        </ion-list>
      </div>
    </ion-content>


Answer №1

Here is the issue at hand:

To successfully set the value of this.allData, you must call ionViewWillEnter()'

Explanation: The reason for this is that when creating a BehaviorSubject, the initial value is empty. In order to emit a value using data (this._data.next(data)), the method getAllData() must be called.

 import { MyService } from '../services/my.service';
    import { async, ComponentFixture, TestBed } from '@angular/core/testing';
    import { IonicModule } from '@ionic/angular';

    import { DataPage } from './data.page';
    import { of } from 'rxjs';

    describe('DataPage', () => {
      let component: DataPage;
      let fixture: ComponentFixture<DataPage>;
      let serviceSpy: jasmine.SpyObj<MyService>;

      beforeEach(async(() => {
        TestBed.configureTestingModule({
          declarations: [DataPage],
          providers: [ MyService ],
          imports: [IonicModule.forRoot()],
        }).compileComponents();

        fixture = TestBed.createComponent(DataPage);
        component = fixture.componentInstance;
        fixture.detectChanges();
      }));

      fit('Should show list of data if data is available', () => {
        component.ionViewWillEnter(); // or create an event which will trigger ionViewWillEnter()
        fixture.detectChanges();
        const element = fixture.nativeElement.querySelectorAll(
          '[test-tag="dataList"] ion-item'
        );
        console.log(
          fixture.nativeElement.querySelectorAll('[test-tag="dataList"]')
        );
        expect(element.length).toBe(3);
      });
    });

Please note the following changes I have made:

  1. Removed UseClass (not being used as intended)
  2. Removed spy (hardcoded value already present in original service)

For more insights into testing in Angular, you can read my article here, which also showcases the use of useClass for reference.


As a side note: Consider utilizing asObservable(), and adhere to the convention of adding $ when creating an Observable (this.data$.asObservable()). Although not mandatory, it is a recommended practice in the JS community.

get data() {
  return this._data.asObservable();
}

Answer №2

If you want to prevent pain when dealing with observables, I highly suggest using a mocking library such as ng-mocks. Check out the article titled "How to mock observable streams in Angular tests" at .

In your specific scenario, the test setup could look like this:

describe('DataPage', () => {
  // Mocks everything except DataPage
  beforeEach(() => {
    return MockBuilder(DataPage)
      .mock(IonicModule.forRoot())
      .mock(MyService);
  });

  // We need to stub it because of subscription in ionViewWillEnter.
  // In a mock service, ionViewWillEnter does not return anything.
  // However, we need to ensure it returns an empty observable stream
  // to avoid errors like cannot call .subscribe on undefined.
  // This line can be removed along with the debugging from the
  // component.
  beforeEach(() => MockInstance(MyService, 'getAllData', () => EMPTY));

  it('Should show list of data if data is available', () => {
    // Spies on the getter of the property the component uses
    MockInstance(MyService, 'data', jasmine.createSpy(), 'get')
      .and.returnValue(of([
        {
          id: '1',
          title: 'Rice',
        },
        {
          id: '2',
          title: 'Wheat',
        },
        {
          id: '33',
          title: 'Water',
        },
      ]));

    // Renders (already with detected changes)
    const fixture = MockRender(DataPage);

    // Assertions
    const element = fixture.nativeElement.querySelectorAll(
      '[test-tag="dataList"] ion-item'
    );
    console.log(
      fixture.nativeElement.querySelectorAll('[test-tag="dataList"]')
    );
    expect(element.length).toBe(3);
  });
});

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

What is the process for resetting the mat-date-range-input selection on the calendar?

I've encountered a puzzling problem that has me stumped. I'm working with a mat date range picker in Angular Typescript and have run into an issue while trying to clear any selection made through a function. The code snippet below successfully c ...

Unraveling the Mystery: Why divide compilerOptions in tsconfig.lib.json in NxWorkspace, Angular?

Why does my NxWorkspace have a tsconfig.lib.json file? I only have one Angular app in my workspace. tsconfig.json { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc" ...

Incorporating the Angular "dist" build file into the Node.js server.js script

Having some trouble deploying my MEAN stack app on Heroku. Managed to commit the app, but struggling to connect the ng build "dist" file to my server.js file. This is the snippet from my server.js file where I'm attempting to link the file: var dist ...

What steps can be taken to eliminate repeat categories and prevent the accumulation of endless iterations?

Analysis I designed an interface that takes two type parameters, with the second parameter being optional and defaulting to void. Additionally, I created a utility type called CommandReturnType which employs conditional typing to ensure that void is not r ...

Although the Jest tests are passing successfully, it seems that the --covering option is not detecting all

Issue summary: I have encountered an issue with Jest while trying to generate test coverage for my TypeScript class. Even though my two tests are passing, Jest seems to be unable to pick up the covered files when using the --coverage option. The output I ...

Assign a value to an array property of a separate Angular component

My issue can be summed up as follows: I am interested in dynamically loading external Angular components from a remote server at runtime. I have successfully achieved this with the help of a blog post by Manfred Steyer However, my specific challenge lies ...

Comparing the Calculation of CSS Selector Specificity: Class versus Elements [archived]

Closed. This question requires additional information for debugging purposes. It is not currently accepting answers. ...

What could be causing the variable in my Angular application, written in typescript, not to update within a function?

I'm encountering a peculiar issue. Within my CategoryComponent component, I have a variable in scope as follows: @Input() category: CategoryModel; Whenever I perform a simple post request using a service and receive data back, I modify this variabl ...

Showing the child component as undefined in the view

Within my Angular application, I encountered an issue involving a parent component named DepotSelectionComponent and its child component SiteDetailsComponent. The problem arises when an event called moreDetails is emitted to the parent component, triggerin ...

Is it possible to pass parameters from a base class's constructor to a child class?

I'm facing an issue with my base (generic) classes where the properties are initialized in the constructor. Whenever I try to extend these classes to create more specific ones, I find myself repeating the same parameters from the base class's con ...

How can a button click function be triggered in another component?

These are the three components in my dashboard.html <top-nav></top-nav> <sidebar-cmp></sidebar-cmp> <section class="main-container" [ngClass]="{sidebarPushRight: isActive}"> <router-outlet></router-outlet> & ...

Is it advisable to choose Ionic or solely Angular when creating a cross-platform app with capacitor?

I am excited to embark on a project to create a versatile software application that can be run as a web app, android app, iOS app, and even potentially as a desktop application. Capacitor offers the promise of achieving this with just one code base. As I p ...

Is it possible for me to use an array as an argument for the rest parameter?

One of the functions in my codebase accepts a rest parameter. function getByIds(...ids: string){ ... } I have tested calling getByIds('andrew') and getByIds('andrew','jackson'), which successfully converts the strings into a ...

Angular 7: Exploring the Best Way to Modify Query Parameters While Subscribing

When attempting to convert query parameters to integers within a subscription, I am encountering an issue where the code stops executing after any transformation of query parameters. This occurs regardless of whether the parameters are transformed to integ ...

Error message: Unable to instantiate cp in Angular 17 application while building with npm run in docker container

After creating a Dockerfile to containerize my application, I encountered an issue. When I set ng serve as the entrypoint in the Dockerfile, everything works fine. However, the problem arises when I try to execute npm run build. Below is the content of my ...

Have there been any instances of combining AngularJS, ASP.NET-WebApi, OData, Breeze.js, and Typescript?

I am attempting to combine different technologies, but I am facing challenges as the entity framework meta-datas are not being consumed properly by breeze.js. Despite setting up all configurations, it's proving to be a bit tricky since there are no ex ...

What is the best way to store a set of tuples in a collection so that each tuple is distinct and

I am working with TypeScript and aiming to create a collection of unique objects, each with distinct properties. The combinations of these properties within the collection must be one-of-a-kind. For example, the following combinations would be considered ...

Angular and C# working together to automatically round large numbers with decimals

I'm facing an issue with my database where I have a value of 100000000000000.165. When I validate this value using an API tester, I get the expected result. https://i.sstatic.net/93NXp.png However, when I retrieve the value in my Angular app and che ...

Guide to Rolling a Set of 5 Dice

I am looking to develop a game involving 5 dice. I have already created a function to roll one die using a random method, but I am unsure how to extend this functionality to the remaining four dice without having to create a separate method for each one. ...

Discovering an element in an Array using Angular 2 and TypeScript

In my setup, I have a Component and a Service: Component: export class WebUserProfileViewComponent { persons: Person []; personId: number; constructor( params: RouteParams, private personService: PersonService) { ...