アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

NuxtアプリケーションをJestでテストする

morishitaです。

このところNuxtのSPAを作っていました。

次のエントリで紹介したものに手を入れていたのですが、このときにはテストを書いていませんでした。

tech.actindi.net

今回はちゃんとテストも書こうと思ってやってみました。
いくつかすんなり行かず試行錯誤した部分があるのでそれを書こうと思います。

Nuxt SPAのテスト

SSRを含まないNuxt SPAアプリケーションの構成要素は大雑把に次を含んでいます。

  1. コンポーネント
    • ページコンポーネントから使われる構成要素を実装したVueのSFC1
  2. ページコンポーネント
    • ルーティングとひも付きページを構成するSFC
  3. Vuexストア
    • クライアントサイドでデータを格納しデータフローを制御するモジュール
  4. PluginやMiddleware
    • Nuxtからフックされるモジュール
  5. その他ユーティリティ的なモジュール
    • 他のコンポーネントから利用されるクラスや関数など

この内、4,5 は単なるTypescriptのテストなので割愛します。 1,2,3について順に見ていきます。

セットアップ

Typescriptで実装したNuxt SPAの例で説明します。

ちなみに試したバージョンは次の通りです。

  • nuxt@2.8.1
  • @nuxt/typescript@2.8.1
  • nuxt-property-decorator@2.3.0
  • typescript@3.5.2

Jestでテストをするためにインストールするものは次のとおりです。 今回使ったバージョンとともに示します。

  • jest@24.8.0
  • ts-jest@24.0.2
  • vue-jest@3.0.4
  • babel-jest@24.8.0
  • @vue/test-utils@1.0.0-beta.29
  • @types/jest@24.0.15

これらは全部 devDependencies でOKです。 次のコマンドでインストールできます。

$ yarn add -D jest ts-jest vue-jest babel-jest @vue/test-utils @types/jest

コンポーネントのテスト

まずは ページの構成要素となるcomponents 以下にあるコンポーネントのテストから。

次のようなコンポーネントのテストを考えます。

<template lang="pug">
.share
  .share-api(v-if="canUseShareApi")
    button(@click="share") 他のアプリで共有する
  .fallback(v-else)
    TwitterShare(:url="url")
    FacebookShare(:url="url")
    LineShare(:url="url")
</template>

<script lang="ts">
import { Component, Prop, Vue } from 'nuxt-property-decorator';
import FacebookShare from '~/components/SnsShare/FacebookShare.vue';
import LineShare from '~/components/SnsShare/LineShare.vue';
import TwitterShare from '~/components/SnsShare/TwitterShare.vue';

const DEFAULT_TITLE = 'アクトインディ開発者ブログ';
const DEFAULT_TEXT = '共有するテキスト';
const DEFAULT_URL = 'https://tech.actindi.net';

@Component({ components: { FacebookShare, LineShare, TwitterShare } })
export default class Share extends Vue {
  @Prop({ default: DEFAULT_TITLE }) title!: string;
  @Prop({ default: DEFAULT_TEXT }) text!: string;
  @Prop({ default: DEFAULT_URL }) url!: string;

  /**
   * Web Share APIが利用できるかどうか判定する
   */
  get canUseShareApi(): boolean {
    return !!navigator.share;
  }

  /**
   * Web Share APIを使って共有する 
   */
  public async share() {
    try {
      await navigator.share({
        title: this.title,
        text: this.text,
        url: this.url,
      });
    } catch (error) {
      console.error(error);
    }
  }
}
</script>

このコンポーネントは次の様に動作します。

  • Web Share API を利用できるブラウザ: Web Share APIを利用する共有ボタンを表示
  • Web Share API を使えないブラウザ: 旧来のやり方で実装した共有ボタンを表示
    • 旧来の共有ボタンは FacebookShareLineShareTwitterShareとして別コンポーネントして実装

このコンポーネントのテストコードは次の様になります。

import { mount } from '@vue/test-utils';
import Share from '@/components/SnsShare/Share.vue';

describe('Share', () => {
  describe('Share APIが使えない場合', () => {
    test('フォールバックボタンが表示される', () => {
      const wrapper = mount(Share as any);
      expect(wrapper.find('.share-api').exists()).toBeFalsy();
      expect(wrapper.find('.fallback').isVisible()).toBeTruthy();
    });
  });

  describe('Share APIが使える場合', () => {
    beforeEach(() => {
      // navigator.share をモックする
      navigator.share = jest.fn();
    });

    test('Web Share APIの共有ボタンが表示される', () => {
      const wrapper = mount(Share as any);
      expect(wrapper.find('.share-api').exists()).toBeTruthy();
      expect(wrapper.find('.fallback').exists()).toBeFalsy();
      wrapper.find('button').trigger('click');
      expect(navigator.share).toBeCalled();
    });
  });
});

Share APIが使えない場合と使える場合に分けてテストしています。
それぞれ表示されるべきボタンが表示されることをテストしています。
Nuxt.jsならではの部分は特にありません。
navigator.shareをモックしていたりしますが、ごく普通のVueコンポーネントのテストです。

ページコンポーネントのテスト

次は pages 配下のページコンポーネントです。

ページコンポーネントは Nuxt ならではの部分があります。
それは asyncDatafetch を使う場合です。

asyncData はページコンポーネントのマウント前に実行され、 その戻り値はコンポーネントの$dataにマージされます。
fetchもページコンポーネントのレンダリング前に実行されますが、 $dataの書き換えには使われず、データをVuexストアに入れるために使われます。

asyncDatafetch を使っているページコンポーネントにアクセスした場合、 Vue.jsのライフサイクルフックも含めた実行順は次のとおりです2

  1. asyncData
  2. fetch
  3. beforeCreate
  4. created
  5. beforeMount
  6. mounted

つまり、asyncDatafetch はページコンポーネントのCreate前に実行されるのです。
後述しますが、asyncDatafetchでハンドリングするデータをページレンダリング時に使う場合、注意が必要です。っていうか普通使うと思うので対処が必要です。

さて、テスト対象のページコンポーネントとして次の例を見てみます。

<template lang="pug">
div
  h1 {{region.name}}
  section(v-for="prefecture in prefectures")
    h2 {{prefecture.name}}
    ul
      li(v-for="facility in facilities[prefecture.name]") 
        nuxt-link(:to="`/facility/${facility.id}`") {{facility.name}}
</template>

<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator';
import { Getter } from 'vuex-class';
import Japan from '@/lib/Japan'; // 地方ごとの都道府県リストを持つクラス

@Component
export default class RegionPage extends Vue {
  // 都道府県語の施設を返す。
  // { 東京都: Facility[], 埼玉県: Facility[], .... }
  @Getter facilities;

  private regionId: number = 0;

  get region() {
    // null や undefined を返さないようにする
    return Japan.getRegion(this.regionId) || { name: '' };
  }

  get prefectures() {
    // null や undefined を返さないようにする
    return this.region.prefectures || [];
  }

  asyncData({ params, query }) {
    const { region } = params;
    return { regionId: Number(region) };
  }

  async fetch({ params, store }) {
    const { region: regionId } = params;
    // サーバから施設のリストを取得してストアに格納する
    await store.dispatch('fetchFacilities', regionId);
  }
}
</script>

このページコンポーネントは次のような動作をします。

  • URLのパスは /regions/:id
  • :id で指定された地方(関東、関西 etc)の都道府県ごとの施設( Facility )の一覧を表示する
  • 施設の情報はサーバから取得する

そしてこのページコンポーネントのテストコードは次のとおりです。
丸数字のコメントでポイントとなるところを示しています。

import { mount } from '@vue/test-utils';
import Fixture from '~/test/fixtures'; // テストデータを生成するユーティリティ
import RegionPage from '~/pages/regions/_region.vue';

describe('RegionPage', () => {
  let wrapper;
  beforeEach(() => {
    // ①ゲッターのモック
    const facilities = (regionId: number) => {
      return { 東京都: [Fixture.facility()] };
    };
    const getters = { facilities };
    const options = {
      stubs: ['nuxt-link'], // ②コンポーネントのスタブ化
      mocks: { $store: { getters } },
    };
    // ③ページコンポーネントをマウント
    wrapper = mount(RegionPage, options);
  });

  describe('デフォルトの月での表示', () => {
    test('正常に表示できる', () => {
      // ④asyncDataの実行
      const data = wrapper.vm.$options.asyncData({
        params: { region: '3' }, 
      });
      wrapper.setData(data); //⑤$dataのセット
      expect(wrapper.isVueInstance()).toBeTruthy();
      expect(wrapper.find('h1').text()).toContain('関東');
    });
  });
});

Nuxtのページコンポーネントならではの問題

Nuxtのページコンポーネントであるために注意が必要なところを説明します。

asyncDatafetch をJestは実行しない

NuxtのページコンポーネントにasyncDatafetchが実装されていると、 それらはコンポーネントがマウントされる間にNuxtフレームワーク側から実行されます。 しかし、Jestによるテストでは実行されません。 そのため、ちょっとした工夫が必要です。

fetchはストアにデータを格納するのが目的です。 なのでfetchを実行する代わりにストアからデータを取り出すゲッターをモックしてやります(コメント)。

asyncDataは自前で実行し(コメント)、 その戻り値をwrapper.setDataでモジュールに設定します(コメント)。
ただ、実行順に問題があります。
コメントの箇所でページコンポーネントをマウントした wrapper オブジェクトを生成しています。
mountという名前の通りモジュールをマウントしてしまいます。 マウント時にはテンプレートがレンダリングされてしまいますが、 wrapper.setData でデータがセットされるのはその後です。
前述したようにNuxt内で動作するときにはマウント前にasyncDataにより$dataにデータが格納されるのですが、テスト内ではマウント後に$dataにデータが入ります。
したがって、データが設定されていない状態でマウントされてもエラーにならない実装をしておく必要があります。
さもないと、wrapperオブジェクトが生成されずテストがすべてエラーとなります。
テスト対象のページモジュールの regionメソッドやprefectureメソッドでnullundefinedを返さないようにしているのはそのためです3

fetchのテストはって? fetchを使うということはVuexストアを使っているのだと思います。なので基本的にはfetchでは引数routeに含まれるparamsquery で渡ってくる値の取り出しとVuexストアのアクションの呼び出しだけにして、テスト不要なほどシンプルにしておくのがいいと思います。Vuexストアのテストをしっかりすればいいでしょう。

Nuxtが提供するコンポーネントはスタブしておく

テストコードのコメントの部分です。
ページモジュールのテンプレート内でnuxt-linkを使っていますが、Jest内で実行するとnuxt-linkコンポーネントが見つからないというエラーが発生します。それを避けるためにスタブ化します。 nuxt-childを使っている場合も同様にスタブ化すればいいと思います。

@vue/test-utilsmountの代わりにshallowMountを使うと子コンポーネントはすべてスタブ化されるのでそちらを使ってもいいと思います。

Vuexストアのテスト

最後にVuexストアのテストです。

Nuxt には Vuex が組み込まれているので 使われるケースが多いのではないでしょうか。
使うのであればデータの出し入れの処理をできるだけVuex側に寄せてページコンポーネント側は表示に集中するほうがいいと思います。そうするとVuexストアのテストは重要です。

次のようなVuexストアのテストを例にします。

import { NuxtAxiosInstance } from '@nuxtjs/axios';
import { Store } from 'nuxt';
// Vuex Store
export function state(): { facilities: { [regionKey: string]: IFacilities } } {
  return { facilities: { region3: [], region4: [], region5: [] } };
}

export const getters = {
  // リージョンを指定して都道府県ごとに分けられた施設の一覧を取得する。
  facilities({ facilities }, getters) {
    return (regionId: number): { [prefecture: string]: IFacility[] } => {
      const result = {};
      facilities[`region${regionId}`].forEach((facility: IFacility) => {
        result[facility.prefecture] = result[facility.prefecture] || [];
        result[facility.prefecture].push(facility);
      });
      return result;
    };
  },
};

export const mutations = {
  // リージョンごとに施設の一覧をセットする
  setFacilities(state: { facilities: IFacilities }, facilityList: IFacilities): void {
    const regionKey = `region${facilityList.region}`;
    state.facilities[regionKey] = facilityList.facilities;
  },
};

export const actions = {
  // 指定したリージョンのJSONを取得し、ストアに格納する
  async fetchFacilities({ commit }, regionId: number) {
    const jsonFile = `region${regionId}.json`;
    const facilities = await this.$axios.$get<IFacilities>(jsonFile);
    commit('setFacilities', facilities);
  },
};

このストアのfacilitiesにはリージョンごとに施設(IFacility)のリストを格納します。
それを出し入れするためのゲッターとアクション、ミューテーションが実装してあります。
@nuxtjs/axios を使っているので this.$axiosaxiosにアクセスできるのがNuxtならではの部分となります。

このVuexのテストコードは次のとおりです。

import Vuex from 'vuex';
import { createLocalVue } from '@vue/test-utils';
import { cloneDeep } from 'lodash';
import axios from 'axios';
import Fixture from '~/test/fixtures';

import * as storeIndex from '@/store';

const localVue = createLocalVue();
localVue.use(Vuex);

// ①axios をモックする
jest.mock('axios', () => ({
  $get: jest.fn(filename => {
    const jsonData = Fixture.json(filename); // APIのレスポンス相当のJSONを返すフィクスチャユーティリティ
    return Promise.resolve(jsonData);
  }),
}));

describe('store/index.ts', () => {
  describe('getters', () => {
    let store;
    beforeEach(() => {
      store = new Vuex.Store(cloneDeep(storeIndex as any));
      const storeData = Fixture.store();
      store.replaceState(storeData); // テストデータをストアにセット
    });

    describe('facilities', () => {
      test('中部地方の施設を取得できる', () => {
        const facilities = store.getters.facilities(4);
        expect(facilities['愛知県']).toHaveLength(3);
      });
    });
  });

  describe('actions', () => {
    let store;
    beforeEach(() => {
      store = new Vuex.Store(cloneDeep(storeIndex as any));
      store.$axios = axios; // ②@nuxtjs/axiosの代わりにaxiosを注入
    });

    describe('fetchFacilities', () => {
      test('取得しストアに格納できる', async () => {
        expect.assertions(1);
        await store.dispatch('fetchFacilities', 3);
        const getter = store.getters.facilities;
        expect(getter(3)).toHaveLength(10);
      });
    });
  });
});

ポイントは axiosをモックしているところ(コメント)。
テストデータを生成するFixtureクラスが、APIのレスポンス相当のJSONを生成して返すようにしています。
そのままだとストア内部から届かないのでコメントでNuxtの代わりにaxiosを注入します。 これでテストが動作するようになります。

ミューテーションは個別にテストしていませんが、アクションから呼ばれるので同時にテストできてしまいます。

まとめ

Nuxtアプリケーションのテスト実装を一通り試してハマりどころに対処しました。

アプリケーションがある程度大きくなるとテストの有無が開発スピードに影響してくると思います。
影響範囲が見えなくて怖くて変更できない状況では開発スピードが落ちていきます。
テストがあれば思わぬ変更の影響範囲に早く気付けますし、リファクタリングも大胆やれます。

最後に

アクトインディではエンジニアを募集しています。 actindi.net


  1. 単一ファイルコンポーネント(Single File Components)

  2. 参考:Nuxt.jsのネストした動的ルーティングで困ったので調べてみた

  3. テストのためにプロダクションコードを調整するのは…と思うかもしれないですが、Nuxt内で動作するときにはモジュールのマウント前にasyncDataの戻り値はモジュールにセットされるので問題ないと思います。