[Vue.js] ページネーション機能の作り方(弐)コンポーネントのテスト作成入門

[Vue] ページネーション機能の作り方とコンポーネント作成入門 からの続き。
続きなので前の記事で作ったやつがないとできません!

引き続きCodeSandboxでやります。
ローカルに環境作った方はそちらでどうぞ。

CodeSandboxのプレビューのところに、Testsっていうタブがあるのに気づきました?

これ、テストを実行して結果を表示してくれる便利機能です。
今回はこの機能を使えるようにします!

インストール

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 を元にナビゲーションを表示するコンポーネントです。

  1. プロパティとして渡した値を元にHTMLが想定した通りに描画されるか
  2. クリックで想定した通りにナビゲーションが動作するか

というテストを書けばよさそうです。

1ページ目のナビゲーションが表示されるか?のテスト

さて、totalPageが20である場合、
1ページ目のナビゲーションはこんなHTMLになります:

<div class="pagination">
  <div class="total">ページ 1/20</div>
  <a href="?page=2" class="next">次へ &gt;</a>
</div>

状態を文字にすると……:

  1. .prev は表示しない
  2. .total は ページ 1/20と表示される。
  3. .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">&lt; 前へ</a>
<div class="total">ページ 20/20</div>
</div>

状態を文字にすると……:

  1. .prev は page-1した値がhrefに使われて表示される
  2. .total は ページ 20/20 と表示される。
  3. .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">次へ &gt;</a>
</div>

こうなる。

<div class="pagination">
<a href="?page=1" class="prev">&lt; 前へ</a>
<div class="total">ページ 2/20</div>
<a href="?page=3" class="next">次へ &gt;</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">&lt; 前へ</a>
  <div class="total">ページ 2/20</div>
  <a href="?page=3" class="next">次へ &gt;</a>
</div>

こうなる。

<div class="pagination">
<div class="total">ページ 1/20</div>
<a href="?page=2" class="next">次へ &gt;</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名やコンポーネントの変更にもパスできます。

.prevref="prev"を指定します:

<a
  :href="`?page=${prevPage}`"
  class="prev"
  ref="prev"
  v-if="currentPage > 1"
  @click.prevent="onPrev"
>&lt; 前へ</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による出力
  • 子コンポーネントとのつながり

です。

テストは書いたほうがいいけど、間違った書き方をしてると自分の首を締めることにもなるので、このアプローチは覚えておきたいです。

Vue NYC – Component Tests with Vue.js – Matt O’Connell

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください