morishitaです。
このところNuxtのSPAを作っていました。
次のエントリで紹介したものに手を入れていたのですが、このときにはテストを書いていませんでした。
今回はちゃんとテストも書こうと思ってやってみました。
いくつかすんなり行かず試行錯誤した部分があるのでそれを書こうと思います。
Nuxt SPAのテスト
SSRを含まないNuxt SPAアプリケーションの構成要素は大雑把に次を含んでいます。
- コンポーネント
- ページコンポーネントから使われる構成要素を実装したVueのSFC1
- ページコンポーネント
- ルーティングとひも付きページを構成するSFC
- Vuexストア
- クライアントサイドでデータを格納しデータフローを制御するモジュール
- PluginやMiddleware
- Nuxtからフックされるモジュール
- その他ユーティリティ的なモジュール
- 他のコンポーネントから利用されるクラスや関数など
この内、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 を使えないブラウザ: 旧来のやり方で実装した共有ボタンを表示
- 旧来の共有ボタンは
FacebookShare
、LineShare
、TwitterShare
として別コンポーネントして実装
- 旧来の共有ボタンは
このコンポーネントのテストコードは次の様になります。
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 ならではの部分があります。
それは asyncData
と fetch
を使う場合です。
asyncData
はページコンポーネントのマウント前に実行され、
その戻り値はコンポーネントの$data
にマージされます。
fetch
もページコンポーネントのレンダリング前に実行されますが、
$data
の書き換えには使われず、データをVuexストアに入れるために使われます。
asyncData
と fetch
を使っているページコンポーネントにアクセスした場合、
Vue.jsのライフサイクルフックも含めた実行順は次のとおりです2。
asyncData
fetch
beforeCreate
created
beforeMount
mounted
つまり、asyncData
と fetch
はページコンポーネントのCreate前に実行されるのです。
後述しますが、asyncData
と fetch
でハンドリングするデータをページレンダリング時に使う場合、注意が必要です。っていうか普通使うと思うので対処が必要です。
さて、テスト対象のページコンポーネントとして次の例を見てみます。
<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のページコンポーネントであるために注意が必要なところを説明します。
asyncData
と fetch
をJestは実行しない
NuxtのページコンポーネントにasyncData
と fetch
が実装されていると、
それらはコンポーネントがマウントされる間にNuxtフレームワーク側から実行されます。
しかし、Jestによるテストでは実行されません。
そのため、ちょっとした工夫が必要です。
fetch
はストアにデータを格納するのが目的です。
なのでfetch
を実行する代わりにストアからデータを取り出すゲッターをモックしてやります(コメント①
)。
asyncData
は自前で実行し(コメント④
)、
その戻り値をwrapper.setData
でモジュールに設定します(コメント⑤
)。
ただ、実行順に問題があります。
コメント③
の箇所でページコンポーネントをマウントした wrapper
オブジェクトを生成しています。
mount
という名前の通りモジュールをマウントしてしまいます。
マウント時にはテンプレートがレンダリングされてしまいますが、
wrapper.setData
でデータがセットされるのはその後です。
前述したようにNuxt内で動作するときにはマウント前にasyncData
により$data
にデータが格納されるのですが、テスト内ではマウント後に$data
にデータが入ります。
したがって、データが設定されていない状態でマウントされてもエラーにならない実装をしておく必要があります。
さもないと、wrapper
オブジェクトが生成されずテストがすべてエラーとなります。
テスト対象のページモジュールの region
メソッドやprefecture
メソッドでnull
やundefined
を返さないようにしているのはそのためです3。
fetch
のテストはって? fetch
を使うということはVuexストアを使っているのだと思います。なので基本的にはfetch
では引数route
に含まれるparams
や query
で渡ってくる値の取り出しとVuexストアのアクションの呼び出しだけにして、テスト不要なほどシンプルにしておくのがいいと思います。Vuexストアのテストをしっかりすればいいでしょう。
Nuxtが提供するコンポーネントはスタブしておく
テストコードのコメント②
の部分です。
ページモジュールのテンプレート内でnuxt-link
を使っていますが、Jest内で実行するとnuxt-link
コンポーネントが見つからないというエラーが発生します。それを避けるためにスタブ化します。
nuxt-child
を使っている場合も同様にスタブ化すればいいと思います。
@vue/test-utils
のmount
の代わりに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.$axios
で
axiosにアクセスできるのが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
-
単一ファイルコンポーネント(Single File Components)↩
-
テストのためにプロダクションコードを調整するのは…と思うかもしれないですが、Nuxt内で動作するときにはモジュールのマウント前に
asyncData
の戻り値はモジュールにセットされるので問題ないと思います。↩