🗿

[Angular, Jest] 依存している他のサービスをモック化してサービスクラスをテストする方法

2020/11/07に公開

はじめに

AngularのサービスクラスのふるまいをJestを使ってテストするテストコードについて紹介します。
サービスクラスは他のサービスクラスに依存することが多いと思うので、他のサービスクラスをモック化する方法も説明します。(むしろモック化する方法がメインになっています。)

なお、Jestのインストール方法などは割愛します。jest-preset-angularなどを用いてセットアップしてください。

環境

Node: 12.16.1
TypeScript: 4.0.5
Angular: 10.1.6
Jest: 26.6.2
jest-preset-angular: 8.3.2

テストするサービスクラス

Angular公式ドキュメントに出てくるチュートリアル Tour of Heroesの「4. サービスの追加」で出てくるHeroServiceをテストしたいと思います。

hero.service.ts
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { Hero } from './hero';
import { HEROES } from './mock-heroes';
import { MessageService } from './message.service';

@Injectable({
  providedIn: 'root'
})
export class HeroService {

  constructor(private messageService: MessageService) { }

  getHeroes(): Observable<Hero[]> {
    this.messageService.add('HeroService: fetched heroes');
    return of(HEROES);
  }
}

このHeroServiceはMessageServiceに依存しており、addメソッドを呼び出しています。
UTの範囲ではMessageServiceについて考える必要がないのですが、MessageServiceは以下です。

MessageService
message.service.ts
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class MessageService {
  messages: string[] = [];

  add(message: string) {
    this.messages.push(message);
  }

  clear() {
    this.messages = [];
  }
}

テスト観点

今回の記事では、以下のようなテスト観点を満たすようにテストコードを作成します。

  • HeroServiceがインスタンス化されていること
  • 依存クラス(MessageService)をモック化できていること
  • HeroServiceクラスのgetHeroesメソッドが想定通りの値を返却していること
  • MessageServiceのaddメソッドが呼ばれていること

テストコード

上記のテスト観点をすべてクリアするテストコードは以下です。(次節で解説します)

hero.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HeroService } from 'src/app/hero.service';
import { MessageService } from 'src/app/message.service';
import { HEROES } from 'src/app/mock-heroes';

// 外部モジュールのモック化 (describeより前に記述すること)
jest.mock('src/app/message.service');

describe('TestHeroService', () => {
    let heroService: HeroService;

    beforeEach(() => {
        TestBed.configureTestingModule({
            providers: [MessageService]
        });
        heroService = TestBed.inject(HeroService);
    });

    describe('テスト準備', () => {
        it('HeroServiceがインスタンス化されていること', () => {
            expect(heroService).toBeTruthy();
        });

        it('依存クラス(MessageService)がモック化されていること', () => {
            const mockedMessageService = TestBed.inject(MessageService) as jest.Mocked<MessageService>;
            expect(mockedMessageService.add.mock).toBeTruthy();
        });
    });
    
    describe('getHeroesメソッドのテスト', () => {
        it('モック用の配列を返却すること', () => {
            // 準備
            const expectedResult = HEROES;
            // 実行
            const result$ = heroService.getHeroes();
            // 検証
            result$.subscribe(result => {
                expect(result).toEqual(expectedResult);
            });
        });

        it('MessageServiceのaddメソッドを呼び出していること', () => {
            // 準備
            const messageService = TestBed.inject(MessageService);
            const spy = jest.spyOn(messageService, 'add');
            // 実行
            heroService.getHeroes();
            // 検証
            expect(spy).toBeCalled();
        });
    });
});

テストコード解説

HeroServiceがインスタンス化されていること

HeroServiceのテストを実施するにあたり、HeroServiceをインスタンス化する必要があります。
ユニットテスト用にコンポーネントやサービスを作成するTestBedというクラスがAngularのcoreモジュールに用意されているのでこれを利用します。

let heroService: HeroService;

beforeEach(() => {
    TestBed.configureTestingModule({
        providers: [MessageService]
    });
    heroService = TestBed.inject(HeroService);
});

TestBed.configureTestingModuleでテスト用モジュールでの設定を行うことができます。後ほど説明しますが、ここではMessageServiceprovidersに追加しています。
そして、TestBed.inject(HeroService)によってインスタンス化されたHeroServiceを取り出すことができます。
beforeEachで毎回取得することで、テストごとにインスタンス化され、あるテストでの操作が他のテストに影響することがありません。
よって、以下のテストケースが通ります。

it('HeroServiceがインスタンス化されていること', () => {
    expect(heroService).toBeTruthy();
});

依存クラス(MessageService)をモック化できていること

次に、HeroServiceが依存しているMessageServiceがモック化できていることを確認します。

ポイントは以下の2つです。

  1. jest.mock(モジュールのパス)で依存クラスをモック化する
  2. TestBed.configureTestingModule()providersに依存クラスを追加する

jest.mock()を使用すると、依存クラスのすべてのメソッドをモック関数に変更したクラスでオーバーライドされます。モック関数はデフォルトでは常にundefinedを返却します。(参考: ES6 Class Mocks - Jest

今回では、

jest.mock('src/app/message.service');

を実行し、'src/app/message.service'でexportされているMessageServiceクラスを自動的にモック化します。jest.mockdescribeよりも前に書く必要があるので注意です。

これにより、このファイル(hero.service.spec.ts)の範囲では、MessageServiceクラスはモッククラスで自動的にオーバーライドされるようになりました。

次に、TestBed.configureTestingModule()providersにMessageServiceクラスを指定すると、モッククラスを利用するようにモジュール定義を設定することができます。
jest.mockで自動モックしていない場合は、通常のMessageServiceクラスが利用されます。

最後に、モック化されていることを確認します。

it('依存クラス(MessageService)がモック化されていること', () => {
    const mockedMessageService = TestBed.inject(MessageService) as jest.Mocked<MessageService>;
    expect(mockedMessageService.add.mock).toBeTruthy();
});

TestBed.inject(MessageService)によってMessageServiceのインスタンスを取得します。injectメソッドの返り値の型推論はMessageServiceですが、実際にはモック化されたMessageServiceインスタンスが返ってくるので型を指定します。(この部分はテクニックが必要)

TestBed.inject(MessageService) as jest.Mocked<MessageService>;

モック関数に置き換わったaddメソッドはmockというプロパティを持つので、これを持つことでモック化されていることを確認できます。

expect(mockedMessageService.add.mock).toBeTruthy();

上記が、メソッドのテストを実行するための準備でした。

HeroServiceクラスのgetHeroesメソッドが想定通りの値を返却していること

今回は、getHeroesメソッドがモック用の値HEROESObservableを返却していることを確認します。
メソッドの返り値がObservable型なので、subscribeした後にexpectで確認します。

it('モック用の配列を返却すること', () => {
    // 準備
    const expectedResult = HEROES;
    // 実行
    const result$ = heroService.getHeroes();
    // 検証
    result$.subscribe(result => {
        expect(result).toEqual(expectedResult);
    });
});

MessageServiceのaddメソッドを呼び出していること

依存先のクラスのメソッドを呼び出していることを確認したい場合があるでしょう。
その場合は、対象メソッドをスパイすることで確認することができます。
(スパイする=対象メソッドの実装を追跡できるようにすること。メソッドのふるまいは変えません。)

今回は、MessageServiceaddメソッドが呼ばれているかどうか確認したいので、以下のようにspyオブジェクトを取得します。

const messageService = TestBed.inject(MessageService);
const spy = jest.spyOn(messageService, 'add');

このspyオブジェクトを用いることで対象メソッドが呼ばれているか確認することができます。

it('MessageServiceのaddメソッドを呼び出していること', () => {
    // 準備
    const messageService = TestBed.inject(MessageService);
    const spy = jest.spyOn(messageService, 'add');
    // 実行
    heroService.getHeroes();
    // 検証
    expect(spy).toBeCalled();
});

ちなみに、以下のようなコードでもspyすることができます。

const spy = jest.spyOn(MessageService.prototype, 'add');

まとめ

いかがだったでしょうか?
Angularでサービスクラスをテストする方法、依存先のクラスをモック化する方法を解説しました。

自分自身が依存先サービスクラスのモック化に苦戦したので、少しでも誰かの助けになれば幸いです。

参考サイト

Discussion