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
- 期待する結果をexpectで書く
- 今回はインプットタグがレンダーされているかだけを検証する
- 基本的にコンポーネントは
wrapper
という変数にshallowMountでラップする - イコール文: expect(1).toBe(1)
- これが基本の形なので覚えてください。
shallowMount のAPIはこちら
他にも mount
というものでラップできるけど、弊社だと基本的には早いから shallowMount
を使います。 またstubを作りまくればshallowMount
だけでもかけますが、 mount
を使ったほうが楽になるパターンもあったり結構深いテーマなので、両者の使い分けは後日Qiitaに書きます!!!
テスト1 単純なレンダリングテストを書いて、適切なhtml構造を作る
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.
そもそもテンプレートがないみたいなので作りにいきましょう。
<template>
<div>test</div>
</template>
次はマウントコンポーネントのエラーが無くなり単純な失敗みたいです。
次は <input>
を作ってみましょう。
<template>
<div>
<input class="new">
</div>
</template>
パスしました!!!!
テスト2 clickイベントを拾い、新しいタグをemitする
とりあえずクリックイベントを発火させて、emitも検知できればいいかなと思います。
この段階で作り始めたときはしっかり設計できてなかった、以下のことを再定義します
- .vueファイルのdataに { text: "text" } というオブジェクトを持たせる
- それを
createTodo
という名前でemitします。
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されていませんね!作りにいきましょう。
<template>
<div>
<input
class="new"
@keyup.enter="submit"
>
</div>
</template>
<script>
export default {
methods: {
submit() {
this.$emit("createTodo", "ここにsubmitされるテキストが入るよ!")
}
}
}
</script>
こんな感じで input
に keyup.enter
エンターイベントが発火したときに submit
というメソッドが呼ばれるように修正しました。このときのテストを以下のように修正して wrapper.emitted("createTodo")
がどういう値になるか見てみましょう。
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されるテキストが入ることがわかりました。のでテストを修正します。
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
<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箇所です。
- v-modelに dataのtextをいれた
- .vueファイルにdata関数をもたせた
- emitの第二引数に textのデータをもたせた。
以上!!
テストがパスしました〜!!!!!
$ yarn test --watch
こちらはgitで差分があるファイルだけホッとリロードでテストしてくれるので、完成したこのコンポーネントはもうコミットしておきます。
コンテイナーを作る
再度こちらの図になります。今回は TodoContainer.vue
を作っていきます。
まずはテストから。
$ touch tests/unit/TodoContainer.spec.js
$ touch src/components/TodoContainer.vue
よし!!次のテストは TodoContainer.vue
をレンダーしたら NewTodo.vue
もレンダーされるかチェックしようではないか。
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コンポーネントを簡単に探せるところです。
もちろん失敗します〜〜のでエラーを見て直して行きましょう。先程みたやつですね!!テンプレートないよってやつです。
<template>
</template>
ん?なんかこれだけでもテストがパスしちゃいました。。。とりあえず進めると
<template>
<NewTodo></NewTodo>
</template>
コンポーネント登録しろよ〜〜
って感じで怒られました。
コンポーネントをインポートして登録します。
<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
に戻って
<script>
export default {
name: "new-todo", // こちら追加
data() {
return {
text: ""
}
},
に戻ってnameオプションつけろ
って感じで怒られましたところを追加しました。
そうすると
expect(wrapper.find(NewTodo).exists()).toBe(true)
がきちんと働くようになってコンポーネントを見つけ出してくれるみたいです。 nameオプションは必ずつけましょう。またnameオプションは ケバブ-ケース
にすることとVueがいい感じに解釈してくれます。 html5では要素の大文字と小文字区別しないから <NewTodo>
と とかが同じと解釈されるので、そのあたりのことを丸めるための処理。(かな)
次は子コンポーネントからemitされた値を [ todos ]というデータに追加するところを作ろうかと思います。テストでまた新しい概念が追加されるので頑張って覚えてください。むしろ覚えようとせずに「ふ〜〜んそんなものあるんだ〜〜」くらいがいいかもです。
今回やること
-
createTodo
としてemitされたtext
をaddTodo
というメソッドで追加する
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
です。それでは見ていきましょう。
function getText () {
return(this.text)
}
const localThis = { text: "localThisのテスト"}
getText()
getText.call(localThis)
call
に引数を渡すことで擬似的に this
を書き換えことができます。
ちなみに
function getText () {
console.log(this)
}
const localThis = { text: "localThisのテスト"}
getText()
getText.call(localThis)
chromeの開発者ツールでの this
は window
になります。今回は spec.js
ではchromeは使わないのでまた別の this
になることでしょう。node?
テストに戻ります
expect(localThis.todos[0]).toEqual({})
オブジェクトの検証はtoEqual
を使います。
toBe ===
toEqual ==
みたいな感じです。
とりあえずなんかエラーでているので
- dataに todosを作る
- methodsにaddTodoを作る
<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
テスト書きましょう。
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
の中身が渡っていきます。テンプレートがないよ
ってエラーが出てきたので作ります。
<template>
<span>{{text}}</span>
</template>
<script>
export default {
name: "todo-item",
props: {
text: {
type: String,
required: true
}
}
}
</script>
いい感じ。テストパスしました。テスト追加する〜
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がつくかチェックします。
まあ存在しないって怒られました。
<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>
で囲ってあげればよさそうね!
<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にバインドしてあげるところ
です。!
const wrapper = shallowMount(TodoItem, {
propsData: {
id,
text,
checked: false // 追加
}
})
自分はcheckedがfalseのときも一応チェックしていましたがそれはお好みにまかせます。
次は
- ラジオボタンをクリックしたら親コンポーネントにemitする
- 親コンポーネントのtodosが書き換わったらpropsとして渡されて自動にチェックボックスのチェックも書き換わる
ということで親コンポーネントにemitするところまで書きましょう。
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するメソッドを発火させます。渡すのはtodo
のid
にしましょう。
<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>
完成形。
親コンテナーに戻る
最初の図間違っていたので修正しました。
こんな感じでいきましょう。
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して、チェックをいれたり外したりする関数を作ります。
toggleChecked(id) {
const todo = this.todos.find(x => x.id === id)
todo.checked = !todo.checked
}
メソッドに追加したらpassしました。
<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
編集リクエストありがとうございます!!