Rails + Vue.jsなアプリケーションではじめてのフロントエンドのユニットテスト

## はじめに

Railsでアプリケーションを開発していると、E2Eの試験としてfeature specを書くことはよくあると思いますが、JavaScriptで実装したフロントエンドのモジュールやライブラリのユニットテストは「feature specがあるから大丈夫」とおざなりになっていませんか？E2Eテストですべて網羅的に検証できていれば良いという意見には一理あるのですが、UIやUXをはじめとして、フロントエンドの要件が大きく複雑になる傾向にある最近のWEBアプリケーションにおいては、フロントエンドのユニットテストがあることのメリットは大きいと考えています。またfeature specはえてして実行時間が長く、ユーザーの操作パターンをすべて網羅的に検証するのは現実的ではありません。

今回の記事では、バックエンドのフレームワークとしてRails、フロントエンドのフレームワークとしてVue.jsを利用して簡単なTodoアプリケーションを作成し、Vue.jsで書いたコンポーネントのユニットテストを書きます。本記事がフロントエンドのユニットテストを書くきっかけになり、堅牢なフロントエンド開発の一助となれば幸いです。

なお今回本記事で紹介するアプリケーションと同じものをGitHubに置きましたので、適宜サンプルコードを見てもらえればと思います。
[edwardkenfox/rails-vue-unit-test-sample
](https://github.com/edwardkenfox/rails-vue-unit-test-sample)

## 今回利用したライブラリ

- Rails: 5.0.0.1
- Vue.js: 2.0.1
- npm: 3.10.8
- webpack: 1.13.2

注）RailsもVue.jsも現時点で最新のバージョンを利用していますが、ここで作成するアプリケーションおよびテストは非常にシンプルなものなので、これよりも古いバージョンでも問題なく動作するはずです（未検証）。

## Railsでビューの作成

まずは新しいRailsアプリケーションを作成します。

```
$ rails new rails-vue-unit-test-sample

$ cd rails-vue-unit-test-sample
```

簡単な画面を作っていきましょう。ここではまだVue.jsは利用せず、このあと実装するVueコンポーネントが乗っかるための土台となるビューをRailsで作成します。

次にルーティングを用意します。rootへのアクセス（`localhost:3000/`）を直接`TodosController#index`へルーティングします。

```ruby:config/routes.rb
root to: "todos#index"
```

つづけてリクエストを制御するコントローラを作成します。さきほど書いたルーティングに沿ったアクションを用意すれば十分です。

```ruby:app/controllers/todos_controller.rb
class TodosController < ApplicationController
  def index
  end
end
```

これで画面を作成する準備が整いました。次はビューを書いていきましょう。

Railsの規約に沿うように、コントローラ名とアクション名に合致するテンプレートファイルを用意します。`<todos></todos>`というのが出てきていますが、これがこのあとVue.jsを使って実装するVueコンポーネントに該当します。

```html:app/views/todos/index.html.erb
<article id="todos-sample">
  <h1>Todos</h1>
  <todos></todos>
</article>
```

ここまで書き終わったら`localhost:3000/`にアクセスしてみましょう。下図のように表示されたら成功です。`<todos>`の実装がまだないので、当然`<h1>`以外には何も表示されません。

![1-1.jpg](https://qiita-image-store.s3.amazonaws.com/0/36524/45da2ba6-fb11-aad5-2b32-0e09f82d3e11.jpeg)

## Todosコンポーネントの実装 

つづけてVueコンポーネントの実装に入ります。pure JavaScriptで書くことも可能ですが、せっかくなのでモダンな感じのセットアップで開発してみることにします。

まずはアプリケーションのルート直下に`/frontend`ディレクトリを作ります。さらにその中に`/config`、`/src`、`/test`というディレクトリを作ります。それぞれのディレクトリに必要なファイルを順を追って作成していきます。

```
/frontend
	├── /config -- webpackの設定ファイルなど
	├── /src    -- Vueコンポーネントなどの実装
	└── /test   -- テスト用の設定ファイルや実際にテストファイル
```

必要なnpmパッケージをインストール・管理するためにpackage.jsonを作り、Vue.jsをはじめ今後必要となるライブラリをすべてインストールします。

```
$ npm init
# コマンドライン上の質問に対してはとりあえず全部EnterでOK

$ npm install --save-dev babel-core babel-loader babel-plugin-transform-runtime babel-preset-es2015 babel-preset-stage-0 babel-runtime function-bind karma karma-mocha karma-phantomjs-launcher karma-spec-reporter karma-webpack mocha vue-html-loader vue-loader webpack webpack-dev-server 
```

npm installが完了すると`package.json`はこんな感じにになっているはずです。

```json:frontend/package.json
{
  "name": "rails-vue-unit-test-sample",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "webpack --config ./config/webpack.config.js",
    "test": "karma start ./karma.conf.js"
  },
  "author": "Edward Fox",
  "license": "ISC",
  "dependencies": {
    "vue": "^2.0.1"
  },
  "devDependencies": {
    "babel-core": "^6.0.0",
    "babel-loader": "^6.0.0",
    "babel-plugin-transform-runtime": "^6.0.0",
    "babel-preset-es2015": "^6.0.0",
    "babel-preset-stage-0": "^6.16.0",
    "babel-runtime": "^6.0.0",
    "function-bind": "^1.1.0",
    "karma": "^1.3.0",
    "karma-mocha": "^1.2.0",
    "karma-phantomjs-launcher": "^1.0.2",
    "karma-spec-reporter": "0.0.26",
    "karma-webpack": "^1.8.0",
    "mocha": "^3.1.0",
    "vue-html-loader": "^1.2.3",
    "vue-loader": "^8.0.0",
    "webpack": "^1.12.2",
    "webpack-dev-server": "^1.12.0"
  }
}

```

次にwebpackをセットアップします。以下のファイルを作成します。

```js:frontend/config/webpack.config.js
var webpack = require('webpack')
var path = require('path')

module.exports = {
  entry: {
    application: './src/javascripts/main.js',
  },
  output: {
    path: '../app/assets/javascripts',
    filename: '[name].js'
  },
  module: {
    loaders: [
      {
        test: /\.vue$/,
        loader: 'vue'
      },
      {
        test: /\.js$/,
        loader: 'babel',
        exclude: /node_modules/
      }
    ]
  },
  babel: {
    presets: ['es2015'],
    plugins: ['transform-runtime']
  },
  resolve: {
    alias: {
      vue: 'vue/dist/vue.js'
    }
  }
}
```

さらにES2015の記法で書かれたファイルをJavaScriptにトランスパイルするために必要なBabelの設定を追加します。

```json:frontend/.babelrc
{
  "presets": ["es2015", "stage-0"],
  "plugins": ["transform-runtime"]
}
```

これでやっとVueでコンポーネント書く準備ができました！さっそくVueコンポーネントを書いていきましょう。

まずはTodosコンポーネントの元となるファイルを用意します。[Vue.js公式のサンプル](https://github.com/vuejs/vue-hackernews-2.0)などを見ると、`App.vue`というアプリケーション全体のコンテナ層を設ける方式が採られているようですが、ここではなるべく簡潔にするため`main.js`で直接必要なコンポーネントをimportします。

```js:frontend/src/javascripts/main.js
import Vue from 'vue'
import Todos from './todos.vue'

new Vue({
  el: '#todos-sample',
  components: { Todos }
})
```

Todosコンポーネントをインスタンス化する準備ができました。次に実際のTodosコンポーネントを作成しましょう。

```html:/frontend/src/javascripts/todos.vue
<template>
  <div>
    <ul>
      <li v-for="(todo, index) in todos">
        {{ todo.title }}
      </li>
    </ul>
    <input type="text" placeholder="Create new todo" v-if="showTodoInput" v-model="newTodoTitle">
    <button @click="addTodo">+ Add Todo</button>
  </div>
</template>

<script>
export default {
  data () {
    return {
      todos: [],
      showTodoInput: false,
      newTodoTitle: null
    }
  },
  methods: {
    addTodo: function() {
      if (this.showTodoInput && this.newTodoTitle) {
        let newTodo = {
          title: this.newTodoTitle,
          completed: false
        }
        this.todos.push(newTodo)
        this.newTodoTitle = null
      } else {
        this.showTodoInput = true
      }
    }
  }
}
</script>
```

これでアプリケーションが完成しました！ここまでが完了した状態で`frontend`ディレクトリに移動し、`$ webpack --config ./config/webpack.config.js`を実行してみましょう。`frontend`配下に書いたVue.jsの実装をwebpackがビルドし、`app/assets/javascripts/application.js`として出力します。ビルドが完了したら`localhost:3000/`をリロードしてみましょう。

![2-1.jpg](https://qiita-image-store.s3.amazonaws.com/0/36524/98d514cd-42cc-9db4-f6ae-688cbc08a4cf.jpeg)

さきほどはなかった「+ Add Todo」というボタンが追加されているのがわかります。実際にボタンをクリックして新しいTodoを作ってみましょう。

![3.gif](https://qiita-image-store.s3.amazonaws.com/0/36524/e80fcfed-4c9d-772b-2f6f-4eb123bb7853.gif)

これでTodoアプリケーションが完成です！このままではTodoアプリケーションとして何の面白みもないですが、本記事の目的はVueコンポーネントのテストを書くことなので、次に進みたいと思います。

## Vueコンポーネントのユニットテストを書く

フロントエンドのテストを実行するための環境を準備します。今回テストランナーには`karma`、テスティングフレームワークには`mocha`を利用します。必要なライブラリはすでにインストール済みなので、karmaの設定ファイルを以下の要領で作成しましょう。

```js:frontend/karma.conf.js
var webpackConf = require('./config/webpack.config.js')

module.exports = function (config) {
  config.set({
    browsers: ['PhantomJS'],
    frameworks: ['mocha'],
    reporters: ['spec'],
    files: ['./test/index.js'],
    preprocessors: {
      './test/index.js': ['webpack']
    },
    webpack: webpackConf,
    webpackMiddleware: {
      noInfo: true
    },
    singleRun: true
  })
}

```

テストもES2015で書いていくので、先にセットアップしたwebpackの設定をここでも流用します。ほかにも、テストの実行環境（ブラウザ）やテスティングフレームワーク（mocha）、テストのエントリポイントとなるファイル（index.js）などを指定しています。

つづけてテストのエントリポイントとなる`index.js`を作成します。

```js:frontend/test/index.js
// Polyfill fn.bind() for PhantomJS
/* eslint-disable no-extend-native */
Function.prototype.bind = require('function-bind')

// require all test files (files that ends with .spec.js)
var testsContext = require.context('.', true, /\.spec$/)
testsContext.keys().forEach(testsContext)
```

これでやっっっとユニットテストを書く準備ができました！いよいよお待ちかねのテストを書く作業に入りましょう。

Todosコンポーネントの実装に沿って、「新しいTodoの内容（`newTodoTitle`）を与えた上でTodosコンポーネントの`addTodo()`メソッドをコールすると、todosに新しいオブジェクトが出来ること」を検証するテストを書きたいと思います。まずは最終的に期待するアサーションだけを書き、その状態でテストが失敗することを確認してみます。

```js:frontend/test/todos.spec.js
import assert from 'assert'

import Vue from 'vue'
import Todos from '../src/javascripts/todos.vue'

describe('Todos', () => {
  it('#addTodo creates new todo', function(done) {
    const vm = new Vue({
      template: '<div><todos ref="todos"></todos></div>',
      components: {
        'todos': Todos
      }
    }).$mount()
    let todosComponent = vm.$refs.todos;

    // Assert new todo is added
    assert.equal(todosComponent.todos.length, 1)
    assert.equal(todosComponent.todos[0].title, "Buy milk")
    done()
  })
})
```

`$ npm test`を実行してみましょう。`$ npm test`コマンドは`package.json`に書いたショートカットで、実体は`$ karma start ./karma.conf.js`です。

![4.jpg](https://qiita-image-store.s3.amazonaws.com/0/36524/567665dd-5bab-325f-f2c6-3b308153c1fb.jpeg)

ちゃんとテストが失敗しました。エラーの内容を見ると、`todos`の数が期待する値（1）と実際の結果（0）とでズレているのがわかります。Todosコンポーネントを初期化した段階では1つもtodoはできていないので、期待通りの結果ですね。

ではこのテストが正しく通るように、必要な手順を追記しましょう。`showTodoInput`と`newTodoTitle`をセットし、`addTodo()`をコールする処理をアサーションの前に追加します。

```js:frontend/test/todos.spec.js
...

describe('Todos', () => {
  it('#addTodo creates new todo', function(done) {
    const vm = new Vue({
      template: '<div><todos ref="todos"></todos></div>',
      components: {
        'todos': Todos
      }
    }).$mount()
    let todosComponent = vm.$refs.todos;

    // Create new todo
    todosComponent.showTodoInput = true
    todosComponent.newTodoTitle = "Buy milk"
    todosComponent.addTodo()

    // Assert new todo is added
    assert.equal(todosComponent.todos.length, 1)
    assert.equal(todosComponent.todos[0].title, "Buy milk")
    done()
  })
})
```

ふたたび`$ npm test`を実行してみます。

![5.jpg](https://qiita-image-store.s3.amazonaws.com/0/36524/80297f1d-231e-3074-b567-1e80801ffbf9.jpeg)

テストが通りました！これでやっとVue.jsで実装したTodosコンポーネントのユニットテストが書けました。

実際のアプリケーションで動作するコンポーネントはもっと複雑なロジックを持っていることが多いと思いますが、Vueコンポーネントのテストを書く要領はここまで書いた内容と同じです。フロントエンドのユニットテストを充実させ、バグやデグレを未然に防ぐ堅牢なフロントエンド開発をしていきましょう！

## Appendix

1. いざフロントエンドのユニットテストを書き始めてみると、テストが書きにくいと感じることがあります。アプリケーションのロジックが複雑すぎたり、ユーザーの操作にもとづく一時的なUIの状態が多いとこの傾向は強くなるようです。テストが書きにくいと感じる場合は、実はテストではなく実装を見直すべきであることが多々あります。テストしやすいコードは保守性や可読性の向上にもつながるので、テストが書きにくいと感じた際はコンポーネントの粒度や設計を見直してみてください。
2. XMLHttpRequestを利用して非同期でリクエストを発行しているコンポーネントなどには、`sinon.js`などのモックライブラリを利用すると便利です。

なお本記事を書くにあたって、以下のページを参考にしました。

- [Unit Testing - vue.js](http://vuejs.org/guide/unit-testing.html)
- [vuejs/vue-loader-example](https://github.com/vuejs/vue-loader-example/blob/master/build/karma.conf.js)