如何测试依赖于包含 BehaviorSubject 的服务的 Angular 组件?

How to test an Angular component that depends on a service containing a BehaviorSubject?

我是 Angular 的新手,我正在尝试测试以下组件的构造,它依赖于 RecipesServices,其中包含一个名为 [=] 的 BehaviorSubject 16=]:

@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);
  }
}

这是服务:

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

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

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

我尝试了很多不同的方法来模拟此服务并将其添加为组件测试中的提供者,但我开始缺乏想法。这是我正在尝试的当前测试,抛出 "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();
  });
});

感谢您的帮助!

好问题,有很多代码可以看!

首先,我不允许 public 从 RecipesService 访问您的主题,当某些组件开始使用 .next 方法时,它可能会导致失去控制。所以我制作了一个 public observable,我在 RecipePage 组件中订阅了它。

另一个味道是构造函数,尽量避免构造函数中的逻辑,并使用 angular 生命周期挂钩,如 ngOnInit/ngOnChanges 代替。 Angular 将在完成组件设置后调用此挂钩。

为了测试,您只需要模拟RecipesService。如果您的组件不依赖于 HttpClient,那么您不需要存根。

我所做的是创建一个特殊的模拟 class 来在测试中处理此服务。很多时候你会在不同的组件中拥有相同的服务,所以拥有一个可重用的 mock class 是很有帮助的。 RecipesServiceMock 有 public 像您的真实服务一样的可观察对象 ($selectedRecipeObs) 和一个在我们的测试中设置新值的辅助方法。

我还创建了 a stackblitz 来向您展示 运行 测试。您可以在 app/pagerecipe/ 文件夹中找到与您的问题相关的所有内容。 如果您想了解更多关于如何测试的想法,请查看 angular tutorial about tests or their example of different kinds of tests and the corresponding git repo

食谱服务:

@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({
  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)
    );
  }
}

我们对 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);
    }
}

测试文件:

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);
  }));
});