ふるてつのぶろぐ

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

写真提供:福岡市

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

今日すること

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

前回は基本に立ち返る意味で簡単なマスタメンテ画面を作ってみました。
今回はさらにユニットテストをこの画面に追加していきたいと思います。

AngularのテストツールはJasmineが標準となっていますので、それをわたしも使用したいと思います。
ちなみにテストランナーはKarmaが標準となります。
わたしはJavaScriptユニットテストは初めてなので、結構な時間がかかりました。
今日はまずはServiceのユニットテストについて書いてみます。

まずテストを実行

まずはng testもしくはnpm testコマンドでテストを実行してみます。
いきなり最初からエラーが出ました。

 10% building 3/3 modules 0 active06 08 2019 21:14:10.755:WARN [karma]: No captured browser, open http://localhost:9876/
06 08 2019 21:14:10.770:INFO [karma]: Karma v1.7.1 server started at http://0.0.0.0:9876/
06 08 2019 21:14:10.771:INFO [launcher]: Launching browser Chrome with unlimited concurrency
 10% building 5/6 modules 1 active ...ront\sanrokumaru\src sync /\.spec\.ts$/06 08 2019 21:14:10.890:INFO [launcher]: Starting browser Chrome
 92% additional asset processing scripts-webpack-plugin× 「wdm」: Error: ENOENT: no such file or directory, open 'C:\papa\00.default\01.develop\Sanrokumaru\02_front\sanrokumaru\node_modules\jquery\dist\jquery.slim.min.js'
    at Object.openSync (fs.js:447:3)
    at Object.readFileSync (fs.js:349:35)
    at Storage.provideSync (C:\papa\00.default\01.develop\Sanrokumaru\02_front\sanrokumaru\node_modules\enhanced-resolve\lib\CachedInputFileSystem.js:98:13)
    at CachedInputFileSystem.readFileSync (C:\papa\00.default\01.develop\Sanrokumaru\02_front\sanrokumaru\node_modules\enhanced-resolve\lib\CachedInputFileSystem.js:259:32)
 ~ 中略 ~
    at C:\papa\00.default\01.develop\Sanrokumaru\02_front\sanrokumaru\node_modules\webpack\lib\Compilation.js:1171:4
    at AsyncSeriesHook.lazyCompileHook (C:\papa\00.default\01.develop\Sanrokumaru\02_front\sanrokumaru\node_modules\tapable\lib\Hook.js:154:20)           at Compilation.finish (C:\papa\00.default\01.develop\Sanrokumaru\02_front\sanrokumaru\node_modules\webpack\lib\Compilation.js:1163:28)
06 08 2019 21:15:10.893:WARN [launcher]: Chrome have not captured in 60000 ms, killing.
06 08 2019 21:15:11.099:INFO [launcher]: Trying to start Chrome again (1/2).
06 08 2019 21:16:11.102:WARN [launcher]: Chrome have not captured in 60000 ms, killing.
06 08 2019 21:16:11.299:INFO [launcher]: Trying to start Chrome again (2/2).
06 08 2019 21:17:11.304:WARN [launcher]: Chrome have not captured in 60000 ms, killing.
06 08 2019 21:17:11.488:ERROR [launcher]: Chrome failed 2 times (timeout). Giving up.

原因をネットで調べるとKarmaJasmineのバージョンが低いのではないか?
と思い、npm outdatedコマンドで最新のバージョンを確認しました。

npm outdated
Package                            Current   Wanted   Latest  Location   
@angular-devkit/build-angular      0.800.4  0.800.6  0.802.1  sanrokumaru
@angular/animations                  8.0.2    8.2.1    8.2.1  sanrokumaru
@angular/cdk                         8.0.1    8.1.2    8.1.2  sanrokumaru
@angular/cli                         8.0.4    8.2.1    8.2.1  sanrokumaru
@angular/common                      8.0.2    8.2.1    8.2.1  sanrokumaru
@angular/compiler                    8.0.2    8.2.1    8.2.1  sanrokumaru
@angular/compiler-cli                8.0.2    8.2.1    8.2.1  sanrokumaru
@angular/core                        8.0.2    8.2.1    8.2.1  sanrokumaru
@angular/forms                       8.0.2    8.2.1    8.2.1  sanrokumaru
@angular/language-service            8.0.2    8.2.1    8.2.1  sanrokumaru
@angular/material                    8.0.1    8.1.2    8.1.2  sanrokumaru
@angular/material-moment-adapter     8.0.1    8.1.2    8.1.2  sanrokumaru
@angular/platform-browser            8.0.2    8.2.1    8.2.1  sanrokumaru
@angular/platform-browser-dynamic    8.0.2    8.2.1    8.2.1  sanrokumaru
@angular/router                      8.0.2    8.2.1    8.2.1  sanrokumaru
@types/jasmine                      2.8.16   2.8.16    3.4.0  sanrokumaru
@types/node                          8.9.5    8.9.5   12.7.1  sanrokumaru
angular-in-memory-web-api            0.6.1    0.6.1    0.8.0  sanrokumaru
core-js                              2.6.4    2.6.9    3.2.0  sanrokumaru
jasmine-core                        2.99.1   2.99.1    3.4.0  sanrokumaru
karma                                1.7.1    1.7.1    4.2.0  sanrokumaru
karma-chrome-launcher                2.2.0    2.2.0    3.0.0  sanrokumaru
karma-coverage-istanbul-reporter     2.0.4    2.1.0    2.1.0  sanrokumaru
karma-jasmine                        1.1.2    1.1.2    2.0.1  sanrokumaru
karma-jasmine-html-reporter          0.2.2    0.2.2    1.4.2  sanrokumaru
npm                                  6.9.0   6.10.3   6.10.3  sanrokumaru
ts-node                              5.0.1    5.0.1    8.3.0  sanrokumaru
tslib                                1.9.3   1.10.0   1.10.0  sanrokumaru
tslint                               5.9.1    5.9.1   5.18.0  sanrokumaru
typescript                           3.4.5    3.4.5    3.5.3  sanrokumaru
zone.js                              0.9.1    0.9.1   0.10.1  sanrokumaru
最新化

わたしのローカルはKarmaJasmin-coreなどが軒並み低いようです。
Angularの4か5の頃にこのプロジェクトを作ったので、その時から更新されずに古いまま残っていたのかもしれません。
Angular CLIやcoreだけのバージョンを先にあげようとしたのですが、
そうこうしているうちにng update --all --forceを流してしまい、一気に全部のバージョンを上げてしまいました。
そもそも起動しなくなるのではと一瞬ドキっとしましたが無事でした、結果オーライというところでしょうか。
f:id:tetsufuru:20190813042832p:plain

BootStrapの名残りを除去

あともう1か所Angular.jsonファイルのtestの所に昔使っていたbootstrapの項目がまだ残っていました。
これらも邪魔していたようです、きれいに消しました。
f:id:tetsufuru:20190813043903p:plain これでテストが動くようになりました。

現状のテストをすべて無効化

テストは動くようになりましたが、下記のような状態で17/34が失敗とのこと。
自動で作られたコードでも失敗するんですね。
f:id:tetsufuru:20190813094831p:plain

そこでいったんすべてのテストコードを実行しないように、各テストクラス先頭のdescribeの部分をxdescribeに変更しました。
そして一つずつxを外しながらテストコードを追加/修整していくことにしました、最初はcompanyServiceです。
companyServiceはサーバからhttpCientで会社関連のデータを取得・新規作成・更新をするクラスです。
Componentよりも簡単で、テストコードを書きやすそうに思えたので選びました。

Serviceのテスト(CompanyService)

まずはテスト対象のCompanyServiceですが下記のようなコードになります、1メソッドのみ掲載します。
company.service.tsの内容

import { Observable, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AppConst } from 'src/app/app-const';
import { SearchCompanyListDto } from 'src/app/entity/company/search-company-list-dto';
import { ErrorMessageService } from 'src/app/service/message/error-message.service';
import { environment } from 'src/environments/environment';

import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { CompanyDto } from 'src/app/entity/company/company-dto';

@Injectable({
  providedIn: 'root'
})
export class CompanyService {
  private server = environment.production ? AppConst.URL_PROD_SERVER : AppConst.URL_DEV_SERVER;

  constructor(
    private http: HttpClient,
    private errorMessageService: ErrorMessageService,
    private readonly translateService: TranslateService
  ) { }

  public getCompanyList(httpParams: HttpParams): Observable {
    const webApiUrl: String = 'company-list';
    return this.http.get(this.server + webApiUrl, { params: httpParams })
      .pipe(
        catchError(error => {
          this.errorMessageService.add(this.translateService.instant('errMessage.http'));
          return of(null as any);
        })
      );
  }
  ~ 中略 ~
}

下記はデフォルトで作成されていたテストコードです。
company.service.spec.tsの内容

import { TestBed } from '@angular/core/testing';
import { CompanyService } from './company.service';

describe('CompanyService', () => {
  beforeEach(() => TestBed.configureTestingModule({}));

  it('should be created', () => {
    const service: CompanyService = TestBed.get(CompanyService);
    expect(service).toBeTruthy();
  });
});

このテストコード、ネットで意味を調べたりしばらく公式リファレンスを見ているとわかってきました。
describeはお約束で必ず書く。
beforeEachは毎回実行されるコード、junitでいうところのsetUp@Beforeのような感じと思いました。
TestBedはAngularのテスト用モジュールを作成する機能のようです。

テストコードの修正その1
import { TestBed } from '@angular/core/testing';
import { CompanyService } from './company.service';
import { HttpClient } from '@angular/common/http';
import { ErrorMessageService } from 'src/app/service/message/error-message.service';
import { TranslateService } from '@ngx-translate/core';

describe('CompanyService', () => {
  //  beforeEach(() => TestBed.configureTestingModule({}));

  let service: CompanyService;

  beforeEach(() => {
    service = new CompanyService(
      new HttpClient(null),
      new ErrorMessageService(),
      new TranslateService(null, null, null, null, null));
  });

  it('should be created', () => {
    expect(service).toBeTruthy();

    // const service: CompanyService = TestBed.get(CompanyService);
    // expect(service).toBeTruthy();
  });
});

今回テストを修整するにあたっては、公式リファレンスの「開発ワークフロー」→「テスト」→「サービスのテスト」を参考にしました。
https://angular.jp/guide/testing#%E3%82%B5%E3%83%BC%E3%83%93%E3%82%B9%E3%81%AE%E3%83%86%E3%82%B9%E3%83%88
ここのページは分量が多いためか重くてなかなか思うようにスクロール操作ができませんお気を付けを、わたしはのんびり見ましたけど。
ここではTestBedでは書いていなく、普通にクラスをnewしていましたので、そちらにわたしも寄せました。 Javajunitに近い感覚で書けそうだという理由もあります。
テストコードについては初期化に失敗していますので、呼び出し時の引数が不足していると思います。
そこでCompanyServiceをnewするときにHttpClient、ErrorMessageService、TranslateServiceの3つのサービスを引数にいれます。
これでとりあえずはテストが通るようになりました。

テストコードの修正その2

次はHttpClientとTranslateServiceをモックに差替えて、#getCompanyListメソッドをテストします。
もう一つ独自に作ったErrorMessageServiceはモックにする必要がありませんでした。

import { CompanyService } from './company.service';
import { ErrorMessageService } from 'src/app/service/message/error-message.service';
import { SearchCompanyListDto } from 'src/app/entity/company/search-company-list-dto';
import { SearchCompanyDto } from 'src/app/entity/company/search-company-dto';
import { HttpErrorResponse } from '@angular/common/http';
import { asyncError, asyncData } from 'src/app/testing/async-observable-helpers';
import { CompanyDto } from 'src/app/entity/company/company-dto';

describe('CompanyService', () => {
  let companyService: CompanyService;
  let httpClientSpy: { get: jasmine.Spy, post: jasmine.Spy, put: jasmine.Spy };
  let translateServiceSpy: { instant: jasmine.Spy };

  beforeEach(() => {
    httpClientSpy = jasmine.createSpyObj('HttpClient', ['get', 'post', 'put']);
    translateServiceSpy = jasmine.createSpyObj('TranslateService', ['instant']);
    companyService = new CompanyService(
      httpClientSpy,
      new ErrorMessageService(),
      translateServiceSpy);
  });

  it('should be created', () => {
    expect(companyService).toBeTruthy();
  });

  it('#getCompanyList:should return expected SearchCompanyListDto (HttpClient called once)', () => {
    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;

    httpClientSpy.get.and.returnValue(asyncData(expectedSearchCompanyListDto));

    companyService.getCompanyList(null).subscribe(
      searchCompanyList => expect(searchCompanyList).toEqual(expectedSearchCompanyListDto, 'expected searchCompanyDto'),
      fail
    );
    expect(httpClientSpy.get.calls.count()).toBe(1, 'one call');
  });

  it('#getCompanyList:when the server returns 400', () => {
    const errorResponse = new HttpErrorResponse({
      error: 'test 400 error',
      status: 400, statusText: 'Bad Request'
    });

    httpClientSpy.get.and.returnValue(asyncError(errorResponse));

    companyService.getCompanyList(null).subscribe(
      searchCompanyList => expect(searchCompanyList).toBe(null)
    );
  });

let httpClientSpy: { get: jasmine.Spy, post: jasmine.Spy, put: jasmine.Spy };は、HttpClientモックの宣言です。
今回はgetのことのみ書きますが、getpostputの3メソッドをモックに変えます。
そしてbeforeEachの中でCompanyServiceをnewする箇所の引数を<any>httpClientSpyに変えます。
テストメソッドit('#getCompanyList:should return expected xxxxxの中ではexpectedSearchCompanyListDtoを検証用に作ります。
そして下記のようにhttpCientモックのgetメソッドが呼ばれたときに返すようにします。
httpClientSpy.get.and.returnValue(asyncData(expectedSearchCompanyListDto));
ちなみにit('#getCompanyList:when the server returns 400は通信エラー時のテストです。

AsyncObservableHelpersを作成

少し順序が逆転しましたが、今回AsyncObservableHelpersというテスト用のHelperクラスを作成しました。
リファレンスの下記を参照ください。
https://angular.jp/guide/testing.en
ng g class testing/async-observable-helpersのコマンドで作成します。

import { defer } from 'rxjs';
export class AsyncObservableHelpers {
}
export function asyncData(data: T) {
  return defer(() => Promise.resolve(data));
}
export function asyncError(errorObject: any) {
  return defer(() => Promise.reject(errorObject));
}
カバレッジレポート

上記と同じ要領でcompanyServiceの残りのメソッドのテストコードも追加します。
すると下記のように今回追加した10件のテストはグリーンになりました。
f:id:tetsufuru:20190813120807p:plain 下記のコマンドでカバレッジもとってみます。
ng test --code-coverage f:id:tetsufuru:20190813121357p:plain まだまだカバレッジが足りませんが、下記のようにcompanyServiceだけは100%となりました。
f:id:tetsufuru:20190813121344p:plain

今日の感想

今日はJasmineにチャレンジしてみました。
こんな感じで書くんですねー!
大変勉強になりました、よし次はcomponentのテストだ。

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