🌊

OAS使ってなるべく楽にNuxtのテスト

2021/08/23に公開約11,200字

はじめに

composition apiの登場でVueもようやくテストしやすい感じになってきました。
とはいえ実際AJAXが絡んだコンポーネントのテストをやろうとするとあれをモックしてこれもモックしてとなんだかんだ大量のモッキングが必要になって色々めんどくさい感じになりがちですし、ほとんどモックされてて実際のところどこがテストできてるのかわけがわからない感じになりがちです。
前回の記事ではOASを利用することでスタブサーバーが用意できることを紹介しました。
この記事ではそのスタブサーバーを利用して、フロントで再現性のあるAJAXテストを簡単に行える方法がないか模索してみます。
スタブサーバーの利用自体はフロントがどんな実装をしていようがあまり関係ありませんが、今回はNuxtを例にとって説明していきたいと思います。

サマリー

  • 出来たこと
    • jestでmutationactionを操作してその結果を反映した状態でコンポーネントをマウントするテスト
    • ajaxしてるvuexのactionをテスト
    • useFetchした結果に対してテスト
    • asyncDataした結果に対してテスト
    • github actions上でスタブサーバーを利用したajaxを含むCIテスト
  • 出来なかったこと
    • nuxt自体をビルドしちゃってコンテキストを完全に再現(何故かjestでやろうとするとtsパーサーがちゃんと動かなかったので多分私の設定が悪かった)
  • 試してないからできるかわからないこと

完成品

https://github.com/kmatsui058/nuxt-jest-sample

こんなのができました

どうやって作るか話すと長くなるので、先に完成系を紹介します。

ProjectComponent.spec.ts
import { mount } from '@vue/test-utils'
import { createStore } from '~/.nuxt/store'
import { authStore, initialiseStores } from '~/utils/store-accessor'
import Projects from '@/components/ProjectsComponent.vue'

describe('projects component', () => {
  beforeEach(() => {
    initialiseStores(createStore()) // storeをテストの度に初期化する
  })
  describe('projects', () => {
    test('mount test', async () => {

      await authStore.fetchSelf() // actionでユーザーデータを取得しておく

      const wrapper = mount(Projects, {
        mocks: {
          $nuxt: {
            context: {}, // useFetchを動かすために空っぽで良いので$nuxtをモックする
          },
        },
      })

      await (wrapper.vm as any).fetch() // useFetchを手動で実行する

      const nameElement = wrapper.find('div.name').element
      expect(nameElement.innerHTML).toBe('admin') // ajaxで設定した値が格納されてることを確認
      const projectNameElement = wrapper.find('div.project-name').element
      expect(projectNameElement.innerHTML).toBe('テストプロジェクト') // ajaxで設定した値が格納されてることを確認
    })
  })
})

@/components/ProjectsComponent.vue
<template>
  <div>
    <User /> // ※Userは直接vuexのgetterからユーザー情報を取得しているのでprops無し
    <Project
      v-for="(project, index) in projects"
      :key="index"
      :project-name="project.name"
    />
  </div>
</template>

<script lang="ts">
import { defineComponent, useFetch, ref, Ref } from '@nuxtjs/composition-api'
import User from '~/components/User.vue'
import Project from '~/components/Project.vue'
import { projectsStore } from '~/store'
import { ProjectItem } from '~/oas'
export default defineComponent({
  name: 'ProjectsComponent',
  components: {
    User,
    Project,
  },
  setup() {
    const projects: Ref<ProjectItem[]> = ref([])
    const { fetch } = useFetch(async () => {  // ※jestでfetch完了を検出するために手動fetchできるようにしておく
      await projectsStore.fetchProjects()
      projects.value = projectsStore.getProjects
    })
    fetch()  // ランタイム用に即座にfetchを実行しておく
    return { projects, fetch }  // jestからvm経由でアクセスできるようにfetchをreturnしておく
  },
})
</script>
~/store/auth.ts
import { Module, VuexModule, Mutation, Action } from 'vuex-module-decorators'
import { AxiosResponse } from 'axios'
import { $apiConfig } from '@/plugins/api-accessor'
import { DefaultApi, UserData } from '~/oas'

@Module({
  name: 'auth',
  stateFactory: true,
  namespaced: true,
  preserveState: true,
})
export default class AuthModule extends VuexModule {
  private self: UserData | null = null

  get getSelf(): UserData | null {
    return this.self
  }

  @Mutation
  setSelf(value: UserData | null): void {
    this.self = value
  }

  @Action
  async fetchSelf(): Promise<void> {
    const res: AxiosResponse<UserData> = await new DefaultApi(
      $apiConfig
    ).apiV2UsersMyselfGet()
    this.setSelf(res.data)
  }
}

shallowMountではなくmountをしているので、このテスト一つで3コンポーネントとvuexモジュールの最低限の動作確認を一気に行っています。
もちろんこんな風に簡単にテストできるのはコンポーネントが綺麗な時だけですが、大分モッキングしないとならない部分が減っていて、これくらいならめんどくさがりさんでもテスト書いてみようかなとなるのではないでしょうか?

それでは仕込みの方を見ていきましょう。

OAS側の仕込み

スタブサーバーのセットアップ

まずスタブサーバーを用意します。特にこだわりが無ければ適当にapisproutをdocker-composeに追加してください。

docker-compose.yaml
version: '3'

services:
  stub:
    image: danielgtaylor/apisprout:latest
    ports:
      - '8000:8000'
    volumes:
      - ./openapi.yaml:/openapi.yaml
    entrypoint: /usr/local/bin/apisprout /openapi.yaml --watch

AJAX先の切り替え

続いてフロント側で開発サーバーとスタブサーバーを切り替えられるようにします。
例えば開発サーバーが3000であがってるとすれば

.env
USE_STUB=true
STUB_PATH=http://localhost:8000
API_PATH=http://localhost:3000

のようなenvファイルを用意し、dotenv(nuxtであれば@nuxtjs/dotenv)を利用してaxiosで叩くためのbasePathを

  const basePath =
    process.env.USE_STUB === 'true'
      ? process.env.STUB_PATH
      : process.env.API_PATH,

のようにして用意しておきます。
これを利用するaxiosクライアントに渡し、.envUSE_STUBを切り替えることで開発サーバーとスタブサーバーを切り替えることができるようになります。

Open api generatorのtypescript-axiosモジュールの導入

前回の記事で紹介しましたが、OASのスキーマがきちんと用意されているのであればts-axiosモジュールを利用することが可能です。
まずjavaのランタイムをインストールしてから@openapitools/openapi-generator-cliをインストールします。

npm install @openapitools/openapi-generator-cli -D

続いてpackage.jsonにgenerator実行用のスクリプトを追加します。

{
  "scripts": {
    "oas": "openapi-generator-cli generate --enable-post-process-file -i ./openapi.yaml -o ./oas --generator-name typescript-axios"
  },
}

このコマンドを実行するとoasというフォルダにaxiosクライアントが生成されます。
この生成したクライアントは第三引数にaxiosインスタンスを渡せますので、必要であればnuxt側の$axiosを渡すことも可能です。
特にnuxt特有のaxios機能を使うつもりが無ければoasでgenerateしたものだけ使うで問題はないかと思います。
とはいえnuxtの$axiosを渡さなくてもモジュールは単体で使えるので、そのまま使っておけばjestでのテストでいちいちモッキングしなくてすんで便利です。
詳しい使い方はリポジトリの方をご覧ください。

CIへの組み込み

ローカルで動作確認ができたらCIに組み込みましょう。envを追加してdocker-compose up -dyarn testを追加します。
github actionsだとこんな感じです。適宜環境に合わせて調整してください。

.github/workflows/ci.yml

jobs:
  ci:
    env:
      USE_STUB: "true"
      STUB_PATH: http://localhost:8000
      API_PATH: http://localhost:3000

    steps:
        ~~
      - name: docker-up
        run: docker-compose up -d
        ~~
      - name: Run build
        run: yarn build
     - name: Run tests
        run: yarn test
        ~~

なお今回例で作っているような.nuxtの中のファイルをテストで使う場合は、testの前に必ずbuildして.nuxtを生成するようにしてください。

nuxt側への仕込み

vuex-module-decoratorの導入

手順はnuxt-typescriptの公式サイトにあるのでそちらを参考にしてください。
型安全性が強化されるだけでなくvuexがmoduleとして分離されてただのクラスになるので、とてもテストしやすくなります。
また、nuxt-typescript公式の手順に従ってセットアップすればvuexのinitializerが手に入ります。

~/utils/store-accessor.ts
/* eslint-disable import/no-mutable-exports */
import { Store } from 'vuex'
import { getModule } from 'vuex-module-decorators'
import MyModule from '~/store/mymodule'
import AuthModule from '~/store/auth'
import ProjectsModule from '~/store/projects'

let mymoduleStore: MyModule
let authStore: AuthModule
let projectsStore: ProjectsModule

function initialiseStores(store: Store<any>): void {
  mymoduleStore = getModule(MyModule, store)
  authStore = getModule(AuthModule, store)
  projectsStore = getModule(ProjectsModule, store)
}

export { initialiseStores, mymoduleStore, authStore, projectsStore }

こちらを使うと先ほどの完成系の例ようにテストごとにvuexのstateを初期化することができるようになり、テストの順番に依存しないクリーンで再現性の高いテストを行うことができるようになります。

useFetchを手動実行できるようにしておく

これは完成系のコメントで注釈した内容になりますが、nuxt本体が起動してない関係上、useFetchは自動で走ってくれません。
なのでsetupの中でfetchを手動実行できるようにしたうえで、returnに含めてvm経由でアクセスできるようにしておきます。

Hoge.vue
export default defineComponent({
  setup() {
    const { fetch } = useFetch(async () => {
      ~~
    })
    fetch()
    return { fetch }
  },
})
.spec.ts
    const wrapper = mount(Hoge, {
        mocks: {
            $nuxt: {
                context: {},
            },
        },
    })
    await (wrapper.vm as any).fetch()

jest側でmountしたあとfetchを手動実行してpromiseの解決を待つことで、fetch完了後のコンポーネントに対してテストを行うことができます。
このときthis.$nuxtが無いとぬるぽになってしまうので、とりあえずmockしてあげてください。

asyncDataの中身をvmに出しておく

asyncDatafetch同様、jestで解決を検出することができませんので、asyncDataの中身をvmに出しておくことで、テストの際にasyncDataを代わりに実行するように仕込んでおきます。

Fuga.vue
export default defineComponent({
  setup() {
    const fetch = async () => {
        ~~
    }
    const data = useAsync(fetch)
    return { data, fetch }
  },
})
.spec.ts
    const wrapper = mount(Fuga)
    const data = await (wrapper.vm as any).fetch()
    wrapper.vm.$data.value = data

子孫がfetchしてたらfindComponentで頑張る

.spec.ts
    test('親も子供もfetchしてるテスト', async () => {
      const wrapper = mount(projects, {
        mocks: {
          $nuxt: {
            context: {},
          },
        },
      })
      const nameElement = wrapper.find('div.name').element
      await (wrapper.vm as any).fetch()
      const projectComponent = wrapper.findComponent(ProjectsComponent)
      await (projectComponent.vm as any).fetch()
      const projectNameElement =
        projectComponent.find('div.project-name').element
      expect(projectNameElement.innerHTML).toBe('テストプロジェクト')
      expect(nameElement.innerHTML).toBe('admin')
    })

まぁ見ての通りですが、子孫が自前でfetchするようなケースではfindComponentで該当コンポーネントを探して、こちらもやはりvm経由でfetchを実行してください。
ちゃんと解決されれば子孫要素のajax結果もテストできます。

jest側の仕込み

global setupの設定

まず、デフォルトではjestは.envを読んでくれないので読むようにします。

jest.setup.ts
import dotenv from 'dotenv'
export default function setup() {
  dotenv.config({ path: '.env' })
}

nuxt固有のコンポーネントは先にモックしておきましょう。

jest.setup.ts
import dotenv from 'dotenv'
import { config } from '@vue/test-utils'
export default function setup() {
  dotenv.config({ path: '.env' })
  config.stubs.nuxt = { template: '<div />' }
  config.stubs['nuxt-link'] = { template: '<a><slot /></a>' }
  config.stubs['no-ssr'] = { template: '<span><slot /></span>' }
}

configの設定

基本create-nuxt-appで生成されるものを使えばいいですが、scssや画像はモックしておかないと面倒なのでjest-transform-stubを使ってモックしておきます。

npm install --save-dev jest-transform-stub
jest.config.js
module.exports = {
  transform: {
    '^.+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$':
      'jest-transform-stub',
  },
}

useStoreを使う

例によってコンテキストがないので、コンポーネント内でuseStoreする場合は$storestoreを流し込みます。私が試した限り何故かlocalVuevuexを渡す方法じゃうまくいきませんでした。
usoStoreせず直接各storeを呼び出している場合は不要です。

import { mount } from '@vue/test-utils'
import { createStore } from '~/.nuxt/store'
import { initialiseStores } from '~/utils/store-accessor'
import index from '@/pages/index.vue'
const store = createStore()
describe('index page', () => {
  beforeEach(() => {
    initialiseStores(store)
  })
  describe('index', () => {
    test('index page', async () => {
      const wrapper = mount(index, {
        mocks: {
          $nuxt: {
            context: {},
          },
          $store: store,
        },
      })
    })
  })
})

まとめ

nuxtは色々コンテキストにべったりで色々うっとうしいですが、CIでAJAX部分のテストがローカルと同じ状態で回せるのはなかなか感動的です。
今後も色々調査検討してもっと簡単にテストできないか調べていきたいと思います。

GitHubで編集を提案

Discussion

ログインするとコメントできます