Nuxt.js + JestでTodoアプリと自動テストを作成

# この記事について

最近フロントエンド技術の勉強をしていて、その中でフロントのテストはどう書けばいいんだろうか？
という疑問があったので、実際にNuxt.jsで適当にアプリを組んで、Jestでテストを書いてみました。
この記事はそのメモ書きみたいなものです。
チュートリアル的なものでもないので、情報の抜けやコードのガバガバ加減などはご容赦ください。

# 使用するツール各種

## Nuxt.js

- Vue.jsをベースとしたフロントエンド開発フレームワーク
- SSRが簡単にできるようサポートされている
- 規約にのっとった開発がしやすい

## Jest

- Facebook製のJavaScript用テストランナー
- RSpecライクな書き方ができる
- カバレッジレポートやスナップショットなどの高機能を備えている

## Cloud Firestore

- Firebase提供するサービスの１つで、ドキュメントベースのデータベース
- 今回作成するアプリのバックエンドREST APIとして使う

# 今回作成するTodoアプリについて

あくまで練習のために作るので、機能や見た目に関してはこだわらずに作成します。

画面はこんな感じ。
todoの新規作成のフォームと、todoの一覧とdone（完了）とするボタンがあるだけの、
シンプルなシングルページのアプリです。

<img width="295" alt="nuxt_todo.png" src="https://qiita-image-store.s3.amazonaws.com/0/254436/77ec4bd6-0b65-b75e-520e-c817f167dc0c.png">

バージョン情報

- Nuxt.js: 2.2.0
- Jest: 23.6.0
- Cloud Firestore: 2018/11/10時点でベータ版

# Nuxt.jsによるtodoアプリの実装

## セットアップ

Nuxtプロジェクトを新しく作成するために`create-nuxt-app`をインストールします。

```bash
$ yarn add global create-nuxt-app
```

インストールできたら早速`create-nuxt-app`を実行してプロジェクトを作成します。
今回はプロジェクト名はnuxt_todoとします。

```bash
$ create-nuxt-app nuxt_todo
```

設定を色々と聞かれますが、今回は以下のようにしました。

```bash
? 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コンソール](https://console.firebase.google.com)にログインして、適当な名前でプロジェクトを作成。
DatabaseからCloud Firestoreの「データベースの作成」をクリック。
セキュリティルールを聞かれますが、今回は「テストモードで開始」を選択します。

Firestoreデータベースを作成したら、プロジェクト内の`nuxt.config.js`の設定を変更。
Firestore REST APIのURLを直接もしくは環境変数で追加します。


```js:nuxt.config.js
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には以下の４つの要素により状態管理を行います。

<dl>
  <dt>state</dt>
  <dd>状態を保存しておく場所。</dd>
  <dt>getters</dt>
  <dd>stateから状態を取得する処理。基本的にstateは直接参照しないで、gettersから値を取得する。</dd>
  <dt>mutations</dt>
  <dd>stateを変更する処理。stateの変更は必ずここでのみ行い、必ず同期的に変更する。</dd>
  <dt>actions</dt>
  <dd>ビューからのイベントに反応してmutationsを発行する処理。<br>
      APIとの通信など非同期処理はここで行うようにする。
  </dd>
</dl>

実装は以下の通りに行います。

**state**
`todos`: todoオブジェクトの配列

**getters**
`todos`: statusが未完了のtodoを取得
`todos_all`: すべてのtodoを取得

**mutations**
`updateTodos`: stateのtodosを更新
`addTodo`: stateのtodosにtodoを追加
`updateTodo`: stateのtodosの中のtodo１つを更新

**actions**
`fetchTodos`: DBからtodosを取得してupdateTodos mutationを発行
`addTodo`: DBにtodoを新規作成してaddTodo mutationを発行
`updateTodo`: DBのtodoを更新してupdateTodo mutationを発行

実際のコードが以下の通り。

```js:store/index.js
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`の２つのコンポーネントを作成します。

### TodoForm
- todoのタイトル入力と、登録ボタンを備える
- 登録ボタンクリックにより、addTodo actionを発行する

実際のコードが以下の通り。

```vue:components/TodoForm.vue
<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を発行

実際のコードが以下の通り。

```vue:components/TodoList.vue
<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`に実装した２つのコンポーネントを追加します。

pagesディレクトリにページコンポーネントを作成すると、
ファイル名がアプリのURLとして解釈され、そのコンポーネントの内容がレンダリングされるようになります。
今回はページが１つしかないので、`pages/index.js`を変更します。

また、ページコンポーネントには`asyncData`メソッドがあります。
ページが呼び出されるたびに実行され、DBからデータの取得などを行います。
また、SSR時にもこのメソッドは呼び出されます。

今回の実装では、`asyncData`でfetchTodos actionを実行して、
DBからtodosを取得してstoreを更新するようにしています。

```vue:pages/index.js
<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を使うためのパッケージを持ってきます。

```bash
$ yarn add --dev jest vue-jest babel-jest @vue/test-utils babel-preset-vue-app
```

`package.json`の`scripts`にテスト用のコマンドを追加します。

```json:package.json
  "scripts": {
    ...,
    "test": "jest --config jest.config.js"
  },
```

Jestはそのままでは`.vue`ファイルのテストができず、ES6の構文が使えないので、
それらを使えるように設定します。

プロジェクトのルートディレクトリに、Babelの設定ファイル`.babelrc`と、
Jestの設定ファイル`jest.config.js`を、以下のような内容で追加します。

```json:.babelrc
{
  "env": {
    "test": {
      "presets": ["vue-app"]
    }
  }
}
```

```js:jest.config.js
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`を使って処理を待ってから検証します。

書いたコードが以下の通り。

```js:spec/store/index.spec.js
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が正しく発行されること

### TodoForm

検証項目は以下の通り。

- template
    - 入力フォームが存在すること
    - フォームの操作
        - dataに入力が反映されること
        - ボタンクリックでhandleAddTodoが呼ばれること
- script
    - data
        - dataの構造が正しいこと
    - methods
        - handleAddTodo
            - titleに入力がある場合
                - action addTodoが発行されること
            - titleの入力が空の場合
                - action addTodoが発行されないこと

methodsで実行されるactionはモック化して、actionが呼び出されたことだけ検証するようにします。

実際のコードが以下の通り。

```js:spec/components/TodoForm.spec.js
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が取得できること
    - methods
        - handleDoneTodo
            - action updateTodoがpayload.status=doneで発行されること

実際のコードは以下の通り。

```js:spec/components/TodoList.spec.js
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