Vue.js（Vuetify）とFirebaseで簡単なブログを公開して、ちょっとテストも書く

# はじめに
以前、[Vue.jsとRailsでTODOアプリのチュートリアルみたいなものを作ってみた](https://qiita.com/naoki85/items/51a8b0f2cbf949d08b11)という記事を作成しました。
ただ、フロントエンドを完全に分離するなら、やはり単独で切り出した方が良いかなと悶々と考えておりました。
今回は、Vue.js と Firebase で簡単なブログを作成してみたので、手順をまとめておきたいと思います。

サンプルはこちらになります。
[https://my-blogs-158b8.firebaseapp.com/](https://my-blogs-158b8.firebaseapp.com/)

なお、実行環境は下記の通りです。

- Node.js 10.6.0
- npm 6.1.0

Node.js のインストールは下記の記事が参考になりました。
[Node.js と npm インストールとアップデート](https://qiita.com/jaxx2104/items/2277cec77850f2d83c7a)

# Vuetify
[Vuetify](https://vuetifyjs.com/ja/)はVue.jsのマテリアルデザイン用フレームワークです。
デザインで楽をしたいので、今回導入しました。
なお、最新の1.1.4では、`vue-cli` の3系が必要なようです。

# プロジェクトの作成
[Vuetify の QuickStart](https://vuetifyjs.com/ja/getting-started/quick-start)を見ながら進めます。

## Vue CLI-3 のインストール

```sh
$ npm install @vue/cli -g
```

## Vueプロジェクトの作成

```sh
$ vue create my-app
# 初期設定。こだわりがなければ default で
? Please pick a preset: default (babel, eslint)
# package manager npm or yarn
? Pick the package manager to use when installing dependencies: Yarn
```
パッケージマネージャーは以後、 `npm` ではなく `yarn` を使用していきます。

## Vuetifyの導入

```sh
$ vue add vuetify
# 全部Enterで良さそう
? Use a pre-made template? (will replace App.vue and HelloWorld.vue) Yes
? Use custom theme? No
? Use a-la-carte components? No
? Use babel/polyfill? Yes
```

## 起動させてみる
ここまで行けば、とりあえずで確認してみます。

```sh
$ yarn serve
http://localhost:8080
```
`http://localhost:8080` で下記のように画面が表示されるかと思います。 
 
![スクリーンショット 2018-07-08 21.56.18.png](https://qiita-image-store.s3.amazonaws.com/0/118817/df3e22b8-3238-853c-fb82-8befdb2d1060.png)

# Firebaseプロジェクトの作成
Firebaseに関しては、ドットインストールさんの動画が分かりやすいと思うので、もし見ていない方がいらっしゃれば先に見ておくと良いかと思います。
[Firebaseでウェブサイトを公開してみよう | ドットインストール](https://dotinstall.com/lessons/hosting_firebase)

[Firebase](https://firebase.google.com/) にいき、ログインします。
Firebase は色々できるのですが、今回は Hosting サービスを使用します。

![スクリーンショット 2018-07-08 22.08.38 (1).png](https://qiita-image-store.s3.amazonaws.com/0/118817/3978bad6-a24d-dd6c-46ba-80eb35919cd6.png)

新しいプロジェクトを作成します。  
プロジェクト名は `my-blogs` 、言語は日本語にします。

![スクリーンショット 2018-07-08 22.07.37.png](https://qiita-image-store.s3.amazonaws.com/0/118817/ce3be62b-c709-c84c-90d3-cd9b7bf2aea7.png)

作成が完了すると、ローカルに `firebase-tools` を入れるよう案内されるので、ローカル環境を作成します。

# プロジェクトにFirebaseの設定を追加
こちらの記事を参考にさせていただきました。
[Vue.js によるアプリを Firebase で Hosting する最短の道](https://qiita.com/Satachito/items/4a00b402970d657a88f3)

```sh
$ npm install -g firebase-tools
```
firebaseコマンドが使用できると思うので、ログインします。

```sh
$ firebase login
```
Firebaseのロゴが出たら成功です。
プロジェクトディレクトリ内でFirebaseの設定を初期化します。

```sh
$ firebase init hosting
```
- どのプロジェクトの設定か聞かれるので、上記のFirebaseコンソールで作成下プロジェクト名を選択します。（ `my-blogs` ）
- `What do you want to use as your public directory?` という質問には `dist` で回答すると良いようです。
- 他の質問はデフォルトで良さそうです。
これでプロジェクトディレクトリに `firebase.json` ができていればOKです。

# デプロイしてみる
Vue.jsを公開ようにするには、まずビルドする必要があります。

```sh
$ yarn build
```
そしてFirebaseにデプロイします。

```sh
$ firebase deploy
```
これで、FirebaseのURLにアクセスすればローカルで構築したような画面になっているかと思います。
今後ある程度開発がまとまったら、適宜デプロイできるようになりました。

# 必要なコンポーネントを作成、編集
表示されている画面は、 `src/App.vue` に記載されています。
ヘッダーやフッターを修正する場合は、その画面を編集します。
ヘッダー、フッターのVuetifyコンポーネントは下記です。

- [Toolbars](https://vuetifyjs.com/ja/components/toolbars)
- [Footer](https://vuetifyjs.com/ja/components/footer)

このあたりはお好みなので、ドキュメントを読みながら好きなように修正していただくのが良いかと思います。
参考までに、私がサンプルを作成した際の差分は下記になります。
[https://github.com/naoki85/my-blogs/commit/1bf479bca2d205811645a5c49f553d641c3cd7d8](https://github.com/naoki85/my-blogs/commit/1bf479bca2d205811645a5c49f553d641c3cd7d8)
## 記事一覧画面用のコンポーネント
後々使用するので、 `src/components` ディレクトリを作成して、 `Index.vue` を作成します。
[Card](https://vuetifyjs.com/ja/components/cards)コンポーネントを使用して作成します。
記事のサムネイルとタイトルが表示されるようにしますが、ちょっと困ったのは、 `img` の `src` にバインドしようとするとうまくいきませんでした。
下記の記事の方法を参考にさせていただきました。
[Vue.jsでimgタグのsrcをバインドさせる際の注意点](http://tk2000ex.blogspot.com/2017/11/vue.html)

```html:src/components/Index.vue
<template>
  <div>
    <v-card>
      <v-container
        fluid
        grid-list-lg
      >
        <v-layout row wrap>
          <v-flex xs12>
            <v-card color="grey lighten-5">
              <v-container fluid grid-list-lg>
                <v-layout row>
                  <v-flex xs7>
                    <div>
                      <div class="headline">About Vuetify</div>
                      <div>2018-07-10</div>
                    </div>
                  </v-flex>
                  <v-flex xs5>
                    <v-card-media
                      :src="image_src"
                      height="125px"
                      contain
                    ></v-card-media>
                  </v-flex>
                </v-layout>
              </v-container>
            </v-card>
          </v-flex>
        </v-layout>
      </v-container>
    </v-card>
  </div>
</template>

<script>
  export default {
    data() {
      return {
        image_src: require("@/assets/logo.png")
      }
    }
  }
</script>
```

## 記事詳細のコンポーネントを作る
今回は静的なブログなので、記事ごとにコンポーネントを用意することを想定しています。
Vuetify の記事を書いている想定で、 `src/components/PostVuetify.js` というファイルを作成します。 ぶっちゃけ `template` の中身はなんでも良いです。

```html
<template>
  <div>
    This blog written about Vuetify!!
  </div>
</template>
```

## VueRouterの追加
[https://router.vuejs.org/ja/](https://router.vuejs.org/ja/)
SPAのためのルータを提供してくれます。

```sh
$ yarn add vue-router
```
`plugins` ディレクトリに `router.js` を作成します。  
ここに、先ほど作成した `Index.vue` と `PostVuetify` を登録することで、対応したパスの時にそのコンポーネントが呼ばれるようになります。

```js:plugins/router.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Index from '../components/Index'
import PostVuetify from '../components/PostVuetify'
Vue.use(VueRouter)
export default new VueRouter({
  mode: 'history',
  routes: [
    { path: '/', component: Index },
    { path: '/vuetify', component: PostVuetify },
  ],
})
```
この `router.js` を `main.js` に登録します。

```js:src/main.js
import '@babel/polyfill'
import Vue from 'vue'
import './plugins/vuetify'
import Router from './plugins/router'
import App from './App.vue'
Vue.config.productionTip = false
new Vue({
  router: Router,
  render: h => h(App)
}).$mount('#app')
```

## VueRouter用のリンクに差し替え
### App.vue
`App.vue` をベースにコンポーネントを出し入れするようにします。  
そのために、 [router-view](https://router.vuejs.org/ja/guide/essentials/named-views.html) を使用します。
この部分がよしなに差し変わります。

```diff:src/App.vue
<v-content>
-  <HelloWorld/>
+  <router-view></router-view>
</v-content>
```
ここまで書いて、 `yarn serve` を実行したあと、 `http://localhost:8080` 、 `http://localhost:8080/vuetify` にアクセスすると、画面が変わるかと思います。
さて、ただまだ画面のボタンから遷移はできないので、設定していきます。

### Index.vueにrouter-linkを設定する
記事のカードが押されたら、記事詳細に差し代わるようにします。
その時は [router-link](https://router.vuejs.org/ja/api/#router-link-props) を使用するのですが、 Vuetify 製のコンポーネントの多くは、 `to` という属性をつけると、 `router-link` として解釈してくれます。

そのため、 `Index.vue` の `v-card` タグに以下のように設定します。

```js:src/components/Index.vue
<v-card color="grey lighten-5" to="/vuetify">
```
これで、カードをクリックすると、記事詳細ページに画面が切り替わると思います。
ついでに、ヘッダーメニューの「Home」も、押されたら一覧に戻るようにします。
これもVuetifyのコンポーネントなので `to` が使えます。
下記の差分のように変更するのが良いかと思います。
[https://github.com/naoki85/my-blogs/commit/51b0156b9e33caf54d66cad79148da42cbda2a73](https://github.com/naoki85/my-blogs/commit/51b0156b9e33caf54d66cad79148da42cbda2a73)

ここまでで、とりあえず形はできました。

記事を追加する場合は、

- 新しく `PostVuetify.vue` のようなファイルを作成する。
- `router.js` に登録する。
- `Index.vue` に同じようなカードコンポーネントを登録する。

ただ、 `Index.vue` にあのカードコンポーネントをツラツラ追記していくのは長ったらしいので、少しまとめてみます。

# Cardコンポーネントを独自コンポーネントに
新しく、 Vue.js に関する記事を書いたとして、 `PostVue.vue` というコンポーネントを作成します。
`router.js` に登録し一覧に表示させるようにしますが、Cardコンポーネントはそのままだとかなり長くなってしまうので、新しくラッパーコンポーネントを作成します。
（ループ文を使えば良いと思いますが、練習ということで。）

新しく、 `src/components/PostCard.vue` を作成します。
ここには、Cardコンポーネントをそのまま移植しますが、親コンポーネントから [props](https://jp.vuejs.org/v2/guide/components-props.html) を使って値を受け取ります。
（よくあるコンポーネントもこんな感じで作成されていると思います。）

親コンポーネントから指示されたい項目としては、タイトル、公開日、リンクのパス、画像のパスかと思うので、それぞれ `props` に登録します。

```js:src/components/PostCard.vue
<template>
  <v-flex xs12>
    <v-card color="grey lighten-5" :to="to">
      <v-container fluid grid-list-lg>
        <v-layout row>
          <v-flex xs7>
            <div>
              <div class="headline">{{ title  }}</div>
              <div>{{ date  }}</div>
            </div>
          </v-flex>
          <v-flex xs5>
            <v-card-media
              :src="src"
              height="125px"
              contain
            ></v-card-media>
          </v-flex>
        </v-layout>
      </v-container>
    </v-card>
  </v-flex>
</template>

<script>
  export default {
    props: {
      title: String,
      date: String,
      to: String,
      src: String,
    }
  }
</script>
```
このコンポーネントを `Index.vue` に取り込みます。

```js:src/components/Index.vue
<script>
  import PostCard from './PostCard'
  export default {
    components: {
      'v-post-card': PostCard,
    },
    // ...
  }
</script>
```
`v-post-card` というタグを登録し、`v-card` だった部分を置き換えます。
その際、 `title` などをタグ内で指定するようにします。
差分表示だとGithubのリンクの方が見やすいかもしれません。
[https://github.com/naoki85/my-blogs/commit/f3c531c43c36f1f7882a05229741c58f5191aedd#diff-be1bbdeb1231c484c6590c15778f98c6](https://github.com/naoki85/my-blogs/commit/f3c531c43c36f1f7882a05229741c58f5191aedd#diff-be1bbdeb1231c484c6590c15778f98c6)
あとは、記事を追加するたびにこのコンポーネントを増やして行けば、通常よりかは短くなるのではないでしょうか。  

ただ、渡しているデータ以外は同じなので、ループ文も使用します。
最終的には `Index.vue` は下記のようになっています。

```js
<template>
  <div>
    <v-card>
      <v-container
        fluid
        grid-list-lg
      >
        <v-layout row wrap>
          <v-post-card v-for="post in posts"
            :title="post.title"
            :date="post.date"
            :to="post.to"
            :src="post.src"
          />
        </v-layout>
      </v-container>
    </v-card>
  </div>
</template>

<script>
  import PostCard from './PostCard'
  export default {
    components: {
      'v-post-card': PostCard,
    },
    data() {
      return {
        posts: [
          {
            title: "About Vuetify",
            date: "2018-07-10",
            to: "/vuetify",
            src: require("@/assets/logo.png")
          },
          {
            title: "About Vue.js",
            date: "2018-07-12",
            to: "/vuejs",
            src: require("@/assets/logo_vue.png")
          }, 
        ]
      }
    }
  }
</script>
```
`posts` に各記事の情報を持たせ、それを `post in posts` で取り出しています。
少しは追加しやすくなったのではないでしょうか。

# テストの導入
## テストツールのインストール
普段はRSpecやPHPUnitを使ってAPIのテストなんかを書いていますが、恥ずかしながら、JS関連のテストは書いたことはありませんでした。  
事前に調べていたところ、[vue-test-utils](https://vue-test-utils.vuejs.org/ja/) と [Jest](https://jestjs.io/docs/en/getting-started) というテストフレームワークを使おうと決めていましたが、なかなか導入がうまくいきませんでした。

どうもVue CLI 3系だと何かしら変わっているのではないかと推測しました。（未確認の情報です。）
幸いなことに、Vue CLIのドキュメントを眺めていたところ、ユニットテストに関するモジュールの案内があったので、そちらを使用します。

- [Unit Testing | Vue CLI](https://cli.vuejs.org/config/#unit-testing)
- [https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-unit-jest](https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-unit-jest)

テストツールは前置き通り、[vue-test-utils](https://vue-test-utils.vuejs.org/ja/) と [Jest](https://jestjs.io/docs/en/getting-started) です。

```sh
$ vue add @vue/unit-jest
```
このコマンドを実行後、 いくつかのファイルが書き換わります。

## 初めてのテストを実行してみる
重要な設定は、 `package.json` や新しくできた `jest.config.js` に記載されていますが、詳細は割愛します。
`jest.config.js` を編集することでテストカバレッジの設定もできるようなので、試してみてください。
[Configuring Jest](https://jestjs.io/docs/en/configuration)

とにもかくにもテストが実行できるかどうかを確認できた方が良いと思います。
デフォルトで、 `tests/HelloWorld.spec.js` というファイルができているかと思います。
これは `src/components/HelloWorld.vue` 用のテストファイルです。

テストの実行には以下のコマンドを使用します。

```sh
$ yarn test:unit
# 実際は vue-cli-service test:unit というコマンドが実行されている
```
コマンドを実行すると、失敗したような感じになるでしょうか？
Vuetify をインストールしている場合、デフォルトのテストはコケるかと思います。
これは、Vuetify のインストール時に `src/components/HelloWorld.vue` を書き換えているので、テストと齟齬が生じています。

```
Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        6.016s
```
このテストを通るように修正していきます。

### HelloWorld.spec.js の修正
やることとしては、Vuetifyを使用する宣言をします。
この方法は下記のドキュメントを参考にしました。
[localVue | Vue Test Utils](https://vue-test-utils.vuejs.org/ja/api/options.html#localvue)

修正後のファイルは下記のようになります。

```js:tests/unit/HelloWorld.spec.js
// mount の他に createLocalVue もインポートする
import { mount, createLocalVue } from '@vue/test-utils'
// Vuetify をインポートする
import Vuetify from 'vuetify'
import HelloWorld from '@/components/HelloWorld.vue'

describe('HelloWorld.vue', () => {
  it('renders props.msg when passed', () => {
    // Vueインスタンスを別で作成する
    const localVue = createLocalVue()
    // そのVueインスタンスにVuetifyを取り込む
    localVue.use(Vuetify)
    const msg = 'new message'
    const wrapper = mount(HelloWorld, {
      // マウントするVueインスタンスを指定
      localVue,
      propsData: { msg }
    })
    expect(wrapper.text()).toMatch(msg)
  })
})
```
これでテストを実行しても、まだ失敗すると思います。
ここで無駄に悩んでしまったのですが、上記のテストは `props.msg` で渡した文字列がちゃんと描画されているのかを確認していますが、そもそも `src/components/HelloWorld.vue` では `msg` を表示していませんでした。

### src/components/HelloWorld.vue の修正
`<h1>{{ msg }}</h1>` というタグを適当な場所に追加してください。

```diff
<template>
  <v-container fluid>
+    <h1>{{ msg }}</h1>
    <!-- ... -->
```
これでテストを実行すると、うまく行くはずです。

```
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        6.269s
```
ここまでの差分は下記になりますので、分かりづらければ参考にしてください。
[https://github.com/naoki85/my-blogs/commit/f969910ac0c097615b0e0b7224cce4c8b72ff613](https://github.com/naoki85/my-blogs/commit/f969910ac0c097615b0e0b7224cce4c8b72ff613)

## 記事詳細のテストを作る
記事詳細はほぼHTMLのみのコンポーネントなので、サンプル同様、タイトルだけチェックするくらいで終わりにします。 `PostVuetify.vue` と `PostVue.vue` は同じような感じなのでテストファイルは一つにまとめて、`Post.spec.js` とします。

```js:tests/unit/Post.spec.js
import { mount, createLocalVue } from '@vue/test-utils'
import Vuetify from 'vuetify'
import PostVuetify from '@/components/PostVuetify.vue'
import PostVue from '@/components/PostVue.vue'

describe('PostVuetify.vue', () => {
  it('renders title', () => {
    const localVue = createLocalVue()
    localVue.use(Vuetify)
    const wrapper = mount(PostVuetify, {
      localVue
    })
    expect(wrapper.text()).toMatch('About Vuetify')
  })
})

describe('PostVue.vue', () => {
  it('renders title', () => {
    const localVue = createLocalVue()
    localVue.use(Vuetify)
    const wrapper = mount(PostVue, {
      localVue
    })
    expect(wrapper.text()).toMatch('About Vue.js')
  })
})
```

## カードコンポーネントのテストを書く
一覧画面のカードコンポーネントのラッパー用に作成した `PostCard.vue` のテストを書きます。
`props` で渡した値が描画されているかのチェックかと思うので、ちょっとサボってタイトルだけチェックします。
（本当はDOMを取得して確認した方が良いのかな？）

なお、 `PostCard.vue` は `VueRouter` を使用しています。
`VueRouter` を使用する場合は注意点があるようなので、下記の通りに実施します。
[Vue Router と一緒に使用する | Vue Test Utils](https://vue-test-utils.vuejs.org/ja/guides/using-with-vue-router.html)

```js:tests/unit/PostCard.spec.js
import { mount, createLocalVue } from '@vue/test-utils'
import Vuetify from 'vuetify'
import VueRouter from 'vue-router'
import PostCard from '@/components/PostCard.vue'

const localVue = createLocalVue()
localVue.use(Vuetify)
localVue.use(VueRouter)
const router = new VueRouter()

describe('PostCard.vue', () => {
  it('renders props when passed', () => {
    const wrapper = mount(PostCard, {
      localVue,
      router,
      propsData: { title: 'About Ruby', date: '2018-07-14', to: '/ruby', src: '/img/logo.png' }
    })
    expect(wrapper.text()).toMatch('About Ruby')
  })
})
```

## 一覧画面のテストを書く
一覧画面のテストですが、 `PostCard.vue` は別でテストしているので、ここはスタブを返すようにします。
[スタブを使用する | Vue Test Utils](https://vue-test-utils.vuejs.org/ja/guides/#%E9%9D%9E%E5%90%8C%E6%9C%9F%E5%8B%95%E4%BD%9C%E3%81%AE%E3%83%86%E3%82%B9%E3%83%88)

ただ、上記をスタブにすると、描画関係で確認することがないような気がするので、Vueインスタンスが持つプロパティの確認をします。

```js:tests/unit/Index.spec.js
import { mount, createLocalVue } from '@vue/test-utils'
import Vuetify from 'vuetify'
import VueRouter from 'vue-router'
import Index from '@/components/Index.vue'

const localVue = createLocalVue()
localVue.use(Vuetify)
localVue.use(VueRouter)
const router = new VueRouter()
const wrapper = mount(Index, {
  localVue,
  router,
  stubs: ['v-post-card']
})

describe('Index.vue', () => {
  it('count data.posts', () => {
    expect(wrapper.vm.$data.posts.length).toBe(2)
  })
})
```
とりあえずこのくらい書いて、全部テストが通ることを確認します。
ちなみに、それぞれのファイルだけテストを実行したい場合は、引数でファイル名を与えます。

```sh
$ yarn test:unit tests/unit/Index.spec.js
```

## さいごに
こちらの記事は、以前個人ブログの方で書かせていただいたものをまとめたものになります。
よろしければそちらもご覧ください。
ブログ自体は　Vue.js + Ruby on Rails で作成しています。

- [Vue.js のプロジェクトを Firebase にデプロイ](https://blog.bookrecorder.net/posts/8)
- [コンポーネントを使って見た目を作る](https://blog.bookrecorder.net/posts/9)
- [コンポーネントのテストコードを書いてみる](https://blog.bookrecorder.net/posts/10)

ブログに関するご意見もいただけると嬉しいです！
ありがとうございました！