OAS使ってなるべく楽にNuxtのテスト
はじめに
composition apiの登場でVueもようやくテストしやすい感じになってきました。
とはいえ実際AJAXが絡んだコンポーネントのテストをやろうとするとあれをモックしてこれもモックしてとなんだかんだ大量のモッキングが必要になって色々めんどくさい感じになりがちですし、ほとんどモックされてて実際のところどこがテストできてるのかわけがわからない感じになりがちです。
前回の記事ではOASを利用することでスタブサーバーが用意できることを紹介しました。
この記事ではそのスタブサーバーを利用して、フロントで再現性のあるAJAXテストを簡単に行える方法がないか模索してみます。
スタブサーバーの利用自体はフロントがどんな実装をしていようがあまり関係ありませんが、今回はNuxtを例にとって説明していきたいと思います。
サマリー
- 出来たこと
- 出来なかったこと
- nuxt自体をビルドしちゃってコンテキストを完全に再現(何故かjestでやろうとするとtsパーサーがちゃんと動かなかったので多分私の設定が悪かった)
- 試してないからできるかわからないこと
- vuex-module-decoratorsが使われていないレガシーなvuexでの動作確認
- puppeteerでE2E(多分できると思う)
完成品
こんなのができました
どうやって作るか話すと長くなるので、先に完成系を紹介します。
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で設定した値が格納されてることを確認
})
})
})
<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>
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に追加してください。
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であがってるとすれば
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クライアントに渡し、.env
のUSE_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 -d
とyarn test
を追加します。
github actionsだとこんな感じです。適宜環境に合わせて調整してください。
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が手に入ります。
/* 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
経由でアクセスできるようにしておきます。
export default defineComponent({
setup() {
const { fetch } = useFetch(async () => {
~~
})
fetch()
return { fetch }
},
})
const wrapper = mount(Hoge, {
mocks: {
$nuxt: {
context: {},
},
},
})
await (wrapper.vm as any).fetch()
jest側でmount
したあとfetch
を手動実行してpromiseの解決を待つことで、fetch
完了後のコンポーネントに対してテストを行うことができます。
このときthis.$nuxt
が無いとぬるぽになってしまうので、とりあえずmockしてあげてください。
asyncDataの中身をvmに出しておく
asyncData
もfetch
同様、jestで解決を検出することができませんので、asyncData
の中身をvm
に出しておくことで、テストの際にasyncData
を代わりに実行するように仕込んでおきます。
export default defineComponent({
setup() {
const fetch = async () => {
~~
}
const data = useAsync(fetch)
return { data, fetch }
},
})
const wrapper = mount(Fuga)
const data = await (wrapper.vm as any).fetch()
wrapper.vm.$data.value = data
子孫がfetchしてたらfindComponentで頑張る
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
を読んでくれないので読むようにします。
import dotenv from 'dotenv'
export default function setup() {
dotenv.config({ path: '.env' })
}
nuxt固有のコンポーネントは先にモックしておきましょう。
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
module.exports = {
transform: {
'^.+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$':
'jest-transform-stub',
},
}
useStoreを使う
例によってコンテキストがないので、コンポーネント内でuseStore
する場合は$store
にstore
を流し込みます。私が試した限り何故かlocalVue
にvuex
を渡す方法じゃうまくいきませんでした。
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部分のテストがローカルと同じ状態で回せるのはなかなか感動的です。
今後も色々調査検討してもっと簡単にテストできないか調べていきたいと思います。
Discussion