为使用 Observables 的 Angular 2 组件编写 Jasmine 测试

Posted

技术标签:

【中文标题】为使用 Observables 的 Angular 2 组件编写 Jasmine 测试【英文标题】:Writing Jasmine test for Angular 2 component which uses Observables 【发布时间】:2017-11-06 15:44:18 【问题描述】:

我正在尝试测试一个使用服务调用和可观察调用来获取数据列表的 angular 2 组件。我已将我的主应用程序模块导入此规范文件。

我的规范文件如下所示:

import  ComponentFixture, TestBed, fakeAsync, async  from '@angular/core/testing';
import  By  from '@angular/platform-browser';
import  DebugElement  from '@angular/core';
import  MaterialModule  from '@angular/material';
import  FormsModule  from '@angular/forms';
import  AppModule  from '../../../src/app/app.module';
import  Observable  from 'rxjs/Observable';
import  Store  from '@ngrx/store';
import   from 'jasmine';

import  FirmService  from '../../../src/app/containers/dashboard/services/firm.service';
import  FirmListComponent  from '../../../src/app/containers/dashboard/firm-list/firm-list.component';
import  mockFirm1, mockFirm2, mockFirms  from './firm-list.mocks';
import  Firm  from '../../../src/app/containers/dashboard/models/firm.model';
import  FirmState  from '../../../src/app/containers/dashboard/services/firm.state';

describe('Firm List Component', () => 
    let fixture: ComponentFixture<FirmListComponent>;
    let component: FirmListComponent;
    let element: htmlElement;
    let debugEl: DebugElement;
    let firmService: FirmService;
    let mockHttp;
    let stateObservable: Observable<FirmState>;
    let store: Store<FirmState>;
    let getFirmsSpy;
    let getObservableSpy;

    // utilizes zone.js in order to mkae function execute syncrhonously although it is asynchrounous
    beforeEach(async(() => 
        TestBed.configureTestingModule(
            imports: [MaterialModule, FormsModule, AppModule],
            declarations: [FirmListComponent],
            providers: [FirmService]
        )
            .compileComponents() // compiles the directives template or any external css calls
            .then(() => 
                fixture = TestBed.createComponent(FirmListComponent); // allows us to get change detection, injector
                component = fixture.componentInstance;
                debugEl = fixture.debugElement;
                element = fixture.nativeElement;
                firmService = fixture.debugElement.injector.get(FirmService);

                getObservableSpy = spyOn(firmService, 'stateObservable')
                    .and.returnValue(new FirmState());

                getFirmsSpy = spyOn(firmService, 'getFirms')
                    .and.returnValue(Observable.of(mockFirms));
            );
    ));

    it('should be defined', () => 
        expect(component).toBeDefined();
    );

    describe('initial display', () => 
        it('should not show firms before OnInit', () => 
            debugEl = fixture.debugElement.query(By.css('.animate-repeat'));
            expect(debugEl).toBeNull();
            expect(getObservableSpy.calls.any()).toBe(false, 'ngOnInit not yet called');
            expect(getFirmsSpy.calls.any()).toBe(false, 'getFirms not yet called');
        );

        it('should still not show firms after component initialized', () => 
            fixture.detectChanges();
            debugEl = fixture.debugElement.query(By.css('.animate-repeat'));
            expect(debugEl).toBeNull();
            expect(getFirmsSpy.calls.any()).toBe(true, 'getFirms called');
        );

        it('should show firms after getFirms observable', async(() => 
            fixture.detectChanges();

            fixture.whenStable().then(() => 
                fixture.detectChanges();

                // **I get the correct value here, this is the table headers for the table data below that is showing 0**
                var rowHeaderLength = element.querySelectorAll('th').length;
                expect(rowHeaderLength).toBe(8);

                // **I get 0 for rowDataLength here, test fails**
                var rowDataLength = element.querySelectorAll('.animate-repeat').length;
                console.log(rowDataLength);
            );
        ));

        it('should show the input for searching', () => 
            expect(element.querySelector('input')).toBeDefined();
        );
    );
);

上面的第一个测试通过,但第二个测试没有通过,我目前收到一条错误消息,提示“无法读取 null 的属性 'nativeElement'”。

我的组件代码如下所示:

 import  NgModule, Component, Input, OnInit, OnChanges  from '@angular/core';
 import  MaterialModule  from '@angular/material';
 import  FlexLayoutModule  from '@angular/flex-layout';
 import  CommonModule  from '@angular/common';
 import  FormsModule  from '@angular/forms';
 import  Firm  from '../models/firm.model';
 import  FirmService  from '../services/firm.service';

 @Component(
   selector: 'firm-list',
   templateUrl: './firm-list.html',
   styles: []
 )

export class FirmListComponent implements OnInit 
   public selectAll: boolean;
   public firms: Array<Firm>;
   public filteredFirms: any;
   public loading: boolean;
   public searchText: string;
   private componetDestroyed = false;

   // @Input() public search: string;

   constructor(public firmService: FirmService)  

     public ngOnInit() 
       this.firmService.stateObservable.subscribe((state) => 
         this.firms = state.firms;
         this.filteredFirms = this.firms;
       );

      this.getFirms();
   

    public getFirms(value?: string) 
      this.loading = true;
      this.firmService.getFirms(value).subscribe((response: any) => 
         this.loading = false;
      );
    


 @NgModule(
   declarations: [FirmListComponent],
   exports: [FirmListComponent],
   providers: [FirmService],
   imports: [
       MaterialModule,
       FlexLayoutModule,
       CommonModule,
       FormsModule
     ]
 )

export class FirmListModule  

我不确定我是否在我的规范文件中遗漏了一些代码来解释可观察的情况,或者我是否遗漏了其他东西?任何帮助表示赞赏。

公司服务

import  Observable  from 'rxjs/Rx';
import  Injectable  from '@angular/core';
import  AuthHttp  from 'angular2-jwt';
import  Response  from '@angular/http';
import  Store  from '@ngrx/store';
import  firmActions  from './firm.reducer';
import  FirmState  from './firm.state';

@Injectable()
export class FirmService 
   public stateObservable: Observable<FirmState>;

   constructor(private $http: AuthHttp, private store: Store<FirmState>) 
    // whatever reducer is selected from the store (in line below) is what the "this.store" refers to in our functions below.
    // it calls that specific reducer function
    // how do I define this line in my unit tests?
      this.stateObservable = this.store.select('firmReducer');
  

public getFirms(value?: string) 
    return this.$http.get('/api/firm').map((response: Response) => 
        this.store.dispatch(
            type: firmActions.GET_FIRMS,
            payload: response.json()
        );
        return;
    );


public firmSelected(firms) 
    // takes in an action, all below are actions - type and payload
    // dispatches to the reducer
    this.store.dispatch(
        type: firmActions.UPDATE_FIRMS,
        payload: firms
    );


public firmDeleted(firms) 
    this.store.dispatch(
        type: firmActions.DELETE_FIRMS,
        payload: firms
    );
  

我的公司组件html模板:

<md-card class="padding-none margin">
  <div class="toolbar" fxLayout="row" fxLayoutAlign="start center">
    <div fxFlex class="padding-lr">
      <div *ngIf="anySelected()">
        <button color="warn" md-raised-button (click)="deleteSelected()">Delete</button>
      </div>
      <div *ngIf="!anySelected()">
        <md-input-container floatPlaceholder="never">
          <input mdInput [(ngModel)]="searchText" (ngModelChange)="onChange($event)" type="text" placeholder="Search" />
        </md-input-container>
      </div>
    </div>
    <div class="label-list" fxFlex fxLayoutAlign="end center">
      <label class="label bg-purple600"></label>
      <span>EDF Model</span>
      <label class="label bg-green600"></label>
      <span>EDF QO</span>
      <label class="label bg-pink800"></label>
      <span>LGD Model</span>
      <label class="label bg-orange300"></label>
      <span>LGD QO</span>
    </div>
  </div>
  <md-card-content>
    <div class="loading-container" fxLayoutAlign="center center" *ngIf="loading">
      <md-spinner></md-spinner>
    </div>
    <div *ngIf="!loading">
      <table class="table">
        <thead>
          <tr>
            <th class="checkbox-col">
              <md-checkbox [(ngModel)]="selectAll" (click)="selectAllChanged()" aria-label="Select All"></md-checkbox>
            </th>
            <th>
              Firm Name
            </th>
            <th>
              Country
            </th>
            <th>
              Industry
            </th>
            <th>
              EDF
            </th>
            <th>
              LGD
            </th>
            <th>
              Modified
            </th>
            <th>
              Modified By
            </th>
          </tr>
        </thead>
        <tbody>
          <tr *ngFor="let firm of filteredFirms; let i = index" class="animate-repeat" [ngClass]="'active': firm.selected">
            <td class="checkbox-col">
              <md-checkbox [(ngModel)]="firm.selected" aria-label="firm.name" (change)="selectFirm(i)"></md-checkbox>
            </td>
            <td>firm.name</td>
            <td>firm.country</td>
            <td>firm.industry</td>
            <td>
              <span class="label bg-purple600">US 4.0</span>
              <span class="label bg-green600">US 4.0</span>
            </td>
            <td>
              <span class="label bg-pink800">US 4.0</span>
              <span class="label bg-orange300">US 4.0</span>
            </td>
            <td>firm.modifiedOn</td>
            <td>firm.modifiedBy</td>
          </tr>
        </tbody>
      </table>
    </div>
  </md-card-content>
</md-card>

【问题讨论】:

您是否尝试过采用shown in the docs的设置? 我已经尝试在 beforeEach 和测试本身之间来回移动代码,但是你推荐什么样的设置呢?我得到一个 null nativeElement 是因为我在设置之前测试 DOM 并准备好进行测试吗?我是否需要只模拟服务而不在提供程序中添加真实的服务? 好吧,阅读它 - 他们推荐 两个 beforeEach 部分,一个 async 一个不。在您当前的设置中,createComponent 似乎在您达到预期之前不会发生。 我正在使用 compileComponents.then() ,这与编写两个 beforeEach 函数相同。另外,如果我的第一个测试通过了,我认为模板已加载,但我不确定 【参考方案1】:

嗯,我在这里看到了一些可能是您的问题。为了清楚起见,您的错误即将出现:

de = fixture.debugElement.query(By.css('table'));

你尝试获取 de 的 nativeElement,它是 null。让我们假设你已经解决了这个问题并且没有理由不存在 - 你可以理智地检查自己并获取一些你“知道”应该存在的其他元素,但我真的认为这里的问题是试图获取对某些东西的引用在它存在之前。在这种情况下,您在尝试获取对 nativeElement 的引用之后检测到更改。如果您的表格按照我认为的方式填充,您需要先检测 changes(),然后获取对传播到 DOM 的内容的引用。确保您的 ngOnInit 还没有发生 - 当 TestBed 创建组件夹具时它不会触发,它发生在第一个 detectChanges() 上。

试试这个:

it('should have table headers', () => 
        fixture.detectChanges();
        de = fixture.debugElement.query(By.css('table'));
        el = de.nativeElement;        
        expect(el.textContent).toEqual('Firm Name');
    );

它更进一步 - 很多时候,表格或任何使用 Angular 动画的东西都需要您导入 BrowserAnimationsModule 或 NoopAnimationsModule。由于这是一个单元测试,我只需导入 NoopAnimationsModule,然后获取您的参考并根据需要执行测试。

好的,在您指出您在 ngOnInit 上遇到的错误之后,我明白您的问题是什么。

所以这个单元测试并不是为了测试那个服务。按照这种思路,你有几个选择。使用 spy 拦截对服务的调用,但由于它是一个属性,因此您必须使用 spyOnProperty。或者,您无论如何都可以使用您提供的存根。回顾你的原始帖子,我认为这就是你想要做的。我认为如果您以这种方式更改它可能会起作用:

beforeEach(async(() => 
     TestBed.configureTestingModule(
        imports: [MaterialModule, FormsModule, AppModule],
        declarations: [FirmListComponent],
        providers: [provide: FirmService, useClass: FirmStub]
    )
        .compileComponents()
        .then(() => 
            fixture = TestBed.createComponent(FirmListComponent);
            component = fixture.componentInstance;
            firmStub = fixture.debugElement.injector.get(FirmService);
        );
));

注意,您还需要在您的 FirmStub 上提供 stateObservable 属性,因为它正在 ngOninit 中访问。你可以相对直接地把它存根。

class FirmStub 
   public stateObservable: Observable<FirmState> = new Observable<FirmState>();
   public getFirms(value?: string): Observable<any> 
    return Observable.of(mockFirms);
    

如果没有 html 文件,我不确定您是否真的需要以某种方式填充该属性来测试模板,但如果不需要,该存根应该可以工作。如果您确实以某种方式需要它,只需让 FirmStub 提供更强大的属性。

您也可以通过将其添加到测试中来拦截 ngOnInit:

spyOn(component, 'ngOnInit');// this will basically stop anything from ngOnInit from actually running. 

希望这会有所帮助!

【讨论】:

是的,当我将 fixture.detectChanges() 移动到 el.nativElement 上方时遇到的问题是,我得到一个来自我的组件的“无法读取 null 的属性‘订阅’” ngOnInit 内部的代码,我实际上是在尝试将我的公司加载到页面,本质上,fixture.detectChanges 正在调用 ngOnInit,这给了我未定义的“stateObservable”值,然后终止测试。如果我取出fixture.detectChanges() 并测试是否已调用间谍或任何基本测试它通过正常。知道为什么我的组件中的“stateObservable”会为空吗?? 我添加了一些额外的代码来说明我现在所处的位置 - 我添加了一个 FirmStub 并添加了一个 overrideComponent 以尝试远离该组件但似乎不起作用..为什么会这样在 ngOnInit 内部变得未定义?? 对不起,我应该更清楚。我想说的是提高你的变更检测。这允许您的 DOM 更改传播,您可以获取对这些节点的引用。我会更新我的答案。 不管我把 fixture.detectChanges() 放在哪里,它都会出错,因为它调用了 ngOnInit,它在我的组件中运行到这一行:“this.firmService.stateObservable.subscribe”。所以 stateObservable 是未定义的。您是否偶然知道如何使用 ngrx 和 redux 模拟或定义 stateObservable? 我的错误。我现在明白你的意思了。因此,这并不是要测试该服务。按照这种思路,你可以做两件事中的一件。我会更新我的答案,因为这种响应格式不适合说明它。

以上是关于为使用 Observables 的 Angular 2 组件编写 Jasmine 测试的主要内容,如果未能解决你的问题,请参考以下文章

Angular 2 使用 observables 缓存 http 请求

在 Angular2 中使用 RxJS 链接 observables

Angular - 如何正确使用服务、HttpClient 和 Observables 在我的应用程序周围传递对象数组

在使用 NGRX 使用 observables 调用 API 之前检查 Angular Store 中的数据

Angular2 http observables - 如何使用未定义的类型

Angular 2 变化检测与 observables