Vue CLIで作成したプロジェクトにaxios-mock-adapterを使用したユニットテストを導入する

こんにちは。サービスグループの武田です。

ある程度の規模のSPA(シングルページアプリケーション)を構築する場合、ほぼ100%APIサーバーとの通信が発生します。その場合の通信は、一般的にはHTTP/HTTPSを使用し、またアプリケーション内でもHTTPクライアントライブラリを使用することになります。さてそういったライブラリを使用しているモジュールのユニットテストは一般的に難しいものになります。なぜなら通信相手のサーバーは常に動いているとは限らず、また途中のネットワーク状況にも結果が左右されてしまいます。通信が失敗するパターンをテストしようにも都合よくエラーになってくれるとは限りません。ユニットテストの品質を一定に保つためにはこの問題を解決する必要がありますが、そのひとつとしてモックを使用することが挙げられます。

今回はVueアプリケーションのHTTP通信にaxiosを使用しているケースを例として、axios-mock-adapterを使用してHTTP通信部分をモック化したユニットテストを試してみました。

環境

今回の検証環境は次のような環境になっています。

$ node -v
v10.16.0

$ vue -V
3.9.2

$ sw_vers
ProductName:	Mac OS X
ProductVersion:	10.14.5
BuildVersion:	18F132

そのほか、主なモジュールのバージョンは以下となっています。また、vueコマンドがインストールされていない場合は、npm install -g @vue/cliでインストールします。

  • vue@2.6.10
  • axios@0.19.0
  • axios-mock-adapter@1.17.0
  • mocha@5.2.0
  • chai@4.2.0

プロジェクトを作成

まずはVue CLIを利用してプロジェクトを作成します。

$ vue create example-vue-axios-mock-adapter

presetはManualにして、すべてデフォルトを選択します。ここでテストスイートとしてmochaとchaiが選択されます。

最後の項目を選択するとインストールが始まります。インストールが終わったら、続いて必要なモジュールを追加でインストールします。

$ cd example-vue-axios-mock-adapter
$ npm install axios
$ npm install -D axios-mock-adapter

UserServiceクラスの実装とテストケースの作成

今回は、よくあるユーザー情報のCRUDを管理するサービスクラスを実装し、そのクラスのユニットテストを作成してみます。

またソースコード全体はGitHubに上げてあります。

まずはテスト対象となるUserServiceクラスと、このクラスが責務として扱うUser型の定義を次のように作ってみます。

export interface User {
  id: number | undefined;
  name: string;
  age: number;
}
import axios from 'axios';
import { User } from './types';

export default class UserService {
  
  public async findById(id: number): Promise<User> {
    const res = await axios.get(`/users/${id}`);
    return res.data;
  }

  public async register(user: User): Promise<User> {
    const res = await axios.post('/users', user);
    return res.data;
  }
  
}

Userはキーとなるidと、名前と年齢を持ったシンプルな型です。UserServiceは、このUser型の値を取得/保存するためのメソッドを提供します。実際には削除や変更、複雑な検索なども必要でしょうが、今回はシンプルにこれだけ実装しておきます。

また架空のAPI仕様として、サーバー側の登録処理でユーザーIDは自動で振られることとします。

それではこのクラスのテストを次のように実装してみます。UserService自体がシンプルですので、テストもシンプルです。

/* tslint:disable:no-unused-expression */

import { expect } from 'chai';
import UserService from '@/services/UserService';
import { User } from '@/services/types';

describe('UserService', () => {
  let userService: UserService;

  beforeEach(() => {
    userService = new UserService();
  });

  it('findById with 1', async () => {
    const user = await userService.findById(1);
    expect(user.id).to.equal(1);
  });

  it('register user data', async () => {
    const data = {
      name: 'kaname madoka',
      age: 14,
    } as User;

    const user = await userService.register(data);
    expect(user.id).to.not.be.undefined;
    expect(user.name).to.equal(data.name);
    expect(user.age).to.equal(data.age);
  });
});

findByIdのテストは、id=1で取得したオブジェクトのidも1であること。registerのテストは、idが振られていること(undefinedではないこと)とそれ以外は渡した値と同じであること。をそれぞれテストしています。

さてここで一度、ユニットテストを実行してみます、まぁ結果はいわずもがな……。

 WEBPACK  Compiled successfully in 4589ms

 MOCHA  Testing...



  HelloWorld.vue
    ✓ renders props.msg when passed

  UserService
    1) findById with 1
    2) register user data


  1 passing (62ms)
  2 failing

  1) UserService
       findById with 1:
     Error: socket hang up
      at createHangUpError (_http_client.js:323:15)
      at Socket.socketOnEnd (_http_client.js:426:23)
      at endReadableNT (_stream_readable.js:1129:12)
      at process._tickCallback (internal/process/next_tick.js:63:19)

  2) UserService
       register user data:
     Error: socket hang up
      at createHangUpError (_http_client.js:323:15)
      at Socket.socketOnEnd (_http_client.js:426:23)
      at endReadableNT (_stream_readable.js:1129:12)
      at process._tickCallback (internal/process/next_tick.js:63:19)

はい通信先のサーバーは存在しませんのでソケットハングアップしてますね。このままでは、本物のサーバー実装がないとユニットテストが成功しません。ここで役に立つのがモックライブラリです。axiosで本当の通信を行うのではなく、値を返すだけのハリボテを用意することによって、UserService.tsの実装を変更することなくテストを成功させましょう。

axiosのモック化とテストケースの修正

それではaxiosをモック化していきましょう。方法はいくつか考えられますが、今回はaxios-mock-adapterというモジュールを使用します。先ほど作成したテストケースに修正を加えます。変更箇所をdiff形式で掲載します。

@@ -1,17 +1,27 @@
 /* tslint:disable:no-unused-expression */

 import { expect } from 'chai';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
 import UserService from '@/services/UserService';
 import { User } from '@/services/types';

 describe('UserService', () => {
   let userService: UserService;
+  let mockAxios: MockAdapter;

   beforeEach(() => {
     userService = new UserService();
+    mockAxios = new MockAdapter(axios);
   });

   it('findById with 1', async () => {
+    mockAxios.onGet('/users/1').reply(200, {
+      id: 1,
+      name: 'QB',
+      age: 18,
+    });
+
     const user = await userService.findById(1);
     expect(user.id).to.equal(1);
   });
@@ -22,6 +32,11 @@ describe('UserService', () => {
       age: 14,
     } as User;

+    mockAxios.onPost('/users').reply(201, {
+      id: 10,
+      ...data,
+    });
+
     const user = await userService.register(data);
     expect(user.id).to.not.be.undefined;
     expect(user.name).to.equal(data.name);

ポイントをいくつか解説します。

まず15行目ですが、beforeEachの処理でaxiosをモック化しています。内部的にはaxiosが持っているadapter機能を利用して処理を割り込むようなしくみになっています。

次に19行目ですが、axios-mock-adapterは onXxx という名前のメソッドを提供しています(Xxxは各HTTPメソッドに対応)。ここでは/users/1というパスに対してGETリクエストをしようとした場合に、所定のレスポンスをするように設定しています。つまり実際にサーバーと通信することなく、200 OKでコンテンツが返ってきたように振る舞うわけです。

そして35行目は、19行目と同様にレスポンスの設定をしています。ユーザー登録時はバックエンドでPOSTリクエストをしますので、それに対して201 Createdおよびコンテンツを返すように振る舞います。

それではもう一度ユニットテストを実行してみます。

 WEBPACK  Compiled successfully in 5012ms

 MOCHA  Testing...



  HelloWorld.vue
    ✓ renders props.msg when passed

  UserService
    ✓ findById with 1
    ✓ register user data


  3 passing (35ms)

 MOCHA  Tests completed successfully

今度は成功しました!

最後に

axios-mock-adapterを試してみました。今回は正常系のテストのみでしたが、axios-mock-adapterを使用すると、ネットワークエラーやタイムアウトといった挙動もテストできます。機会があればそれらも試してみます。

コメントは受け付けていません。