Angular8 で Web アプリを作ろう - Jasmine - Componentのテスト その1
今日すること
こんにちは、ふるてつです。
前回はService
にJasmine
のユニットテストを追加していきましたが、
今回はさらにComponent
に対して追加していきたいと思います。
テストをするComponentの概要
今回テストをするのは会社マスターの一覧を検索する画面です。
すごく簡単です、検索条件は「会社名」、「会社名カナ」、「削除フラグ」の3です。
画面は下記のようになります、レイアウトはざっとしか作っていません。
別途修正していくと思いますが、今日はこの感じのまま、ご説明します。
テストしたいアクションとしては「新規」「クリア」「検索」の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')からまず失敗します。
なぜ失敗するかと言いますとcompany-list.componentにはわたしが新たにサービスを追加して、
コンストラクタでインジェクションしているからです。
メッセージをよく読むとたしかにRouterのDI
ができていない模様です。
ではそのあたりからまず修正してきます、箇条書きにすると下記になります。
- テストの先頭で
let routerSpy: { navigate: jasmine.Spy };
を追加 beforeEach(async(() => {}
の中で、
routerSpy = jasmine.createSpyObj('Router', ['navigate']);
でspyを作成providers: []
の中に{ provide: Router, useValue: routerSpy }
を追加上記と同様に
companyServiceSpy
とtranslatePipeSpy
も追加
translatePipe
はhtmlの中で他言語のパイプを使っているため登場しましたFormBuilder
とTitle
は今回のテストではそのまま使用できそうなのでspyではなくそのものをprovidersに追加わたしの環境では
ReactiveForms
やMaterial
を使用しており、imports文が必要でした
imports: [ReactiveFormsModule, BrowserAnimationsModule, MaterialModule, HttpClientModule,
最後に
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させているもの以外はテストがすべて通るようになりました。
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」で書きたいと思います。
では、今日もお疲れ様でした。