Edited at

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

More than 1 year has passed since last update.


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

※ TDD (test-driven development): テスト駆動開発

※ テスト駆動開発は文化です。チームの「状況」「納期」「スキルレベル」、その他いろんな要因が絡んできた結果、そのチームが導入するかどうか決めたらいいと思います。

※ 例えがいいかはわかりませんが、私は「早起き」と「テスト」は同じようなものだと思っています。「早起き」は健康にいいよねって誰でも言うと思うけど、実際に万人がやっているかどうかは別じゃないですか。それと同じで「テストすること」も絶対いいことだと私は思っていますが、やるかどうかはチームの置かれている状況によって決まります。この記事は、その「テストを導入するかどうか」という意思決定の一助にでもなれればいいなと思います。


はじめに

こんにちは。ぼくです。

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


  • TDDで開発して、

  • ブラウザは最後まで見ない

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

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


コンポーネント作成


目標物の確認

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

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


  • Backbone.js

  • AngularJS

  • Ember.js

  • KnockoutJS

  • ( etc )

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


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

責務の分解をします。

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

- 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する

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

$ 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) 判定式

エラー文はこちら。


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


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


src/components/NewTodo.vue

<template>

<div>test</div>
</template>

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

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


src/components/NewTodo.vue

<template>

<div>
<input class="new">
</div>
</template>

パスしました!!!!


テスト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

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

たぶん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つのエラーがでてきました。

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のデータをもたせた。

以上!!

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

$ yarn test --watch

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


コンテイナーを作る

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

まずはテストから。

$ 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コンポーネントを簡単に探せるところです。

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


src/components/TodoContainer.vue

<template>

</template>

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


src/components/TodoContainer.vue

<template>

<NewTodo></NewTodo>
</template>

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

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


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 です。それでは見ていきましょう。


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?

テストに戻ります

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

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


  • toBe ===


  • toEqual ==

みたいな感じです。

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


  • 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から完了と非完了を入れ替える



$ 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がつくかチェックします。

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


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.


コンポーネントは 要素、つまり<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>


完成形。


親コンテナーに戻る

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

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


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箇所。

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


作ってみた結論

結論として言いたいのは


  • 簡単な内部ロジックで構成されているコンポーネント開発だと簡単にTDDできるよ

  • 私の経験だと3ヶ月くらいずっとテスト書き続けていたら、TDDできるようになると思う

  • StorybookでHot-reloadで開発してもいいと思うけどな!

  • これは得意な人がつきっきりで、チュートリアルな形ならやれるものであって、決してVue-test-utils初心者の方ができるものではない気がしてきました。。。

です。


参考資料


おわりに

いかがでしたか?開発終了まで一度もブラウザを見ずに開発できました。黒い画面(vim)好きなかたはぜひ。

TDDやれって言いたいわけじゃないですが、ロジック層だとTDDやってもいいと思うけど、それよりもまずは簡単なところからでもいいからテストを書いていって、Vue-test-utilsのAPI覚えて、テストに慣れていってください。チームがテストに関して成熟してきたかなと思ったらTDDやってもいいかもしれませんが、はじめにも言いましたが 自分のチーム で決めたらいいと思います。

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


Thx

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