JavaScript
TDD
vue.js
フロントエンド
vue-test-utils

【Vue.js】いつから「フロントエンド開発でTDDができない」と錯覚していた?

Vue.jsのコンポーネント開発をTDDでやってみる

※ TDD (test-driven development): テスト駆動開発
※ テスト駆動開発は文化です。チームの「状況」「納期」「スキルレベル」、その他いろんな要因が絡んできた結果、そのチームが導入するかどうか決めたらいいと思います。
※ 例えがいいかはわかりませんが、私は「早起き」と「テスト」は同じようなものだと思っています。「早起き」は健康にいいよねって誰でも言うと思うけど、実際に万人がやっているかどうかは別じゃないですか。それと同じで「テストすること」も絶対いいことだと私は思っていますが、やるかどうかはチームの置かれている状況によって決まります。この記事は、その「テストを導入するかどうか」という意思決定の一助にでもなれればいいなと思います。

はじめに

こんにちは。ぼくです。

今回はVue.jsでTODOアプリを作ってみようと思います。その時の条件は

  • TDDで開発して、
  • ブラウザは最後まで見ない

です。普通にチュートリアルな感じなので、ざっくりコード眺めていけるかと思いますし、スクショ多めでいくので、自分でやらなくてもなんとなく追体験ができるようにしています。よかったら自分でもやってみてください。

またテストが分かるかたは、このTodoアプリを参考に、ハンズオンとか開いてくれたりするとうれしかったりします。Vue-meetupでもテストを書いている割合が圧倒的に少なかったので、テストユーザー増やしていきましょう〜〜

コンポーネント作成

目標物の確認

今回作成するものはこちらの TODOアプリ です。

いろんなフレームワークでTODOアプリのサンプルはこちらをみるとたくさん載っていて、

  • Backbone.js
  • AngularJS
  • Ember.js
  • KnockoutJS
  • ( etc )

などたくさんサンプルがあるので、フレームワーク選定の時にお使いください。

目標物の設計を分解して考える

責務の分解をします。

スクリーンショット 2018-06-24 0.21.35のコピー.png

今回は、コンポーネント構成を以下のようにする。

- TodoContainer.vue
    - NewTodo.vue
    - TodoItem.vue
  • TodoContainer.vue
    • todoのリストを管理するコンポーネント
  • NewTodo.vue
    • 新しいTodoリストのテキストを親のコンポーネントにemitする
  • TodoItem.vue
    • 親のコンポーネントからtodoリストのpropsをもらってレンダリングする

環境構築

$ vue -v
3
$ vue create vue-todo
1. manually select features
2. ---
◉ Babel
◉ Unit Testing
3. Jest
4. In package.json(どっちでもいい)
5. N
6. yarn

もしくは

$ git clone git@github.com:ykhirao/vue-todos.git
$ cd vue-todos
$ git checkout 9b2b09d5081 -b practice

ではじめのコミットをcheckoutしてやってください。

入力から作ってみる NewTodo.vue

ここのインプットフォームから作っていきたいと思いますが、このコンポーネントは

  • v-modelでinputのデータを管理する
  • エンターが押されると親コンポーネントにそのテキストをemitする

という責務にしましょう。

スクリーンショット 2018-06-24 0.51.29.png

$ touch src/components/NewTodo.vue
$ touch tests/unit/NewTodo.spec.js
  1. 期待する結果をexpectで書く
    • 今回はインプットタグがレンダーされているかだけを検証する
    • 基本的にコンポーネントは wrapper という変数にshallowMountでラップする
    • イコール文: expect(1).toBe(1)
    • これが基本の形なので覚えてください。

shallowMount のAPIはこちら

他にも mount というものでラップできるけど、弊社だと基本的には早いから shallowMount を使います。 またstubを作りまくればshallowMountだけでもかけますが、 mountを使ったほうが楽になるパターンもあったり結構深いテーマなので、両者の使い分けは後日Qiitaに書きます!!!

テスト1 単純なレンダリングテストを書いて、適切なhtml構造を作る

NewTodo.spec.js
import { shallowMount } from '@vue/test-utils'
import NewTodo from "@/components/NewTodo.vue"

describe("NewTodo.vue", () => {
  it("インプットタグをレンダリングする", () => {
    const wrapper = shallowMount(NewTodo)

    expect(wrapper.find("input.new").exists()).toBe(true)
  })
})

テストコードを簡単に説明すると newってclassがついたinputタグが存在する? ってテストです。

  • find = document.querySelector みたいなものです。 find api
  • exists api 存在するか
  • toBe(true) 判定式

スクリーンショット 2018-06-24 1.13.32.png

エラー文はこちら。

[Vue warn]: Failed to mount component: template or render function not defined.

そもそもテンプレートがないみたいなので作りにいきましょう。

src/components/NewTodo.vue
<template>
  <div>test</div>
</template>

次はマウントコンポーネントのエラーが無くなり単純な失敗みたいです。

スクリーンショット 2018-06-24 1.16.55.png

次は <input> を作ってみましょう。

src/components/NewTodo.vue
<template>
  <div>
    <input class="new">
  </div>
</template>

スクリーンショット 2018-06-24 1.18.03.png

パスしました!!!!

テスト2 clickイベントを拾い、新しいタグをemitする

とりあえずクリックイベントを発火させて、emitも検知できればいいかなと思います。

この段階で作り始めたときはしっかり設計できてなかった、以下のことを再定義します

  • .vueファイルのdataに { text: "text" } というオブジェクトを持たせる
  • それを createTodoという名前でemitします。
NewTodo.spec.js
it("新しいタグをemitする", () => {
  const text = "text"
  const wrapper = shallowMount(NewTodo)

  wrapper.setData({text})
  wrapper.find(".new").trigger("keyup.enter")

  expect(wrapper.emitted("createTodo")).toBe(text)
})

今回は、 shallowMount でコンポーネントをラップして、 find でinputタグを見つけ出し、 trigger でイベントを発火させて、本当にemitするかどうかというテストになります。

$ yarn test --watch

うーんテストが失敗しています。

スクリーンショット 2018-06-25 0.07.31.png

たぶんcreateTodoがemitされていませんね!作りにいきましょう。

src/components/NewTodo.vue
<template>
  <div>
    <input
      class="new"
      @keyup.enter="submit"
    >
  </div>
</template>

<script>
export default {
   methods: {
    submit() {
      this.$emit("createTodo", "ここにsubmitされるテキストが入るよ!")
    }
  } 
}
</script>

こんな感じで inputkeyup.enter エンターイベントが発火したときに submit というメソッドが呼ばれるように修正しました。このときのテストを以下のように修正して wrapper.emitted("createTodo") がどういう値になるか見てみましょう。

NewTodo.spec.js
it("新しいタグをemitする", () => {
  const text = "text"
  const wrapper = shallowMount(NewTodo)

  wrapper.setData({text})
  wrapper.find(".new").trigger("keyup.enter")

  console.log(wrapper.emitted("createTodo"))
  expect(wrapper.emitted("createTodo")).toBe(text)
})
console.log tests/unit/NewTodo.spec.js:18
  [ [ 'ここにsubmitれるテキストが入るよ!' ] ]

これで wrapper.emitted("createTodo")[0][0] に emitされるテキストが入ることがわかりました。のでテストを修正します。

NewTodo.spec.js
it("新しいタグをemitする", () => {
  const text = "text"
  const wrapper = shallowMount(NewTodo)

  wrapper.setData({text})
  wrapper.find(".new").trigger("keyup.enter")

  expect(wrapper.emitted("createTodo")[0][0]).toBe(text)
})

また2つのエラーがでてきました。

スクリーンショット 2018-06-25 0.17.58.png

2つのエラーは

  • 単純にexpect().toBe() が失敗していますが、これは後に解決させます。
  • もう一つは wrapper.setData({text}) というところでコンポーネントにdataを定義していないので怒られていると思われます。作りにいきましょう。 setData api
src/components/NewTodo.vue
<template>
  <div>
    <input
      class="new"
      v-model="text" // 1. ここ追加!!
      @keyup.enter="submit"
    >
  </div>
</template>

<script>
export default {
  data() { // 2. ここ追加!!
    return {
      text: ""
    }
  },
   methods: {
    submit() {
      this.$emit("createTodo", this.text) // 3. ここ追加!!
    }
  } 
}
</script>

修正箇所は3箇所です。

  1. v-modelに dataのtextをいれた
  2. .vueファイルにdata関数をもたせた
  3. emitの第二引数に textのデータをもたせた。

以上!!

スクリーンショット 2018-06-25 0.24.15.png

テストがパスしました〜!!!!!

$ yarn test --watch

こちらはgitで差分があるファイルだけホッとリロードでテストしてくれるので、完成したこのコンポーネントはもうコミットしておきます。

コンテイナーを作る

再度こちらの図になります。今回は TodoContainer.vue を作っていきます。

スクリーンショット 2018-06-24 0.21.35のコピー.png

まずはテストから。

$ touch tests/unit/TodoContainer.spec.js
$ touch src/components/TodoContainer.vue

よし!!次のテストは TodoContainer.vue をレンダーしたら NewTodo.vue もレンダーされるかチェックしようではないか。

tests/unit/TodoContainer.spec.js
import { shallowMount } from '@vue/test-utils'
import TodoContainer from "@/components/TodoContainer.vue"
import NewTodo from "@/components/NewTodo.vue"

describe("TodoContainer.vue", () => {
  it("formをレンダー", () => {
    const wrapper = shallowMount(TodoContainer)

    expect(wrapper.find(NewTodo).exists()).toBe(true)
  })
})

ポイントは find(NewTodo) でVueコンポーネントを簡単に探せるところです。

スクリーンショット 2018-06-25 0.40.06.png

もちろん失敗します〜〜のでエラーを見て直して行きましょう。先程みたやつですね!!テンプレートないよってやつです。

src/components/TodoContainer.vue
<template>
</template>

ん?なんかこれだけでもテストがパスしちゃいました。。。とりあえず進めると

src/components/TodoContainer.vue
<template>
  <NewTodo></NewTodo>
</template>

コンポーネント登録しろよ〜〜 って感じで怒られました。

スクリーンショット 2018-06-25 0.47.53.png

コンポーネントをインポートして登録します。

src/components/TodoContainer.vue
<template>
  <NewTodo></NewTodo>
</template>
<script>
import NewTodo from "@/components/NewTodo.vue"
export default {
  components: {
    NewTodo
  },
}
</script>

はい完了。

For recursive components, make sure to provide the "name" option.が気になったので、ちょっと NewTodo.vueに戻って

NewTodo.vue
<script>
export default {
  name: "new-todo", // こちら追加
  data() {
    return {
      text: ""
    }
  },

に戻ってnameオプションつけろ って感じで怒られましたところを追加しました。

そうすると

tests/unit/TodoContainer.spec.js
expect(wrapper.find(NewTodo).exists()).toBe(true)

がきちんと働くようになってコンポーネントを見つけ出してくれるみたいです。 nameオプションは必ずつけましょう。またnameオプションは ケバブ-ケース にすることとVueがいい感じに解釈してくれます。 html5では要素の大文字と小文字区別しないから <NewTodo> と とかが同じと解釈されるので、そのあたりのことを丸めるための処理。(かな)

次は子コンポーネントからemitされた値を [ todos ]というデータに追加するところを作ろうかと思います。テストでまた新しい概念が追加されるので頑張って覚えてください。むしろ覚えようとせずに「ふ〜〜んそんなものあるんだ〜〜」くらいがいいかもです。

今回やること

  • createTodo としてemitされたtextaddTodo というメソッドで追加する
tests/unit/TodoContainer.spec.js
import TodoContainer from "@/components/TodoContainer.vue"

it("todoを追加する", () => {
  const localThis = {
    todos: []
  }

  TodoContainer.methods.addTodo.call(localThis, "text")

  expect(localThis.todos[0]).toEqual({
    id: 1,
    text: "text",
    checked: false
  })
})

今回は localThis という概念がでてきます。後 call めっちゃ重要な概念だから 調べて 簡単に書きます。

import TodoContainer インポートしたコンポーネントは TodoContainer.methods.addTodo という形でコンポーネントメソッドを使うことができます。

ただこのときコンポーネントメソッドの this.todos が呼び出す文脈によって変わります。これを解消するのが call です。それでは見ていきましょう。

スクリーンショット 2018-06-25 1.13.55.png

chrome's.console.js
function getText () {
  return(this.text)
}
const localThis = { text: "localThisのテスト"}

getText()

getText.call(localThis)

call に引数を渡すことで擬似的に this を書き換えことができます。

ちなみに

chrome's.console.js
function getText () {
  console.log(this)
}
const localThis = { text: "localThisのテスト"}

getText()

getText.call(localThis)

chromeの開発者ツールでの thiswindow になります。今回は spec.js ではchromeは使わないのでまた別の this になることでしょう。node?

スクリーンショット 2018-06-25 1.16.42.png

テストに戻ります

expect(localThis.todos[0]).toEqual({})

オブジェクトの検証はtoEqualを使います。

  • toBe ===
  • toEqual ==

みたいな感じです。

スクリーンショット 2018-06-25 1.23.04.png

とりあえずなんかエラーでているので

  • dataに todosを作る
  • methodsにaddTodoを作る
src/components/TodoContainer.vue
<template>
  <NewTodo
    @createTodo="addTodo" // 追加
  ></NewTodo>
</template>
<script>
import NewTodo from "@/components/NewTodo.vue"

export default {
  name: "todo-container",
  components: {
    NewTodo
  },
  data() {
    return {
      todos: [] // 追加
    }
  },
  methods: {
    addTodo(text) { // 追加
      this.todos.push({text, checked:false, id: this.todos.length + 1 })
    },
  }
}
</script>

addTodoについて

  • Vueのオブサーバー壊さないためにpush で追加しています。
  • 暫定的にid は配列の数で適当にやってます。
  • またcheckedは最初はtodoは完了していないはずなので false からスタートさせています

とりあえずコンテナーは一旦作れたので次は子コンポーネントに移ります。

todolistを作る

ここを作ります〜〜

todos から一つだけ todo をもらって

  • テキストをレンダーする
  • チェックボックスレンダーする
  • チェックボックスにクリックイベント持たせて、親コンポーネントのメソッドをemitして、Todoから完了と非完了を入れ替える

スクリーンショット 2018-06-24 0.51.29のコピー2.png


$ touch src/components/TodoItem.vue
$ touch tests/unit/TodoItem.spec.js

テスト書きましょう。

tests/unit/TodoItem.spec.js
import { shallowMount } from '@vue/test-utils'
import TodoItem from "@/components/TodoItem.vue"

describe("todo-item.vue", () => {
  const id = 1
  it("renders text", () => {
    const text = "最初の文字"
    const wrapper = shallowMount(TodoItem, {
      propsData: {
        id,
        text,
        checked: true
      }
    })

    expect(wrapper.find("span").text()).toBe(text)

    wrapper.setProps({ text: "二番目の文字" })
    expect(wrapper.find("span").text()).toBe("二番目の文字")
  })
})

ここで出てくる新しい概念は shallowMount の第二引数です。 propsData という場所に todo一つだけわたします。こうすることで todoitemに todo の中身が渡っていきます。テンプレートがないよ ってエラーが出てきたので作ります。

src/components/TodoItem.vue
<template>
  <span>{{text}}</span>
</template>

<script>
export default {
  name: "todo-item",
  props: {
    text: {
      type: String,
      required: true
    }
  }
}
</script>

いい感じ。テストパスしました。テスト追加する〜

tests/unit/TodoItem.spec.js
  it("renders radio", () => {
    const text = "text1"
    const wrapper = shallowMount(TodoItem, {
      propsData: {
        id,
        text,
        checked: true
      }
    })

    expect(wrapper.find(".radio:checked").exists()).toBe(true)
  })

propsとして checked が渡ってきたらラジオボタンがちゃんとレンダリングされて、しかもcheckedがつくかチェックします。

スクリーンショット 2018-06-25 1.54.34.png

まあ存在しないって怒られました。

src/components/TodoItem.vue
<template>
  <input
    type="radio"
    class="radio"
  >
  <span>{{text}}</span>
</template>

Component template should contain exactly one root element. If you are using v-if on multiple elements, use v-else-if to chain them instead.

スクリーンショット 2018-06-25 1.55.54.png

コンポーネントは 要素、つまり<div> で囲ってあげればよさそうね!

src/components/TodoItem.vue
<template>
<div>
  <input
    type="radio"
    class="radio"
    :checked="checked" //追加
  >
  <span>{{text}}</span>
</div>
</template>

<script>
export default {
  name: "todo-item",
  props: {
    text: {
      type: String,
      required: true
    },
    checked: { //追加
      type: Boolean,
      required: true
    }
  }
}
</script>

これでテストがパスするはずです。

ポイントは

  • propsとして checked を受け取るところ
  • :checked="checked" でcheckedの値をhtmlにバインドしてあげるところ

です。!

tests/unit/TodoItem.spec.js
    const wrapper = shallowMount(TodoItem, {
      propsData: {
        id,
        text,
        checked: false // 追加
      }
    })

自分はcheckedがfalseのときも一応チェックしていましたがそれはお好みにまかせます。

次は

  • ラジオボタンをクリックしたら親コンポーネントにemitする
  • 親コンポーネントのtodosが書き換わったらpropsとして渡されて自動にチェックボックスのチェックも書き換わる

ということで親コンポーネントにemitするところまで書きましょう。

tests/unit/TodoItem.spec.js
  const id = 1

  it("emit event when I click radio", () => {
    const text = "text1"
    const wrapper = shallowMount(TodoItem, {
      propsData: {
        id,
        text,
        checked: false
      }
    })

    wrapper.find(".radio").trigger("click")

    expect(wrapper.emitted("toggleChecked")[0][0]).toBe(id)
  })

既存の知識だけでもうこのテストは理解できると思います。ラジオボタンのクリックイベントでテキストをemitするメソッドを発火させます。渡すのはtodoidにしましょう。

src/components/TodoItem.vue
<template>
<div>
  <input
    type="radio"
    class="radio"
    :checked="checked"
    @click="toggleChecked"
  >
  <span>{{text}}</span>
</div>
</template>

<script>
export default {
  name: "todo-item",
  props: {
    text: {
      type: String,
      required: true
    },
    checked: {
      type: Boolean,
      required: true
    },
    id: {
      type: Number,
      required: true
    }
  },
  methods: {
    toggleChecked() {
      this.$emit("toggleChecked", this.id)
    }
  }
}
</script>

完成形。

親コンテナーに戻る

最初の図間違っていたので修正しました。

スクリーンショット 2018-06-24 0.21.35のコピー3.png

こんな感じでいきましょう。

tests/unit/TodoContainer.spec.js
  it("toggleChecked", () => {
    const localThis = {
      todos: [
        {
          id: 1,
          text: "text",
          checked: true
        }
      ]
    }

    TodoContainer.methods.toggleChecked.call(localThis, 1)

    expect(localThis.todos[0].checked).toBe(false)
  })

todosからidでfindして、チェックをいれたり外したりする関数を作ります。

src/components/TodoContainer.vue
    toggleChecked(id) {
      const todo = this.todos.find(x => x.id === id)
      todo.checked = !todo.checked
    }

メソッドに追加したらpassしました。

src/components/TodoContainer.vue
<template>
<div>
  <NewTodo
    @createTodo="addTodo"
  ></NewTodo>
  <TodoItem
    v-for="todo in todos"
    :id="todo.id"
    :key="todo.id"
    :text="todo.text"
    :checked="todo.checked"
    @toggleChecked="toggleChecked"
  ></TodoItem>
</div>
</template>

結合する、ブラウザテスト

最終的に少し修正しました。コンポーネントに登録してなくてエラーでたのが2箇所。

スクリーンショット 2018-06-25 3.33.01.png

これで最終的にブラウザは1度しか見ずに簡単なTODOアプリが作れました!

スクリーンショット 2018-06-25 3.31.29.png

作ってみた結論

結論として言いたいのは

  • 簡単な内部ロジックで構成されているコンポーネント開発だと簡単にTDDできるよ
  • 私の経験だと3ヶ月くらいずっとテスト書き続けていたら、TDDできるようになると思う
  • StorybookでHot-reloadで開発してもいいと思うけどな!
  • これは得意な人がつきっきりで、チュートリアルな形ならやれるものであって、決してVue-test-utils初心者の方ができるものではない気がしてきました。。。

です。

参考資料

おわりに

いかがでしたか?開発終了まで一度もブラウザを見ずに開発できました。黒い画面(vim)好きなかたはぜひ。
TDDやれって言いたいわけじゃないですが、ロジック層だとTDDやってもいいと思うけど、それよりもまずは簡単なところからでもいいからテストを書いていって、Vue-test-utilsのAPI覚えて、テストに慣れていってください。チームがテストに関して成熟してきたかなと思ったらTDDやってもいいかもしれませんが、はじめにも言いましたが 自分のチーム で決めたらいいと思います。

少しでもテスト書く人が増えますように!!!!!!!

Thx

編集リクエストありがとうございます!!