LoginSignup
154
149

More than 5 years have passed since last update.

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

Posted at

この記事について

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

使用するツール各種

Nuxt.js

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

Jest

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

Cloud Firestore

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

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

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

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

nuxt_todo.png

バージョン情報

  • 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を直接もしくは環境変数で追加します。

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には以下の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を発行

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

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の実装

TodoFormTodoListの2つのコンポーネントを作成します。

TodoForm

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

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

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を発行

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

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に実装した2つのコンポーネントを追加します。

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

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

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

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を使うためのパッケージを持ってきます。

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

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

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

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

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

.babelrc
{
  "env": {
    "test": {
      "presets": ["vue-app"]
    }
  }
}
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を使って処理を待ってから検証します。

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

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が呼び出されたことだけ検証するようにします。

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

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で発行されること

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

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

154
149
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
154
149