noxi雑記

.NET、Angularまわりの小ネタブログ

Angular HttpClientのテストコードを書いてみる

AngularのHttpClientを使用するサービスのテスト方法メモです。サービスからHttpClientを使用してAPIコールをしつつ、接続先のベースURLはHttpInterceptorから設定している状況を想定しています。


試した環境

     _                      _                 ____ _     ___
    / \   _ __   __ _ _   _| | __ _ _ __     / ___| |   |_ _|
   / △ \ | '_ \ / _` | | | | |/ _` | '__|   | |   | |    | |
  / ___ \| | | | (_| | |_| | | (_| | |      | |___| |___ | |
 /_/   \_\_| |_|\__, |\__,_|_|\__,_|_|       \____|_____|___|
                |___/


Angular CLI: 8.1.2
Node: 12.5.0
OS: win32 x64
Angular: 8.1.3
... animations, common, compiler, compiler-cli, core, forms
... language-service, platform-browser, platform-browser-dynamic
... router

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.801.2
@angular-devkit/build-angular     0.801.2
@angular-devkit/build-optimizer   0.801.2
@angular-devkit/build-webpack     0.801.2
@angular-devkit/core              8.1.2
@angular-devkit/schematics        8.1.2
@angular/cli                      8.1.2
@ngtools/webpack                  8.1.2
@schematics/angular               8.1.2
@schematics/update                0.801.2
rxjs                              6.4.0
typescript                        3.4.5
webpack                           4.35.2

サービスの作成

冒頭に書いたとおり、接続先のベースURLをHttpInterceptorを使用して差し替えるケースを想定しています。作成するサービスはHttpInterceptorと実際にHttpClientを使用するサービスの2つです。

ベースURLを書き換えるHttpInterceptor

HttpInterceptorの実装

サービスとspecファイルはCLIで生成します。ベースURLは @angular/common/APP_BASE_HREF を使用します。

import { APP_BASE_HREF } from '@angular/common';
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';

const REPLACE_STRING = '{baseHref}';

/**
 * URL中の `{baseHref}` 文字列を `@angular/common/APP_BASE_HREF` に差し替えるHttpInterceptor
 */
@Injectable()
export class BaseHrefHttpInterceptor implements HttpInterceptor {

  constructor(@Inject(APP_BASE_HREF) private readonly baseHref: string) {
  }

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    if (req.url.indexOf(REPLACE_STRING) !== -1) {
      req = req.clone({
        url: req.url.replace(REPLACE_STRING, this.baseHref)
      });
    }

    return next.handle(req);
  }
}

HttpInterceptorのテスト

Interceptorが APP_BASE_HREF を使用するため、beforeEachで忘れずprovideします。URLに {baseHref} が含まれるパターン、含まれないパターンの2通りをテストしてみます。

import { APP_BASE_HREF } from '@angular/common';
import { HttpHandler, HttpRequest, HttpResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { BaseHrefHttpInterceptor } from './base-href-http-interceptor.service';

describe('BaseHrefHttpInterceptor', () => {

  let interceptor: BaseHrefHttpInterceptor;
  let handlerSpy: jasmine.SpyObj<HttpHandler>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        BaseHrefHttpInterceptor,
        { provide: APP_BASE_HREF, useValue: '/test/', }
      ]
    });
    interceptor = TestBed.get(BaseHrefHttpInterceptor);
    handlerSpy = jasmine.createSpyObj('HttpHandler', ['handle']);
  });

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

  it('should replace {baseHref}', async done => {
    handlerSpy.handle.and.returnValue(of<any>(new HttpResponse()));

    const req = new HttpRequest('GET', '{baseHref}api/hoge');
    const result = await interceptor.intercept(req, handlerSpy).toPromise();
    expect(result).toEqual(jasmine.any(HttpResponse));
    expect(handlerSpy.handle.calls.first().args[0]).toEqual(req.clone({ url: '/test/api/hoge' }));

    done();
  });

  it('should do nothing without {baseHref}', async done => {
    handlerSpy.handle.and.returnValue(of<any>(new HttpResponse()));

    const req = new HttpRequest('GET', '/api/hoge');
    const result = await interceptor.intercept(req, handlerSpy).toPromise();
    expect(result).toEqual(jasmine.any(HttpResponse));
    expect(handlerSpy.handle.calls.first().args[0]).toBe(req);

    done();
  });

});

HttpClientを使用するサービス

サービスの実装

HttpClientを使用するAPIサービスクラスを実装します。こちらもファイル生成はCLIから行っています。データの一覧を日付を指定して取得すものとIDを指定して個別に取得するものの2メソッドを実装します。HttpClientの使い方自体はいたって普通です(普通だと思います)。個別に取得するものは、HTTPステータスコードが404だった場合nullを返すロジックがあります。

import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Moment } from 'moment';
import { Observable, of, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';

/**
 * APIクライアント
 */
@Injectable({
  providedIn: 'root'
})
export class ApiClientService {

  constructor(private readonly http: HttpClient) {
  }

  /**
   * 全件取得
   */
  getAll(targetDate?: Moment): Observable<any[]> {
    let params: HttpParams | undefined;
    if (targetDate != null) {
      params = new HttpParams({
        fromObject: { targetDate: targetDate.format('YYYY-MM-DD') }
      });
    }

    return this.http.get<any[]>('{baseHref}hoge', { params });
  }

  /**
   * 一件取得
   */
  getOne(id: number): Observable<any | null> {
    return this.http.get<any>(`{baseHref}hoge/${id}`)
      .pipe(
        catchError(e => e instanceof HttpErrorResponse && e.status === 404 ? of(null) : throwError(e))
      );
  }

}

サービスのテスト

APIクライアントサービスのテストを書きます。次の5パターンをテストします。

  • 一覧取得で日付指定する
  • 一覧取得で日付指定しない
  • 個別取得で取得成功
  • 個別取得で403になった
  • 個別取得で404になった

HttpClientのテストをするには HttpClientTestingModuleHttpTestingController を使用します。

import { APP_BASE_HREF } from '@angular/common';
import { HTTP_INTERCEPTORS, HttpErrorResponse } from '@angular/common/http';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import * as moment from 'moment';
import { ApiClientService } from './api-client.service';
import { BaseHrefHttpInterceptor } from './base-href-http-interceptor.service';

describe('ApiClientService', () => {

  let service: ApiClientService;
  let httpController: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [
        HttpClientTestingModule
      ],
      providers: [
        { provide: HTTP_INTERCEPTORS, useClass: BaseHrefHttpInterceptor, multi: true },
        { provide: APP_BASE_HREF, useValue: '/test/' }
      ]
    });

    service = TestBed.get(ApiClientService);
    httpController = TestBed.get(HttpTestingController);
  });

  afterEach(() => {
    httpController.verify();
  });


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

  describe('getAll', () => {
    it('(non targetDate parameter) should send http request without parameter ', async done => {
      const responseData: any[] = [
        { id: 1 },
        { id: 2 }
      ];

      const promise = service.getAll().toPromise();

      const request = httpController.expectOne('/test/hoge');
      expect(request.request.method).toBe('GET');
      request.flush(responseData);

      const response = await promise;
      expect(response).toEqual(responseData);
      done();
    });

    it('should send http request with parameter', async done => {
      const responseData: any[] = [
        { id: 1 },
        { id: 2 }
      ];

      const promise = service.getAll(moment([2019, 0, 1])).toPromise();

      const request = httpController.expectOne('/test/hoge?targetDate=2019-01-01');
      expect(request.request.method).toBe('GET');
      request.flush(responseData);

      const response = await promise;
      expect(response).toEqual(responseData);
      done();
    });
  });


  describe('getOne', () => {
    it('should send http request and get data if success', async done => {
      const responseData: any = { id: 1 };
      const promise = service.getOne(1).toPromise();

      const request = httpController.expectOne('/test/hoge/1');
      expect(request.request.method).toBe('GET');
      request.flush(responseData);

      const response = await promise;
      expect(response).toEqual(responseData);
      done();
    });

    it('should send http request and get null if not found', async done => {
      const promise = service.getOne(1).toPromise();

      const request = httpController.expectOne('/test/hoge/1');
      expect(request.request.method).toBe('GET');
      request.error(new ErrorEvent('http'), { status: 404 });

      const response = await promise;
      expect(response).toBeNull();
      done();
    });

    it('should send http request and throw error if forbidden', async done => {
      const promise = service.getOne(1).toPromise();

      const request = httpController.expectOne('/test/hoge/1');
      expect(request.request.method).toBe('GET');
      request.error(new ErrorEvent('http'), { status: 403 });

      try {
        await promise;
        fail();
      } catch (e) {
        expect(e).toEqual(jasmine.any(HttpErrorResponse));
        expect((e as HttpErrorResponse).status).toBe(403);
      } finally {
        done();
      }
    });
  });

});

HttpClientのテストでは確実にレスポンスが返っていることを確認するためにPromiseに変換してasyncのテストとしています。asyncにすることで、実はテストコードでレスポンスを返し忘れていて結果の確認が行われていなかった、、、という悲劇を防げます。