1

I'm rather new to Angular and I'm trying to test the construction of the following component, that depends on a RecipesServices that contains a BehaviorSubject called selectedRecipe:

@Component({
  selector: 'app-recipe',
  templateUrl: './recipe.page.html',
  styleUrls: ['./recipe.page.scss'],
})
export class RecipePage implements OnInit {
  selectedRecipe: Recipe;
  constructor(
    private recipesService: RecipesService
  ) {
    this.recipesService.selectedRecipe.subscribe(newRecipe => this.selectedRecipe = newRecipe);
  }
}

Here is the service:

@Injectable({
  providedIn: 'root'
})
export class RecipesService {

  /**
   * The recipe selected by the user
   */
  readonly selectedRecipe : BehaviorSubject<Recipe> = new BehaviorSubject(null);

  constructor(
    private httpClient: HttpClient
  ) {}
...
}

I have tried a lot of different things to mock this service and add it as a provider in the component's test, but I start lacking ideas. Here is the current test I'm trying, that throws "Failed: this.recipesService.selectedRecipe.subscribe is not a function":

import { HttpClient } from '@angular/common/http';
import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing';
import { Router, UrlSerializer } from '@angular/router';
import { IonicModule } from '@ionic/angular';
import { BehaviorSubject, defer, Observable, of, Subject } from 'rxjs';
import { Recipe } from '../recipes-list/recipe';
import { RecipesService } from '../recipes-list/services/recipes.service';

import { RecipePage } from './recipe.page';

let mockrecipesService = {
  selectedRecipe: BehaviorSubject
}

describe('RecipePage', () => {
  let component: RecipePage;
  let fixture: ComponentFixture<RecipePage>;
  var httpClientStub: HttpClient;
  let urlSerializerStub = {};
  let routerStub = {};

  beforeEach(waitForAsync(() => {

    TestBed.configureTestingModule({
      declarations: [ RecipePage ],
      imports: [IonicModule.forRoot()],
      providers: [
        { provide: HttpClient, useValue: httpClientStub },
        { provide: UrlSerializer, useValue: urlSerializerStub },
        { provide: Router, useValue: routerStub },
        { provide: RecipesService, useValue: mockrecipesService}
      ]
    }).compileComponents();
    spyOn(mockrecipesService, 'selectedRecipe').and.returnValue(new BehaviorSubject<Recipe>(null));

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

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

Thank you for your help!

Mario Petrovic
  • 7,500
  • 14
  • 42
  • 62
wadidi
  • 13
  • 2

1 Answers1

0

Good question with lots of code to look at!

To begin with, I would not allow public access to your subject from the RecipesService, it could lead to loss of control when some components start using the .next method. So I made a public observable to which I subscribe inside the RecipePage component.

Another smell is the constructor, try to avoid logic in constructors and use the angular life cycle hooks like ngOnInit/ngOnChanges instead. Angular will call this hook once it has finished setting up the component.

For the test, you only need to mock the RecipesService. If your component has no dependency on the HttpClient, then you don't need a stub there.

What I did was create a special mock class to handle this service in tests. Often times you will have the same service in different components, so having a re-useable mock class is quite helpful. RecipesServiceMock has the public observable like your real service ($selectedRecipeObs) and a helper method to set new values in our tests.

I also created a stackblitz to show you the running tests. You find everything relevant to your problem inside the app/pagerecipe/ folder. Look into the angular tutorial about tests or their example of different kinds of tests and the corresponding git repo if you want more ideas on how to test.

RecipesService:

@Injectable({
    providedIn: 'root'
  })
export class RecipesService {
  
    /**
     * The recipe selected by the user
     */
    private readonly selectedRecipe : BehaviorSubject<Recipe> = new BehaviorSubject(null);
    // it's good practice to disallow public access to your subjects. 
    // so that's why we create this public observable to which components can subscibe.
    public $selectedRecipeObs = this.selectedRecipe.asObservable();

    constructor(
      private httpClient: HttpClient
    ) {}
}

Component:

@Component({
  selector: "app-recipe-page",
  templateUrl: "./recipe-page.component.html",
  styleUrls: ["./recipe-page.component.css"],
  providers: []
})
export class RecipePageComponent implements OnInit {
  selectedRecipe: Recipe;
  constructor(private recipesService: RecipesService) {
    // the contructor should be as simple as possible, most code usually goes into one of the life cycle hooks like ngOnInit
  }

  ngOnInit(): void {
    // since we want to avoid any loss of control, we subscribe to the new $selectedRecipeObs instead of the subject.
    // everything else goes through your service, set/get etc
    this.recipesService.$selectedRecipeObs.subscribe(
      newRecipe => (this.selectedRecipe = newRecipe)
    );
  }
}

Our mock of the RecipesService:

export class RecipesServiceMock {
    private selectedRecipe = new BehaviorSubject<Recipe>(null);
    // must have the same name as in your original service.
    public $selectedRecipeObs = this.selectedRecipe.asObservable();

    constructor() {
    } 

    /** just a method to set values for tests. it can have any name. */
    public setSelectedRecipeForTest(value: Recipe): void {
        this.selectedRecipe.next(value);
    }
}

Testfile:

import {
  ComponentFixture,
  fakeAsync,
  TestBed,
  tick,
  waitForAsync
} from "@angular/core/testing";
import { Recipe } from "../recipe";

import { RecipesService } from "../recipes.service";
import { RecipesServiceMock } from "../test-recipes.service";
import { RecipePageComponent } from "./recipe-page.component";

////// Tests //////
describe("RecipePageComponent", () => {
  let component: RecipePageComponent;
  let fixture: ComponentFixture<RecipePageComponent>;
  let recipesServiceMock: RecipesServiceMock;

  beforeEach(
    waitForAsync(() => {
      recipesServiceMock = new RecipesServiceMock();
      TestBed.configureTestingModule({
        imports: [],
        providers: [{ provide: RecipesService, useValue: recipesServiceMock }]
      }).compileComponents();

      fixture = TestBed.createComponent(RecipePageComponent);
      component = fixture.componentInstance;
    })
  );

  it("should create", () => {
    fixture.detectChanges();
    expect(component).toBeTruthy();
  });

  it("should update component with new value", fakeAsync(() => {
    // set new value other than null;
    const myNewRecipe = new Recipe("tasty");

    recipesServiceMock.setSelectedRecipeForTest(myNewRecipe);

    fixture.detectChanges(); //
    tick(); // )

    expect(component.selectedRecipe).toEqual(myNewRecipe);
  }));
});
sombrerogalaxy
  • 366
  • 2
  • 6
  • Thanks for your answer! Sorry for having added so much code, I've tried to remove the parts that were not useful but didn't want to accidentally remove parts that would actually be useful, that's why it ended up being so long. Unfortunately I still get an error of type Failed: Cannot read property 'subscribe' of undefined I'm not sure what I'm doing wrong... it looks like the mocked recipe services is correctly passed to the RecipePage component when I use console.log(). – wadidi Mar 07 '21 at 00:33
  • The amount of code is a good thing. A lot of people asking questions leave out all code and except people to know what they mean. Anyway, I forgot to mention another smell yesterday. Constructors should be as simple as possible and should only initialize class members. Logic should go into the life cycle hooks like ngOnInit. I also added a working stackblitz example. I hope I could help. – sombrerogalaxy Mar 07 '21 at 12:26
  • I've just found out where the issue is coming from! Trying to go for a minimal version has helped me, I've created a sample component, service and unit test with nothing but the minimal to debug. That error "Failed: Cannot read property 'subscribe' of undefined" is actually referring to an ion-button (I am using ionic) that I have in the HTML template that contains a [routerLink]="['/']" property. So that error was linked to the routing, nothing to do with the BehaviorSubject, and your suggestion actually works. Thanks a lot! – wadidi Mar 07 '21 at 13:06
  • Regarding the routing issue with [routerLink] in the component's template that was throwing the error, importing the RouterTestingModule in the test solved the issue: https://stackoverflow.com/questions/39577920/angular-2-unit-testing-components-with-routerlink/39579009 – wadidi Mar 07 '21 at 13:24