お茶漬けびより

"あなたに教わったことを、噛んでいるのですよ" 五等分の花嫁 7巻 「最後の試験が五月の場合」より

Angular の HttpClient を使うサービスクラスのテスト

最近、TypeScript の楽しさに気づきました。

HttpTestingModuleHttpTestingController を使ったテストの書き方です。

テスト対象を用意するために https://angular.jp/tutorial/toh-pt6 で公開されているプロジェクトを使います。

このプロジェクトの HeroService をテストします。

概要

HttpClientTestingModuleHttpClientTestingModule を使えば、テスト対象のクラスの動きだけでなく、リクエストした内容が期待通りの結果になっているか検証できる。

公式が詳しいのでもっと知りたい人は、公式ドキュメントの HTTPリクエストのテスト を参照しよう。

準備

これ を落としてきて、任意の場所で展開します。

動作することを確認し、テストも動かして全て通ることを確認します。

hero.service.spec.ts を作成し、以下のように書きます。

import { MessageService } from "./message.service";
import { HttpClient } from '@angular/common/http';
import { HeroService } from './hero.service';

describe('HeroService', () => {
  let httpClientSpy = jasmine.createSpyObj<HttpClient>(["get"]);
  let messageServiceSpy = jasmine.createSpyObj<MessageService>(["add"]);
  let service: HeroService;

  beforeEach(() => {
    service = new HeroService(httpClientSpy, messageServiceSpy)
  });

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

テストが通っているか確認して、問題なければ次に進みましょう。

ちなみにここでやっているのは、サービスに必要な2つのインスタンスhttpClientSpy, messageServiceSpy)のスパイオブジェクトを作成し、そのスパイオブジェクトを引数にサービスを生成しています。

TetBed を使った方法に変える

引数で渡すのは面倒なので、TestBed を使って生成するように変更します。

import { MessageService } from "./message.service";
import { HttpClient } from '@angular/common/http';
import { HeroService } from './hero.service';
import { TestBed } from '@angular/core/testing';

describe('HeroService', () => {
  let httpClientSpy = jasmine.createSpyObj<HttpClient>(["get"]);
  let messageServiceSpy = jasmine.createSpyObj<MessageService>(["add"]);
  let service: HeroService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        { provide: HttpClient, useValue: httpClientSpy },
        { provide: MessageService, useValue: messageServiceSpy }
      ],
    });
    service = TestBed.inject(HeroService);

  });

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

変更箇所は、以下です。

import { HttpClient } from '@angular/common/http';
+ import { TestBed } from '@angular/core/testing';



  beforeEach(() => {
+   TestBed.configureTestingModule({
+     providers: [
+       { provide: HttpClient, useValue: httpClientSpy },
+       { provide: MessageService, useValue: messageServiceSpy }
+     ],
+   });
+   service = TestBed.inject(HeroService);
-   service = new HeroService(httpClientSpy, messageServiceSpy)

これも問題なく通るはずです。本題ではないので、説明は省きますが、何をやっているか分からない場合、以下を参照するのをオススメします。

サービスのテスト

HttpClientTestingModule と HttpClientTestingModule を使う

ここが今回の本題です。

以下のように変えます。

import { MessageService } from "./message.service";
import { HttpClient } from '@angular/common/http';
import { HeroService } from './hero.service';
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

describe('HeroService', () => {
  let httpClient: HttpClient;
  let httpTestingController: HttpTestingController;
  let messageServiceSpy = jasmine.createSpyObj<MessageService>(["add"]);
  let service: HeroService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [ HttpClientTestingModule ],
      providers: [
        { provide: MessageService, useValue: messageServiceSpy }
      ],
    });
    service = TestBed.inject(HeroService);

    httpClient = TestBed.inject(HttpClient);
    httpTestingController = TestBed.inject(HttpTestingController);
  });

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

変更箇所は、以下です。

import { TestBed } from '@angular/core/testing';
+ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

describe('HeroService', () => {
-  let httpClientSpy = jasmine.createSpyObj<HttpClient>(["get"]);
+  let httpClient: HttpClient;
+  let httpTestingController: HttpTestingController;
...
      providers: [
-       { provide: HttpClient, useValue: httpClientSpy },
        { provide: MessageService, useValue: messageServiceSpy }
...
    service = TestBed.inject(HeroService);

+   httpClient = TestBed.inject(HttpClient);
+   httpTestingController = TestBed.inject(HttpTestingController);
  });

とりあえずテストを通るか確認します。HttpClientTestingModule, HttpTestingController を使うとリクエストした内容の検証までできるようになります。次はそれを試すためにテストを追加します。

リクエストの内容を検証するテストを作る

以下のテストを追加して、通るか確認します。

  it('サーバからヒーローを取得できる', async () => {
    const expected: Hero[] = [
      { id: 11, name: 'Dr Nice' },
      { id: 12, name: 'Narco' },
    ];

    service.getHeroes().subscribe(actual => {
      expect(actual).toEqual(expected);
    });

    const req = httpTestingController.expectOne('api/heroes');

    expect(req.request.method).toEqual('GET');

    req.flush(expected);

    httpTestingController.verify();
  });

解説

最初に期待値を作っています。この期待値は、同時にサーバから返ってくる値にもなります(req.flush(expected); で行っています)。

そのあと、service.getHeroes() を実行して、サブスクライブしています。データを受け取るとそのデータが期待通りになっているか検証しています(expect(actual).toEqual(expected); の箇所です)。

この検証の処理は、まだ行われません。サーバからの応答は、HttpTestingController で制御できます(厳密には、TestResult?)。

先の処理で、リクエストは投げられたので、その内容が httpTestingController で取得できます。const req = httpTestingController.expectOne('api/heroes'); がそれです。

expectOne には URL を指定していますが、URL だけでマッチングさせるのが不都合な場合、独自に定義することができます。詳しくは、公式のドキュメントに委ねます。

HttpTestingController

Custom request expectations

expect(req.request.method).toEqual('GET'); は見たら分かる通り、GET や POST などの期待するメソッドになっているか検証しています。

req.flush(expected); でサーバからの応答を擬似的に行っています。ここで指定した引数が actual に入ってきます。この関数を抜けたあとにデータが流れて、expect(actual).toEqual(expected); が行われるはずです。

最後に httpTestingController.verify(); で未処理のリクエストがないことを確認しています。 afterEach で行うのが一般的みたいです。残っている場合は、テストが失敗します。

すごく雑な解説になりましたが、以上です。他にもエラーのテスト同じAPIの複数のリクエストなどの検証ができるみたいなので、知りたい方は、公式ドキュメントを参照してみるといいと思います。

最終的なコード

import { MessageService } from "./message.service";
import { HttpClient } from '@angular/common/http';
import { HeroService } from './hero.service';
import { Hero } from './hero';
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpClientTestingModule } from '@angular/common/http/testing';

describe('HeroService', () => {
  let httpClient: HttpClient;
  let httpTestingController: HttpTestingController;

  let messageServiceSpy = jasmine.createSpyObj<MessageService>(["add"]);
  let service: HeroService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [ HttpClientTestingModule ],
      providers: [
        { provide: MessageService, useValue: messageServiceSpy }
      ],
    });
    service = TestBed.inject(HeroService);

    httpClient = TestBed.inject(HttpClient);
    httpTestingController = TestBed.inject(HttpTestingController);
  });

  it('サーバからヒーローを取得できる', async () => {
    const expected: Hero[] = [
      { id: 11, name: 'Dr Nice' },
      { id: 12, name: 'Narco' },
    ];

    service.getHeroes().subscribe(actual => {
      expect(actual).toEqual(expected);
    });

    const req = httpTestingController.expectOne('api/heroes');

    expect(req.request.method).toEqual('GET');

    req.flush(expected);

    httpTestingController.verify();
  });
});