ふるてつのぶろぐ

福岡在住のエンジニアです。

写真提供:福岡市

Angular8 で Web アプリを作ろう - Jasmine - Componentのテスト その1

今日すること

こんにちは、ふるてつです。

前回はServiceJasmineユニットテストを追加していきましたが、
今回はさらにComponentに対して追加していきたいと思います。

テストをするComponentの概要

今回テストをするのは会社マスターの一覧を検索する画面です。
すごく簡単です、検索条件は「会社名」、「会社名カナ」、「削除フラグ」の3です。

画面は下記のようになります、レイアウトはざっとしか作っていません。
別途修正していくと思いますが、今日はこの感じのまま、ご説明します。
f:id:tetsufuru:20190831161753p:plain

テストしたいアクションとしては「新規」「クリア」「検索」の3種類のボタンのクリック。
それと一覧の上をクリックして明細にジャンプするアクションです。

テストをするComponentのソースコード

ソースコードは下記のようになります。
長いのでたたんでおきます。

テストをするComponent(company-list.component.ts)ソースコードはこちら

import { merge, of } from 'rxjs';
import { catchError, map, startWith, switchMap } from 'rxjs/operators';
import { AppConst } from 'src/app/app-const';
import { SearchCompanyDto } from 'src/app/entity/company/search-company-dto';
import { CompanyService } from 'src/app/service/company/company.service';

import { HttpParams } from '@angular/common/http';
import { Component, OnInit, ViewChild } from '@angular/core';
import { FormBuilder, FormControl } from '@angular/forms';
import { MatPaginator } from '@angular/material/paginator';
import { Title } from '@angular/platform-browser';
import { Router } from '@angular/router';

@Component({
  selector: 'app-company-list',
  templateUrl: './company-list.component.html',
  styleUrls: ['./company-list.component.css']
})

export class CompanyListComponent implements OnInit {
  // Timezone and Locale
  locale: string;
  timezone: string;

  // Search criteria controls
  companyName = new FormControl('', []);
  companyKana = new FormControl('', []);
  deleted = new FormControl(false);

  // Form builder
  mainForm = this.formBuilder.group({
    companyName: this.companyName,
    companyKana: this.companyKana,
    deleted: this.deleted
  });

  // Search result dto
  searchCompanyDtos: SearchCompanyDto[];

  // Material tables header
  displayCompanyListColumns: string[] = [
    'companySeq',
    'companyName',
    'companyKana',
    'companyAddress1',
    'deleted',
    'createUser',
    'createTime',
    'updateUser',
    'updateTime',
  ];

  // Loading and pagenation
  isLoadingResults = false;
  resultsLength = 0;
  @ViewChild(MatPaginator, { static: true }) public paginator: MatPaginator;

  constructor(
    private formBuilder: FormBuilder,
    private companyService: CompanyService,
    private title: Title,
    private router: Router
  ) { }

  ngOnInit() {
    this.setUpLocale();
    this.setUpBrowserTitle();
  }

  /**
   * Sets the locale from appConst.
   */
  private setUpLocale() {
    this.locale = AppConst.LOCALE;
    this.timezone = AppConst.TIMEZONE;
  }

  /**
   * Sets screen title.
   */
  private setUpBrowserTitle() {
    this.title.setTitle(AppConst.APP_TITLE + AppConst.APP_SUB_TITLE_COMPANY_LIST);
  }

  /**
   * Clicks the new registration button.
   */
  private onNew() {
    this.router.navigate(['/company-detail/new']);
  }

  /**
   * Click the clear button.
   */
  private onClear() {
    this.clearSearchCondition();
    this.clearSearchResultList();
  }

  /**
   * Searches for customer informations.
   */
  private onSearch() {
    merge(this.paginator.page)
      .pipe(
        startWith({}),
        switchMap(() => {
          this.isLoadingResults = true;
          return this.companyService.getCompanyList(this.createHttpParams());
        }),

        map(data => {
          // Flip flag to show that loading has finished.
          this.isLoadingResults = false;
          this.resultsLength = data.resultsLength;
          this.paginator.pageIndex = data.pageIndex;
          return data.searchCompanyDtos;
        }),

        catchError(() => {
          this.isLoadingResults = false;
          return of(null as SearchCompanyDto[]);
        })

      ).subscribe(data => this.searchCompanyDtos = data);
  }

  /**
   * Creates search criterias.
   */
  private createHttpParams(): HttpParams {
    const conditions = {
      companyName: this.companyName.value,
      companyKana: this.companyKana.value,
      deleted: this.deleted.value.toString(),
      pageSize: this.paginator.pageSize.toString(),
      pageIndex: this.paginator.pageIndex.toString()
    };

    const paramsOptions = { fromObject: conditions };
    const params = new HttpParams(paramsOptions);

    return params;
  }

  /**
   * Clears search criteria controls.
   */
  private clearSearchCondition() {
    this.companyName.setValue('');
    this.companyKana.setValue('');
    this.deleted.setValue(false);
  }

  /**
   * Clears search result list.
   */
  private clearSearchResultList() {
    this.searchCompanyDtos = null;
    this.resultsLength = 0;
  }

  /**
   * Clicks search result.
   * @param searchCompanyDto cliked company entity.
   */
  private listClicked(searchCompanyDto: SearchCompanyDto) {
    this.router.navigate(['/company-detail', searchCompanyDto.companySeq]);
  }
}

デフォルトのテストコードを確認

デフォルトでできているテストコードは下記のようになります。

import { TestBed } from '@angular/core/testing';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';

import { CompanyListComponent } from './company-list.component';

describe('CompanyListComponent', () => {
  let component: CompanyListComponent;
  let fixture: ComponentFixture;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [CompanyListComponent]
    })
      .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(CompanyListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

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

component初期化の修正

ng testコマンドでテストを実行するとcomponentの初期化('should create')からまず失敗します。
f:id:tetsufuru:20190831175021p:plain

なぜ失敗するかと言いますとcompany-list.componentにはわたしが新たにサービスを追加して、 コンストラクタでインジェクションしているからです。
メッセージをよく読むとたしかにRouterのDIができていない模様です。
ではそのあたりからまず修正してきます、箇条書きにすると下記になります。

  1. テストの先頭でlet routerSpy: { navigate: jasmine.Spy };を追加
  2. beforeEach(async(() => {}の中で、
    routerSpy = jasmine.createSpyObj('Router', ['navigate']);でspyを作成
  3. providers: []の中に{ provide: Router, useValue: routerSpy }を追加

  4. 上記と同様にcompanyServiceSpytranslatePipeSpyも追加
    translatePipeはhtmlの中で他言語のパイプを使っているため登場しました

  5. FormBuilderTitleは今回のテストではそのまま使用できそうなのでspyではなくそのものをprovidersに追加

  6. わたしの環境ではReactiveFormsMaterialを使用しており、imports文が必要でした
    imports: [ReactiveFormsModule, BrowserAnimationsModule, MaterialModule, HttpClientModule,

  7. 最後にschemasプロパティを指定します
    下記のように設定すると認識できない要素と属性を無視するようになります
    schemas: [NO_ERRORS_SCHEMA],

まとめると下記のようにいったん変わりました。
これでテストが通るようになりました。
translatePipeSpyなど無駄にspyを作っているような気が若干しますが良しとします…

  let component: CompanyListComponent;
  let fixture: ComponentFixture;

  let routerSpy: { navigate: jasmine.Spy };
  let companyServiceSpy: { getCompanyList: jasmine.Spy };
  let translatePipeSpy: { translate: jasmine.Spy };

  beforeEach(async(() => {
    routerSpy = jasmine.createSpyObj('Router', ['navigate']);
    companyServiceSpy = jasmine.createSpyObj('CompanyService', ['getCompanyList']);
    translatePipeSpy = jasmine.createSpyObj('TranslatePipe', ['translate']);

    TestBed.configureTestingModule({
      declarations: [CompanyListComponent],
      providers: [
        FormBuilder,
        Title,
        { provide: Router, useValue: routerSpy },
        { provide: CompanyService, useValue: companyServiceSpy },
        { provide: TranslatePipe, useValue: translatePipeSpy },
      ],
    })
      .compileComponents();
  }));

テストケースの追加

次に新たにテストケースを追加します。

●「新規」ボタンのアクションテスト

この画面では「新規」ボタンをクリックするとonNew()が呼ばれます。
onNew()はprivateのメソッドなのでcomponent['onNew']();で呼び出します。
そしてonNew()を実行した後にrouterSpyの#navigateメソッドが1回呼ばれたのを確認します。

  it('should navigate when called onNew', () => {
    component['onNew']();
    expect(routerSpy.navigate.calls.count()).toBe(1, 'one call');
  });

●「クリア」ボタンのアクションテスト

次はonClear()メソッドです、「クリア」ボタンをクリックした時に呼ばれます。
あらかじめ検索条件のコントロールに何か値を設定しておき、onClearメソッドを呼びだします。
そしてそれぞれの検索条件のコントロールが初期値に戻っているかを確認します。

  it('should navigate when called onClear', () => {
    fillSearchCriteria(component);
    component['onClear']();
    expect(component.companyName.value).toEqual('');
    expect(component.companyKana.value).toEqual('');
    expect(component.deleted.value).toEqual(false);
  });
~ 中略 ~
function fillSearchCriteria(component: CompanyListComponent) {
  component.companyName.setValue('a');
  component.companyKana.setValue('a');
  component.deleted.setValue(true);
}

●一覧クリックアクションのテスト

一覧の上をクリックすると詳細画面にジャンプします。
上記のonNewの時とほぼ同じ感じになりました。

  it('should navigate when called listClicked', () => {
    const searchCompanyDto: SearchCompanyDto = new SearchCompanyDto();
    searchCompanyDto.companySeq = BigInt('1');
    component['listClicked'](searchCompanyDto);
    expect(routerSpy.navigate.calls.count()).toBe(1, 'one call');
  });

●「検索」ボタンのアクションテスト

最後にonSearch()のテストです、こちらはすこし長くなりました。
このonSearch()メソッドは非同期await component['onSearch']();で呼び出すようにします。
メソッドの中でsubscribeしているからか非同期でないと思ったような結果が得られません。

まずはcompanyServiceSpyのgetCompanyList()がエラーを返すようにして、元ソースの中にあるcatchError(() => {}の部分をテストします。

  it('should catch error when called onSearch', async () => {
    await component['onSearch']();
    companyServiceSpy.getCompanyList.and.returnValue(throwError(''));
    expect(component.isLoadingResults).toEqual(false);
  });

次は正常終了の場合です。
companyServiceSpyのgetCompanyList()がダミーのdtoを返すようにしています。
最初はダミーのdtoは返さずにonSearch()メソッドを呼び出していたのですが、そのやり方だと元ソース中にあるmap(data => {}の個所がテストできませんでした。
そこも網羅するためにダミーのdtoを返すようにしました。

  it('should call map operator when called onSearch', async () => {
    const expectedSearchCompanyListDto: SearchCompanyListDto = new SearchCompanyListDto();
    const searchCompanyDto: SearchCompanyDto[] =
      [{
        companySeq: BigInt('1'),
        companyName: 'companyName',
        companyKana: 'companyKana',
        companyAddress1: 'companyAddress1',
        deleted: '',
        createUser: 'createUser',
        createTime: new Date,
        updateUser: 'updateUser',
        updateTime: new Date
      }];
    expectedSearchCompanyListDto.searchCompanyDtos = searchCompanyDto;
    companyServiceSpy.getCompanyList.and.returnValue(asyncData(expectedSearchCompanyListDto));

    await component['onSearch']();
    expect(component.searchCompanyDtos).toEqual(expectedSearchCompanyListDto.searchCompanyDtos);
    expect(component.isLoadingResults).toEqual(false);
    expect(companyServiceSpy.getCompanyList.calls.count()).toBe(1, 'one call');
  });

これで下記のようにSkipさせているもの以外はテストがすべて通るようになりました。

f:id:tetsufuru:20190901014803p:plain

DOM側のテストコードをまだ説明していませんが、 これまでのテストコードを下に記します、みなさまのご参考になればと思います。

テストコード(company-list.component.spec.ts)はこちら

import { throwError } from 'rxjs';
import { AppConst } from 'src/app/app-const';
import { HttpLoaderFactory } from 'src/app/app.module';
import { SearchCompanyDto } from 'src/app/entity/company/search-company-dto';
import { SearchCompanyListDto } from 'src/app/entity/company/search-company-list-dto';
import { CompanyService } from 'src/app/service/company/company.service';
import { asyncData } from 'src/app/testing/async-observable-helpers';
import { MaterialModule } from 'src/app/utils/material/material.module';

import { HttpClient, HttpClientModule, HttpParams } from '@angular/common/http';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { By, Title } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { Router } from '@angular/router';
import { TranslateLoader, TranslateModule, TranslatePipe } from '@ngx-translate/core';

import { CompanyListComponent } from './company-list.component';

describe('CompanyListComponent', () => {
  let component: CompanyListComponent;
  let fixture: ComponentFixture;

  let routerSpy: { navigate: jasmine.Spy };
  let companyServiceSpy: { getCompanyList: jasmine.Spy };
  let translatePipeSpy: { translate: jasmine.Spy };

  beforeEach(async(() => {
    routerSpy = jasmine.createSpyObj('Router', ['navigate']);
    companyServiceSpy = jasmine.createSpyObj('CompanyService', ['getCompanyList']);
    translatePipeSpy = jasmine.createSpyObj('TranslatePipe', ['translate']);

    TestBed.configureTestingModule({
      declarations: [CompanyListComponent],
      schemas: [NO_ERRORS_SCHEMA],
      imports: [ReactiveFormsModule, BrowserAnimationsModule, MaterialModule, HttpClientModule,
        TranslateModule.forRoot({
          loader: {
            provide: TranslateLoader,
            useFactory: HttpLoaderFactory,
            deps: [HttpClient]
          }
        }),
      ],
      providers: [
        FormBuilder,
        Title,
        { provide: Router, useValue: routerSpy },
        { provide: CompanyService, useValue: companyServiceSpy },
        { provide: TranslatePipe, useValue: translatePipeSpy },
      ],
    })
      .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(CompanyListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

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

  /**
   * Type Script test cases.
   */
  it('locale and timezone should be set when called ngOnInit', () => {
    component.ngOnInit();
    expect(component.locale).toEqual(AppConst.LOCALE);
    expect(component.timezone).toEqual(AppConst.TIMEZONE);
  });

  it('locale and timezone should be set when called setUpLocale', () => {
    component['setUpLocale']();
    expect(component.locale).toEqual(AppConst.LOCALE);
    expect(component.timezone).toEqual(AppConst.TIMEZONE);
  });

  // TBD
  // it('browser title should be set when called setUpBrowserTitle', () => {
  //   component['setUpBrowserTitle']();
  //   expect(component.locale).toEqual(AppConst.LOCALE);
  // });

  it('should navigate when called onNew', () => {
    component['onNew']();
    expect(routerSpy.navigate.calls.count()).toBe(1, 'one call');
  });

  it('should navigate when called onClear', () => {
    fillSearchCriteria(component);
    component['onClear']();
    expect(component.companyName.value).toEqual('');
    expect(component.companyKana.value).toEqual('');
    expect(component.deleted.value).toEqual(false);
  });

  it('should call map operator when called onSearch', async () => {
    const expectedSearchCompanyListDto: SearchCompanyListDto = new SearchCompanyListDto();
    const searchCompanyDto: SearchCompanyDto[] =
      [{
        companySeq: BigInt('1'),
        companyName: 'companyName',
        companyKana: 'companyKana',
        companyAddress1: 'companyAddress1',
        deleted: '',
        createUser: 'createUser',
        createTime: new Date,
        updateUser: 'updateUser',
        updateTime: new Date
      }];
    expectedSearchCompanyListDto.searchCompanyDtos = searchCompanyDto;
    companyServiceSpy.getCompanyList.and.returnValue(asyncData(expectedSearchCompanyListDto));

    await component['onSearch']();
    expect(component.searchCompanyDtos).toEqual(expectedSearchCompanyListDto.searchCompanyDtos);
    expect(component.isLoadingResults).toEqual(false);
    expect(companyServiceSpy.getCompanyList.calls.count()).toBe(1, 'one call');
  });

  it('should catch error when called onSearch', async () => {
    await component['onSearch']();
    companyServiceSpy.getCompanyList.and.returnValue(throwError(''));
    expect(component.isLoadingResults).toEqual(false);
  });

  it('should navigate when called listClicked', () => {
    const searchCompanyDto: SearchCompanyDto = new SearchCompanyDto();
    searchCompanyDto.companySeq = BigInt('1');
    component['listClicked'](searchCompanyDto);
    expect(routerSpy.navigate.calls.count()).toBe(1, 'one call');
  });

  /**
   * DOM test cases.
   */
 ~ 中略 ~
});
function fillSearchCriteria(component: CompanyListComponent) {
  component.companyName.setValue('a');
  component.companyKana.setValue('a');
  component.deleted.setValue(true);
}

今日の感想


今日はComponentのテストについて書きました。
Componentの中でもどちらかと言えばType Script側のコードをテストしたことになります。
あと別途DOM側のテストが残っていますが、そちらについては書ききれなかったので「その2」で書きたいと思います。

では、今日もお疲れ様でした。