この記事について
最近フロントエンド技術の勉強をしていて、その中でフロントのテストはどう書けばいいんだろうか?
という疑問があったので、実際にNuxt.jsで適当にアプリを組んで、Jestでテストを書いてみました。
この記事はそのメモ書きみたいなものです。
チュートリアル的なものでもないので、情報の抜けやコードのガバガバ加減などはご容赦ください。
使用するツール各種
Nuxt.js
- Vue.jsをベースとしたフロントエンド開発フレームワーク
- SSRが簡単にできるようサポートされている
- 規約にのっとった開発がしやすい
Jest
- Facebook製のJavaScript用テストランナー
- RSpecライクな書き方ができる
- カバレッジレポートやスナップショットなどの高機能を備えている
Cloud Firestore
- Firebase提供するサービスの1つで、ドキュメントベースのデータベース
- 今回作成するアプリのバックエンドREST APIとして使う
今回作成するTodoアプリについて
あくまで練習のために作るので、機能や見た目に関してはこだわらずに作成します。
画面はこんな感じ。
todoの新規作成のフォームと、todoの一覧とdone(完了)とするボタンがあるだけの、
シンプルなシングルページのアプリです。
バージョン情報
- Nuxt.js: 2.2.0
- Jest: 23.6.0
- Cloud Firestore: 2018/11/10時点でベータ版
Nuxt.jsによるtodoアプリの実装
セットアップ
Nuxtプロジェクトを新しく作成するためにcreate-nuxt-app
をインストールします。
$ yarn add global create-nuxt-app
インストールできたら早速create-nuxt-app
を実行してプロジェクトを作成します。
今回はプロジェクト名はnuxt_todoとします。
$ create-nuxt-app nuxt_todo
設定を色々と聞かれますが、今回は以下のようにしました。
? Project name nuxt_app
? Project description My supreme Nuxt.js project
? Use a custom server framework none
? Use a custom UI framework none
? Choose rendering mode Universal
? Use axios module yes
? Use eslint yes
? Use prettier yes
axiosはhttpクライアント、eslintはJS用の静的コード解析ツール、prettierはコードフォーマッターです。
今回はFirestoreとの通信にaxiosを使うため、eslintとprettierはあった方がコードが綺麗になると思うので入れました。
Firestoreのセッティングもします。
Firebaseコンソールにログインして、適当な名前でプロジェクトを作成。
DatabaseからCloud Firestoreの「データベースの作成」をクリック。
セキュリティルールを聞かれますが、今回は「テストモードで開始」を選択します。
Firestoreデータベースを作成したら、プロジェクト内のnuxt.config.js
の設定を変更。
Firestore REST APIのURLを直接もしくは環境変数で追加します。
axios: {
baseURL: process.env.NUXT_ENV_AXIOS_BASE_URL
}
ちなみにFirestore REST APIのURLは以下のようになります。([project-name]は自分のfirebaseのプロジェクト名)
https://firestore.googleapis.com/v1beta1/projects/[project-name]/databases/(default)/documents
storeの実装
Vuexによりアプリケーションの状態管理とAPIとの非同期通信を行う、Nuxtアプリのstoreを作成します。
storeには以下の4つの要素により状態管理を行います。
- state
- 状態を保存しておく場所。
- getters
- stateから状態を取得する処理。基本的にstateは直接参照しないで、gettersから値を取得する。
- mutations
- stateを変更する処理。stateの変更は必ずここでのみ行い、必ず同期的に変更する。
- actions
- ビューからのイベントに反応してmutationsを発行する処理。
APIとの通信など非同期処理はここで行うようにする。
実装は以下の通りに行います。
state
todos
: todoオブジェクトの配列
getters
todos
: statusが未完了のtodoを取得
todos_all
: すべてのtodoを取得
mutations
updateTodos
: stateのtodosを更新
addTodo
: stateのtodosにtodoを追加
updateTodo
: stateのtodosの中のtodo1つを更新
actions
fetchTodos
: DBからtodosを取得してupdateTodos mutationを発行
addTodo
: DBにtodoを新規作成してaddTodo mutationを発行
updateTodo
: DBのtodoを更新してupdateTodo mutationを発行
実際のコードが以下の通り。
import _ from 'lodash'
export const state = () => ({
todos: []
})
export const getters = {
todos_all: state => state.todos,
todos: state => state.todos.filter(todo => todo.status === 'todo')
}
export const mutations = {
updateTodos(state, todos) {
state.todos = todos
},
addTodo(state, newTodo) {
state.todos.push(newTodo)
},
updateTodo(state, newTodo) {
const todo = state.todos.find(todo => todo.id === newTodo.id)
if (todo) {
todo.title = newTodo.title
todo.status = newTodo.status
}
}
}
export const actions = {
async fetchTodos({ commit }) {
await this.$axios.$get('/todos').then(res => {
let todos = []
if (_.has(res, 'documents')) {
todos = res.documents.map(doc => {
return {
id: _.last(doc.name.split('/')),
title: doc.fields.title.stringValue,
status: doc.fields.status.stringValue
}
})
}
commit('updateTodos', todos)
})
},
async addTodo({ commit }, payload) {
const req = {
fields: {
title: {
stringValue: payload.title
},
status: {
stringValue: 'todo'
}
}
}
await this.$axios.$post('/todos', req).then(res => {
const newTodo = {
id: _.last(res.name.split('/')),
title: res.fields.title.stringValue,
status: res.fields.status.stringValue
}
commit('addTodo', newTodo)
})
},
async updateTodo({ commit }, payload) {
const req = {
fields: {
title: {
stringValue: payload.title
},
status: {
stringValue: payload.status
}
}
}
await this.$axios.$patch(`/todos/${payload.id}`, req).then(res => {
const newTodo = {
id: _.last(res.name.split('/')),
title: res.fields.title.stringValue,
status: res.fields.status.stringValue
}
commit('updateTodo', newTodo)
})
}
}
componentsの実装
TodoForm
とTodoList
の2つのコンポーネントを作成します。
TodoForm
- todoのタイトル入力と、登録ボタンを備える
- 登録ボタンクリックにより、addTodo actionを発行する
実際のコードが以下の通り。
<template>
<div>
<input
v-model="todoForm.title"
type="text">
<button
type="button"
@click="handleAddTodo">
Submit
</button>
</div>
</template>
<script>
import { mapActions } from 'vuex'
import _ from 'lodash'
export default {
data() {
return {
todoForm: { title: '' }
}
},
methods: {
async handleAddTodo() {
if (this.todoForm.title) {
await this.addTodo(_.cloneDeep(this.todoForm))
this.todoForm.title = ''
}
},
...mapActions(['addTodo'])
}
}
</script>
<style scoped>
</style>
TodoList
- 未完了(
status: 'todo'
)のtodo一覧をstoreから取得して表示 - todoを完了として、リストから消すボタンをリストの各要素に表示
- 完了ボタンクリックでstatusをdoneにするupdateTodo actionを発行
実際のコードが以下の通り。
<template>
<div>
<ul>
<li
v-for="todo in todos"
:key="todo.id">
<span>{{ todo.title }}</span>
<small
class="change-status"
@click="handleDoneTodo(todo)">
done
</small>
</li>
</ul>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
import _ from 'lodash'
export default {
computed: {
...mapGetters(['todos'])
},
methods: {
async handleDoneTodo(todo) {
const payload = _.cloneDeep(todo)
payload.status = 'done'
await this.updateTodo(payload)
},
...mapActions(['updateTodo'])
}
}
</script>
<style scoped>
.change-status {
text-decoration: underline;
cursor: pointer;
text-indent: 1em;
}
</style>
pagesの実装
componentsを実装しましたが、それが使わなければ意味がありません。
ということで、pages/index.vue
に実装した2つのコンポーネントを追加します。
pagesディレクトリにページコンポーネントを作成すると、
ファイル名がアプリのURLとして解釈され、そのコンポーネントの内容がレンダリングされるようになります。
今回はページが1つしかないので、pages/index.js
を変更します。
また、ページコンポーネントにはasyncData
メソッドがあります。
ページが呼び出されるたびに実行され、DBからデータの取得などを行います。
また、SSR時にもこのメソッドは呼び出されます。
今回の実装では、asyncData
でfetchTodos actionを実行して、
DBからtodosを取得してstoreを更新するようにしています。
<template>
<section>
<TodoForm/>
<TodoList/>
</section>
</template>
<script>
import TodoForm from '@/components/TodoForm.vue'
import TodoList from '@/components/TodoList.vue'
export default {
async asyncData({ store }) {
await store.dispatch('fetchTodos')
},
components: {
TodoForm,
TodoList
}
}
</script>
<style>
</style>
これでTodoアプリの実装は終わりです。
Jestによるテスト
機能の実装ができたので、次にJestでstoreおよびcomponentsの単体テストを書いていきます。
セットアップ
NuxtプロジェクトでJestを使うためのパッケージを持ってきます。
$ yarn add --dev jest vue-jest babel-jest @vue/test-utils babel-preset-vue-app
package.json
のscripts
にテスト用のコマンドを追加します。
"scripts": {
...,
"test": "jest --config jest.config.js"
},
Jestはそのままでは.vue
ファイルのテストができず、ES6の構文が使えないので、
それらを使えるように設定します。
プロジェクトのルートディレクトリに、Babelの設定ファイル.babelrc
と、
Jestの設定ファイルjest.config.js
を、以下のような内容で追加します。
{
"env": {
"test": {
"presets": ["vue-app"]
}
}
}
module.exports = {
transform: {
'^.+\\.js$': 'babel-jest',
'.*\\.(vue)$': 'vue-jest'
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
'^~/(.*)$': '<rootDir>/$1'
},
moduleFileExtensions: ['js', 'json', 'vue'],
}
これでvueファイルがテストできて、JestのテストがES6の構文で書けるようになりました。
storeのテスト
まずstoreのテストを書いてみますが、どのようなテストケースを想定すればいいでしょうか。
今回はgetters、actionsについて以下のようなテストを書いてみました。
- getters
- stateから指定の値が取得できること
- actions
- actionを受けてstateの値が正しく変更されること
残りのstateとmutationsについてですが、
stateはgettersから必ず参照されるので、gettersによる外から見た振る舞いさえ正しければ、
stateも正しいだろう、ということでテストケースに含みません。
mutationsはactionsを経由して実行されるので、actionsの振る舞いによるstateの変更を検証すれば、
mutationsをテストする必要はないだろう、ということでこちらも含みません。
また、actionsではaxiosによるAPIとの通信が行われるので、その振る舞いをモック化します。
非同期処理については、async done
を使って処理を待ってから検証します。
書いたコードが以下の通り。
import Vuex from 'vuex'
import * as index from '@/store'
import { createLocalVue } from '@vue/test-utils'
import _ from 'lodash'
import axios from 'axios'
const localVue = createLocalVue()
localVue.use(Vuex)
// $axiosテストは https://github.com/nuxt-community/axios-module/issues/105 を参考
let mockAxiosGetResult
jest.mock('axios', () => ({
$get: jest.fn(() => Promise.resolve(mockAxiosGetResult)),
$post: jest.fn(() => Promise.resolve(mockAxiosGetResult)),
$patch: jest.fn(() => Promise.resolve(mockAxiosGetResult))
}))
let action
const testedAction = (context = {}, payload = {}) => {
return index.actions[action].bind({ $axios: axios })(context, payload)
}
describe('store/index.js', () => {
let store
let todo1, todo2
beforeEach(() => {
store = new Vuex.Store(_.cloneDeep(index))
todo1 = { id: '1', title: 'title_1', status: 'todo' }
todo2 = { id: '2', title: 'title_2', status: 'done' }
})
describe('getters', () => {
let todos
beforeEach(() => {
todos = [todo1, todo2]
store.replaceState({
todos: todos
})
})
describe('todos', () => {
test('statusがtodoのtodoが取得できること', () => {
expect(store.getters['todos']).toContainEqual(todo1)
expect(store.getters['todos']).not.toContainEqual(todo2)
})
})
describe('todos_all', () => {
test('すべてのtodoが取得できること', () => {
expect(store.getters['todos_all']).toEqual(
expect.arrayContaining(todos)
)
})
})
})
describe('actions', () => {
let commit
beforeEach(() => {
commit = store.commit
})
describe('fetchTodos', () => {
test('todosが取得できること', async done => {
action = 'fetchTodos'
mockAxiosGetResult = {
documents: [
{
name: `path/to/${todo1.id}`,
fields: {
title: { stringValue: todo1.title },
status: { stringValue: todo1.status }
}
}
]
}
await testedAction({ commit })
expect(store.getters['todos']).toContainEqual(todo1)
done()
})
})
describe('addTodo', () => {
test('todoが追加されること', async done => {
mockAxiosGetResult = {
name: `path/to/${todo1.id}`,
fields: {
title: { stringValue: todo1.title },
status: { stringValue: todo1.status }
}
}
action = 'addTodo'
await testedAction({ commit })
expect(store.getters['todos']).toContainEqual(todo1)
done()
})
})
describe('updateTodo', () => {
beforeEach(() => {
store.replaceState({
todos: [todo1]
})
})
test('todoが更新されること', async done => {
mockAxiosGetResult = {
name: `path/to/${todo1.id}`,
fields: {
title: { stringValue: 'updatedTitle' },
status: { stringValue: 'done' }
}
}
action = 'updateTodo'
await testedAction({ commit })
expect(store.getters['todos_all']).toContainEqual({
id: todo1.id,
title: 'updatedTitle',
status: 'done'
})
done()
})
})
})
})
componentsのテスト
componentsに関しては、以下のテストを想定しました。
- template
- ビューに必要なhtml要素が存在すること
- イベントをトリガーして、dataの値が変更されること
- イベントをトリガーして、期待されるメソッドが呼び出されること
- script
- data
- dataの構造が正しいこと
- methods, computed
- メソッドを実行して正しい値が出力されること
- storeのactionが正しく発行されること
- data
TodoForm
検証項目は以下の通り。
- template
- 入力フォームが存在すること
- フォームの操作
- dataに入力が反映されること
- ボタンクリックでhandleAddTodoが呼ばれること
- script
- data
- dataの構造が正しいこと
- methods
- handleAddTodo
- titleに入力がある場合
- action addTodoが発行されること
- titleの入力が空の場合
- action addTodoが発行されないこと
- titleに入力がある場合
- handleAddTodo
- data
methodsで実行されるactionはモック化して、actionが呼び出されたことだけ検証するようにします。
実際のコードが以下の通り。
import Vuex from 'vuex'
import { mount, createLocalVue } from '@vue/test-utils'
import * as store from '@/store'
import TodoForm from '@/components/TodoForm.vue'
const localVue = createLocalVue()
localVue.use(Vuex)
describe('components/TodoForm.vue', () => {
let wrapper
beforeEach(() => {
wrapper = mount(TodoForm, {
store: store,
localVue
})
})
describe('template', () => {
test('入力フォームが存在すること', () => {
expect(wrapper.contains('input[type="text"]')).toBe(true)
expect(wrapper.contains('button')).toBe(true)
})
describe('フォームの操作', () => {
beforeEach(() => {
wrapper.find('input[type="text"]').setValue('this title')
})
test('dataに入力が反映されること', () => {
expect(wrapper.vm.todoForm.title).toBe('this title')
})
test('ボタンクリックでhandleAddTodoが呼ばれること', () => {
const mock = jest.fn()
wrapper.setMethods({ handleAddTodo: mock })
wrapper.find('button').trigger('click')
expect(mock).toBeCalled()
})
})
})
describe('script', () => {
describe('data', () => {
test('dataの構造が正しいこと', () => {
expect(wrapper.vm.$data).toHaveProperty('todoForm.title')
})
})
describe('methods', () => {
describe('handleAddTodo', () => {
let mock
beforeEach(() => {
mock = jest.fn()
wrapper.setMethods({ addTodo: mock })
})
describe('titleに入力がある場合', () => {
test('action addTodoが発行されること', async done => {
wrapper.find('input[type="text"]').setValue('this title')
await wrapper.vm.handleAddTodo()
expect(mock).toBeCalled()
done()
})
})
describe('titleが空の場合', () => {
test('action addTodoが発行されないこと', async done => {
wrapper.find('input[type="text"]').setValue('')
await wrapper.vm.handleAddTodo()
expect(mock).not.toBeCalled()
done()
})
})
})
})
})
})
TodoList
検証項目は以下の通り。
- template
- todoのリストが表示されること
- todoリストのdoneをクリックする場合
- handleDoneTodoが指定の引数で呼び出されること
- script
- computed
- todos
- storeからtodosが取得できること
- todos
- methods
- handleDoneTodo
- action updateTodoがpayload.status=doneで発行されること
- handleDoneTodo
- computed
実際のコードは以下の通り。
import Vuex from 'vuex'
import { mount, createLocalVue } from '@vue/test-utils'
import * as indexStore from '@/store'
import TodoList from '@/components/TodoList.vue'
const localVue = createLocalVue()
localVue.use(Vuex)
describe('components/TodoList.vue', () => {
let wrapper
let store
let todo1
beforeEach(() => {
store = new Vuex.Store(indexStore)
todo1 = { id: '1', title: 'title_1', status: 'todo' }
wrapper = mount(TodoList, {
store: store,
localVue
})
store.replaceState({ todos: [todo1] })
})
describe('template', () => {
test('todoのリストが表示されること', () => {
const li = wrapper.find('li')
expect(li.find('span').text()).toBe(todo1.title)
expect(li.find('small').text()).toBe('done')
})
describe('todoリストのdoneをクリックする場合', () => {
test('handleDoneTodoが指定の引数で呼び出されること', () => {
const mock = jest.fn(todo => todo)
wrapper.setMethods({ handleDoneTodo: mock })
wrapper.find('li small').trigger('click')
expect(mock).toBeCalled()
expect(mock.mock.results[0].value).toBe(todo1)
})
})
})
describe('script', () => {
describe('computed', () => {
describe('todos', () => {
test('storeからtodosが取得できること', () => {
expect(wrapper.vm.todos).toEqual(expect.arrayContaining([todo1]))
})
})
})
describe('methods', () => {
describe('handleDoneTodo', () => {
test('action updateTodoがpayload.status=doneで発行されること', async done => {
const mock = jest.fn(payload => payload.status === 'done')
wrapper.setMethods({ updateTodo: mock })
await wrapper.vm.handleDoneTodo(todo1)
expect(mock.mock.results[0].value).toBe(true)
done()
})
})
})
})
})
所感
とりあえず、気になっていたフロントエンドのテスト書く体験ができたのはよかったです。
NuxtでTodoを実装するところは結構スラスラ書けましたが、
本当はテストファーストでやりたかったんですが、私がJestの使い方を知らないので、
先に実装を書いてからテストをやり方を手探る、という感じになっていました。
Firestoreとの通信は専用のnpmパッケージを使えばもっと楽に書けたんでしょうが、
それに依存したコードは避けたかったので、今回はaxiosで通信することにしました。
テストを書く上で辛いと感じたのが、Nuxt.jsがVueベースのフロント開発を便利にしている反面、
その独自実装に対してテスト用のライブラリの対応が足りてない点です。
例えばasyncData
ですが、先にも説明した通り、これはページコンポーネントで使えてレンダリング時の処理をサポートしていますが、
テスト用のライブラリでは素のVueコンポーネントを使うことが想定されているため、
Nuxt.js独自の実装であるasyncData
がテスト時には実行できませんでした。
そのために、今回pagesのテストは書けませんでした。
参考にした資料
Nuxt.js 公式サイト
https://ja.nuxtjs.org/
Jest 公式サイト
https://jestjs.io/
Vue test utils
https://vue-test-utils.vuejs.org/
Cloud Firestore ドキュメント
https://firebase.google.com/docs/firestore/
Nuxt.js ビギナーズガイド
https://www.amazon.co.jp/dp/B07J5434JB