[Vue] ページネーション機能の作り方とコンポーネント作成入門 からの続き。
続きなので前の記事で作ったやつがないとできません!
引き続きCodeSandboxでやります。
ローカルに環境作った方はそちらでどうぞ。
CodeSandboxのプレビューのところに、Testsっていうタブがあるのに気づきました?
これ、テストを実行して結果を表示してくれる便利機能です。
今回はこの機能を使えるようにします!
【2019/10/21追記】CodeSandboxのバグでテスト機能が動きません
インストール
Vueのテストには 公式の Vue Test Utils を使います。
テストランナーはJestを使います。
Add Dependency ボタンを押すとインストール用のモーダルが出るので、
必要なものを検索してDependenciesに追加します。
- @vue/test-utils
- vue-template-compiler
- jest
- vue-jest
- babel-jest
- eslint-plugin-jest
設定
package.json に Jest のための設定を追加します。
各設定については公式の説明にいちいち書いてあるのでここでは省きます。
{ "scripts": { // ... "test": "jest", }, // ... "jest": { "moduleFileExtensions": [ "js", "json", "vue" ], "moduleNameMapper": { "^@/(.*)$": "<rootDir>/src/$1" }, "transform": { ".*\\.(vue)$": "vue-jest", "^.+\\.js$": "<rootDir>/node_modules/babel-jest" } } }
eslintの設定にもJestについて追加。
"eslintConfig": { "env": { // ... "jest/globals": true }, "extends": [ // ... "plugin:jest/recommended" ], "plugins": ["jest"], }
CodeSandboxの人はここまでやったら1度リロードします。それで反映されます。
※CodeSandboxでJestとeslintを利用するとき、eslintがjestの構文をエラーにする不具合があるようです。
通常は上記設定でおkなはずですが、eslintがno-undefエラーを出すと思います。
気になる場合は、ファイル先頭に/* global describe, test, expect */
を追加してください。
テストファイルの作成
テスト用のjsファイルを配置するディレクトリ test をsrcと同じ階層に作ります。
※Jestのガイドだとテスト対象と同じルートに__tests__というディレクトリを作るのを推奨してますが、
個人的な好みもあると思うので、好きなように置き換えて貰えればおkです。
testディレクトリにprev-next.test.jsを作成します。
test内のディレクトリ構成はsrcと揃えておきましたが、大きく育てるつもりがないなら直下でいいと思います。
ファイルを作りましたら、公式の説明にあるサンプルソースをコピペして、
import { mount } from '@vue/test-utils' import Component from './component' describe('Component', () => { test('is a Vue instance', () => { const wrapper = mount(Component) expect(wrapper.isVueInstance()).toBeTruthy() }) })
環境に合うように書き換えます。
import { mount } from "@vue/test-utils"; import PrevNext from "@/components/pagination/prev-next"; describe("PrevNext", () => { test("is a Vue instance", () => { const wrapper = mount(PrevNext); expect(wrapper.isVueInstance()).toBeTruthy(); }); });
そうすると書き換えてるそばからテストが実行されてると思います。
すごい😲
ローカルでやってるひとは npm run test
または yarn run test
で実行できます。
これでテストが動くようになったので、あとは書いてくだけです。
テストを書く
書いてくだけって言われても何書いたらいいかわからへん……って思いました?
ですよね~~~。その気持ちすごいわかります。私もそうでした。
こういうテストを使うチュートリアルなんかインターネッツには腐るほどありますけど、大体HelloWorld的なのを動かすところまでしかなくて「で?」ってなるんですよね…。
なのでこの記事ではここまで読んでくれた人のため、「で?」と思ったその先も踏み込んで書いておこうと思いました。
まあ私も未だにテストをどう書けばいいのか迷っちゃうレベルなんで恐縮ですが、この先の内容がチュートリアル終わったばかりの方の参考になれば幸いです。
さて、インターネッツをさまよっていた時に私がなるほどーと思ったのが
「console.logで表示したいと思ったことをテストに書けばいい」
という文章でした。どこで見たのかは忘れたけど、この一文だけは強烈に覚えてます。
前の記事をなぞってやった人なら、console.log結構使ったんじゃないですか?w
や、大丈夫です。私もかなり使いながら書きましたよ!
思い出してください……console.logは正しいかどうか見て確認するために使ってませんでしたか?
つまり目で見てテストしてたということです。テストランナーは自分です。
でもまあいちいち自分で確認するの面倒なので、これをJestにやってもらおうというのがこの記事の目的です。
prev-next.vueはプロパティで受け取った page と totalPage を元にナビゲーションを表示するコンポーネントです。
- プロパティとして渡した値を元にHTMLが想定した通りに描画されるか
- クリックで想定した通りにナビゲーションが動作するか
というテストを書けばよさそうです。
1ページ目のナビゲーションが表示されるか?のテスト
さて、totalPageが20である場合、
1ページ目のナビゲーションはこんなHTMLになります:
<div class="pagination"> <div class="total">ページ 1/20</div> <a href="?page=2" class="next">次へ ></a> </div>
状態を文字にすると……:
.prev
は表示しない.total
は ページ 1/20と表示される。.next
は page+1した値がhrefに使われて表示される。
チェックする内容、決まりましたね!
テストを追加します。テスト名は日本語でも動作しますよ。
propsとして渡すデータは、mountのpropsDataで設定します。
test("pageが1のとき1ページ目のナビが表示される", () => { const wrapper = mount(PrevNext, { propsData: { page: 1, totalPage: 20 } }); });
ついでに最初にコピペして改変したテストにも、propsDataを追加しておいてください。
状態に当てはまるかどうかはマッチャーを使って書いていきます。
詳しいことはjestの公式見ていただくとして、先に書いておいた状態をテストにすると次のようになります。
「.prev は表示しない」
expect(wrapper.find(".prev").exists()).toBe(false);
「.total は ページ 1/20と表示される」
expect(wrapper.find(".total").text()).toBe("ページ 1/20");
「.next は page+1した値がhrefに使われて表示される」
// .next は表示する expect(wrapper.find(".next").exists()).toBe(true); // .nextのhrefが2ページ目になっている expect(wrapper.find(".next").attributes().href).toBe("?page=2");
macherが英語の文章っぽい作りになってるので、コメントが無くても何をしてるかわかりますね。
wrapper.findとかはvue-test-utilsのやつです。
以下にざっくり意味と説明へのリンクを載せておきます。
Jestの関数
メソッド等 | 説明 |
---|---|
describe | いくつかの関連テストをまとめたブロックを作成する |
test | テストを実行する関数。 第1引数にテスト名を、第2引数にテストの確認項目を含む関数を設定して使う |
expect | 値をテストしたい時に毎回使うやつ |
toBe | 引数で指定した値と同じかどうか確認したいときに使う |
vue-test-utilsの関数
メソッド等 | 説明 |
---|---|
wrapper | mountやshallowMountの戻り値。 マウントされたコンポーネントと仮想DOM、仮想DOMをテストするメソッドを含むオブジェクト |
find | wrapperのメソッド。 引数で渡したセレクタに一致するものを返す。 セレクタはCSSでもいいし、Vueコンポーネントでもいい。 戻り値はwrapperオブジェクト。 |
exists | wrapperのメソッド。 Wrapper か WrapperArray が存在するか検証するために使う。 存在してたらtrue、してなかったらfalseを返す。 |
text | wrapperのメソッド。 Wrapper のテキスト内容を返す。 |
attributes | wrapperのメソッド。 Wrapper にラップされている要素の属性をオブジェクトで返す。 |
テスト全体はこのようになりまして、
test("pageが1のとき1ページ目のナビが表示される", () => { const wrapper = mount(PrevNext, { propsData: { page: 1, totalPage: 20 } }); // .prev は表示しない expect(wrapper.find(".prev").exists()).toBe(false); // total は ページ 1/20と表示される expect(wrapper.find(".total").text()).toBe("ページ 1/20"); // .next は表示する expect(wrapper.find(".next").exists()).toBe(true); // .nextのhrefが2ページ目になっている expect(wrapper.find(".next").attributes().href).toBe("?page=2"); });
testsタブで結果が出ていればおkです。
最後のページ用のナビゲーションが表示されるか?のテスト
では前の要領で、最後のページについてもテストを書きます。
最後のページだとナビゲーションはこんなHTMLになっています:
<div class="pagination"> <a href="?page=19" class="prev">< 前へ</a> <div class="total">ページ 20/20</div> </div>
状態を文字にすると……:
.prev
は page-1した値がhrefに使われて表示される.total
は ページ 20/20 と表示される。.next
は 表示しない。
これをテストで書くと?
test("pageがtotalPageと同じとき最終ページ用のナビが表示される", () => { const wrapper = mount(PrevNext, { propsData: { page: 20, totalPage: 20 } }); // .prev は表示する expect(wrapper.find(".prev").exists()).toBe(true); // .nextのhrefが19ページ目になっている expect(wrapper.find(".prev").attributes().href).toBe("?page=19"); // total は ページ 20/20と表示される expect(wrapper.find(".total").text()).toBe("ページ 20/20"); // .next は表示しない expect(wrapper.find(".next").exists()).toBe(false); });
これは1ページ目の改変でスラスラっと書けますね!
クリックでページ移動できるか?のテスト
テストランナーが自分のとき、前へ、次へを押したらページ移動できるか確認してましたよね?
Jestにそのテストもやらせましょう。
1ページ目のときに次へを押したら2ページになりますから、
これが、
<div class="pagination"> <div class="total">ページ 1/20</div> <a href="?page=2" class="next">次へ ></a> </div>
こうなる。
<div class="pagination"> <a href="?page=1" class="prev">< 前へ</a> <div class="total">ページ 2/20</div> <a href="?page=3" class="next">次へ ></a> </div>
状態を文字にすると……
.next
をクリックしたら、.prevが表示される.prev
のhrefはpage=1である.total
は ページ 2/20 と表示される.next
はhrefがpage=3となって表示される
表示されてるか、hrefがpage1になっているかなどはもう書けますよね。
「クリックしたら」について説明します。
vue-test-utilsにはイベントを発火させるためのメソッド triggerがあります。
jQuery経験者なら見覚えあると思います。あれと似たようなやつです。
.next
をクリックしたことにするのは次のように書けます:
const next = wrapper.find(".next"); next.trigger('click');
あとは確認項目を添えれば.next
を押したときのテストができます。
test(".nextを押したら次のページに移動する", () => { const wrapper = mount(PrevNext, { propsData: { page: 1, totalPage: 20 } }); const next = wrapper.find(".next"); //.nextをクリックしたら、 next.trigger('click'); //.prevが表示される expect(wrapper.find(".prev").exists()).toBe(true); //.prevのhrefはpage=1である expect(wrapper.find(".prev").attributes().href).toBe("?page=1"); //.totalは ページ 2/20 と表示される expect(wrapper.find(".total").text()).toBe("ページ 2/20"); //.nextはhrefがpage=3となって表示される expect(wrapper.find(".next").exists()).toBe(true); expect(wrapper.find(".next").attributes().href).toBe("?page=3"); });
.prevを押したときのテストもこれの応用で書けますよね!
2ページ目のときに前へを押したら1ページになりますから、
これが、
<div class="pagination"> <a href="?page=1" class="prev">< 前へ</a> <div class="total">ページ 2/20</div> <a href="?page=3" class="next">次へ ></a> </div>
こうなる。
<div class="pagination"> <div class="total">ページ 1/20</div> <a href="?page=2" class="next">次へ ></a> </div>
テストは1ページ目のナビが表示されるかと同じ内容になりますね。
test(".prevを押したら前のページに移動する", () => { const wrapper = mount(PrevNext, { propsData: { page: 2, totalPage: 20 } }); const prev = wrapper.find(".prev"); //.prevをクリックしたら、 prev.trigger('click'); //..prevは表示されない expect(wrapper.find(".prev").exists()).toBe(false); //.totalは ページ 1/20 と表示される expect(wrapper.find(".total").text()).toBe("ページ 1/20"); //.nextはhrefがpage=2となって表示される expect(wrapper.find(".next").exists()).toBe(true); expect(wrapper.find(".next").attributes().href).toBe("?page=2"); });
カスタムイベントの発火をテストする
prev-next.vueはページ番号が変わるとき、カスタムイベント change を発火しています。
これもテストとして書く必要がありますね。
イベントが発火したかどうかを確認するのはemittedでできます:
expect(wrapper.emitted().change).toBeTruthy();
イベントが発火したときに渡してるページ番号も確認できます:
expect(wrapper.emitted().change[0]).toEqual([1]);
テストはこのようになります。
test(".prevか.nextを押したらchangeが発火する", () => { const wrapper = mount(PrevNext, { propsData: { page: 2, totalPage: 20 } }); const prev = wrapper.find(".prev"); //.prevをクリックしたら、 prev.trigger("click"); //changeイベントが発火する expect(wrapper.emitted().change).toBeTruthy(); //ページ番号は1が渡されます expect(wrapper.emitted().change[0]).toEqual([1]); const next = wrapper.find(".next"); //.nextをクリックしたら、 next.trigger("click"); //changeイベントが発火する expect(wrapper.emitted().change).toBeTruthy(); //ページ番号は2が渡されます expect(wrapper.emitted().change[1]).toEqual([2]); //2回イベントが発火してるはずです expect(wrapper.emitted().change.length).toBe(2); });
これでprev-next.vueのテストは書けました!🎉
App.vueのテストも書く
基本が分かったらもっとテスト書いてみたくなりましたか?
prev-next.vueを使っている親コンポーネント App.vueについてのテストを書きましょう。
test/App.test.js
を作成してから続きを読んでください。
App.vueをテストするとき、prev-next.vueについてテストする必要はありません。
そういうときはmountではなくshallowMountを使います。
shallowMountを使うと子コンポーネントはスタブ(代用品)に置き換えられます。
レンダリングをテストする
App.vueを最初に表示したときはこのようになってます:
<div class="content"> <ol> <li>Item 1</li> <li>Item 2</li> <li>Item 3</li> <li>Item 4</li> <li>Item 5</li> <li>Item 6</li> <li>Item 7</li> <li>Item 8</li> <li>Item 9</li> <li>Item 10</li> </ol> </div>
状態を書き出すと…
.content
が表示されるol > li
が10個ある- 最初のリストのテキストは Item 1である
- 最後のリストのテキストは Item 10である
となります。
テストはこの通り書くだけです:
import { shallowMount } from "@vue/test-utils"; import App from "@/App.vue"; describe("App", () => { test("1ページ目のリストが表示される", () => { const wrapper = shallowMount(App); //.contentが表示される expect(wrapper.contains(".content")).toBe(true); const lists = wrapper.findAll("ol > li"); //ol > li が10個ある expect(lists.length).toBe(10); //最初のリストのテキストは Item 1である expect(lists.at(0).text()).toBe("Item 1"); //最後のリストのテキストは Item 10である expect(lists.at(9).text()).toBe("Item 10"); }); });
新しいvue-test-utilsの関数をつかったので以下はその説明です。
メソッド等 | 説明 |
---|---|
findAll | WrapperArrayを返す。 findは1個、findAllは複数と覚えればおk |
at | WrapperArrayのメソッド。 渡された index の Wrapper を返す。indexは0スタート。 |
contains | .findに似ているが、containsはセレクタで指定したものがあるかどうかを検証することしかできない |
prev-next.vueのテスト同様、最後のページについてもテストを書きたいと思いましたか?
App.vueはdata()でダミーデータを作成していて、propsは設定してないのでpropDataでページ番号を渡すことはできません。
ならどうするか?直接data()の中身を操作すればいいのです!
最後のページである20をdata()にセットするには、wrapper.setDataを使います:
wrapper.setData({ page: 20 });
最後のページのテストはこのようになります:
test("20ページ目のリストが表示される", () => { const wrapper = shallowMount(App); wrapper.setData({ page: 20 }); //.contentが表示される expect(wrapper.contains(".content")).toBe(true); const lists = wrapper.findAll("ol > li"); //ol > li が10個ある expect(lists.length).toBe(10); //ol > li:first-child のテキストは Item 191である expect(lists.at(0).text()).toBe("Item 191"); //ol > li:last-child のテキストは Item 200である expect(lists.at(9).text()).toBe("Item 200"); });
子コンポーネントへの値渡しをテストする
親コンポーネントで子コンポーネントをテストする必要はないのでshallowMountを使いましたが、
子コンポーネントへpropsが正しく渡されているか?はテストに含めておきたいです。
まずprev-next.vueをimport
します:
import PrevNext from "@/components/pagination/prev-next";
contains
でApp.vueにprev-nex.vueがあるかを確認できます:
expect(wrapper.contains(PrevNext)).toBe(true);
prev-next.vueコンポーネントにpropsが渡っているかはpropsでチェックできます:
test("PrevNextにpageとtotalPageが渡される", () => { const wrapper = shallowMount(App); wrapper.setData({ page: 10, totalPage: 15 }); const pagination = wrapper.find(PrevNext); //PrevNextに渡されたpageは10です expect(pagination.props().page).toBe(10); //PrevNextに渡されたtotalPageは15です expect(pagination.props().totalPage).toBe(15); });
カスタムイベントの応答をテストする
App.vueで忘れてはいけないのが、prev-next.vueのchange
イベントで表示を変えているということです。
これも擬似的にchangeイベントを発火することができればテストが可能ですね。
子コンポーネントのイベントは$emitで発火しています。
wrapper.vm
にVueインスタンスがあるので、
changeイベントはprev-nex.vueの$emit
を直接叩いて発火させます:
wrapper.find(PrevNext).vm.$emit("change", 2);
これで2ページ目に移動したことになったので、
あとはリストの表示内容についての確認を書けばおkです:
test("1ページ目のchangeイベントで2ページ目が表示される", () => { const wrapper = shallowMount(App); //PrevNextがあります expect(wrapper.contains(PrevNext)).toBe(true); //PrevNextからchangeイベントが発火したことにする wrapper.find(PrevNext).vm.$emit("change", 2); const lists = wrapper.findAll("ol > li"); //ol > li が10個ある expect(lists.length).toBe(10); //ol > li:first-child のテキストは Item 11である expect(lists.at(0).text()).toBe("Item 11"); //ol > li:last-child のテキストは Item 20である expect(lists.at(9).text()).toBe("Item 20"); //PrevNextに渡されたpageは2です expect(wrapper.find(PrevNext).props().page).toBe(2); });
これでApp.vueのテストも書けました!🎉
何をテストするかを知る
この記事で初めてテストを書いた人、今もっとテストを書きたいと思ってませんか?
一旦おちついて、vue-test-utilsのガイド冒頭にある「何をテストするかを知る」を読んでみてください。
ここまで書いたテストを振り返って貰えば、これがどういう意味なのかふんわりわかると思います。
.nextを押したら次のページに移動するテストのとき、clickイベントでchangeイベントが発火することは検証に入れましたが、changeイベントがonNextメソッドで発火されたかはテストに書きませんでした。
後々onNextから別のメソッドに変更しても、発火するイベントがchangeである限りテストは合格です。
$refsを利用する
class名やコンポーネントで指定していたところを$refs
にすれば、class名やコンポーネントの変更にもパスできます。
.prev
にref="prev"
を指定します:
<a :href="`?page=${prevPage}`" class="prev" ref="prev" v-if="currentPage > 1" @click.prevent="onPrev" >< 前へ</a>
//$refs.prevはありません expect(wrapper.find({ ref: "prev" }).exists()).toBe(false);
コンポーネントにもrefが使えます:
<prev-next ref="pagination" :page="page" :totalPage="totalPage" @change="onPageChange"/>
//$refs.paginationがあります expect(wrapper.contains({ ref: "pagination" })).toBe(true); //$refs.pagination からchangeイベントが発火したことにする wrapper.find({ ref: "pagination" }).vm.$emit("change", 2);
テスト用のデータを使う
コンポーネントで作成したデータをそのまま使ってテストを書いていましたが、これだとデータを変えただけでテストが失敗するので、setDataでテスト用のデータを使うようにします:
test("最後のページのリストが表示される", () => { const wrapper = shallowMount(App); const items = new Array(54).fill(null).map((e, i) => `Item ${i + 1}`); const perPage = 10; const totalPage = Math.ceil(items.length / perPage); const page = totalPage; const count = items.length; wrapper.setData({ items, page, perPage, totalPage, count }); const lists = wrapper.findAll("ol > li"); expect(lists.length).toBe(4); expect(lists.at(0).text()).toBe("Item 51"); expect(lists.at(3).text()).toBe("Item 54"); });
この修正を加える前後でApp.vueやprev-next.vueを修正してみれば「コンポーネントのパブリックインターフェイスが同じままである限り、コンポーネントの内部実装が時間の経過とともにどのように変化してもテストは合格になります。」が実感できると思います。
パブリックインターフェースというのはVueだと
- propsからのデータ入力
- @clickなどのユーザーインタラクション
- mountedなどのライフサイクルメソッド
- $emitで発火するイベント
- renderによる出力
- 子コンポーネントとのつながり
です。
テストは書いたほうがいいけど、間違った書き方をしてると自分の首を締めることにもなるので、このアプローチは覚えておきたいです。