シャッフル主任の進捗報告

興味のあるものを作ります。進捗を不定期にご報告します。

そうだ、教祖になろう。出エジプト記 第4章6節 クライアントサイドを自動テストする

Deine Zauber binden wieder,(汝が魔力は再び結び合わせる)


第3章8節 サーバサイドを自動テストするではPythonで書いたLambda処理のユニットテストを作りました。

今回は第4章5節 Veu.jsでクライアントサイドを実装するで実装したVue.jsのコードを自動テストしていきたいと思います。

Vue.jsのユニットテストはJestかMocha+Chaiって感じらしいのですが、よりお手軽なJestを使います。

qiita.com

vue createがJestをインストールしてくれてtests/unit/sample.spec.jsというテストモジュールができています。
テスト対象のHelloWorld.vueはすでに消してしまいましたが、試しにテストを起動してみます。

$ yarn test:unit

f:id:chief-shuffle:20191228171718j:plain

はい、「んなもんねえよ!」と怒られました。

これを利用してテストモジュールを作ってみます。
名前をtests/unit/Life.spec.jsに変更。
とりあえずで書いたのがこちら。

import axios from 'axios'
import Vue from 'vue'
import Life from '@/components/Life.vue'
import {shallowMount} from '@vue/test-utils'

jest.mock('axios')

describe('Life.vue', () => {

  beforeEach(() => {
    let response = [
      'あなたはアジアの小国の王様に生まれ変わりました。',
      'アジアの小国の王様は今から1000年前に生まれました。',
      '安定した治世で民に敬われながら生き',
      '40歳で死にました。',
    ]
    axios.get.mockResolvedValue(response)
  })

  it('正:疎通', () => {
    let wrapper = shallowMount(Life, {})
    expect(wrapper.find('div#life').exists()).toBe(true)
    expect(wrapper.find('div#life ul').exists()).toBe(false)
  })
})

axiosをJestのモックオブジェクトに差し替えてaxios.getで任意の値を受信するようにしてます。
it()がテストメソッドですね。
shallowMountでダミーマウントしたら、生成されたHTMLにタグが存在するかexport().to~()アサーションしてます。
beforeEach()は各テストメソッド実行前に呼ばれるやつです。

これを実行すると、

f:id:chief-shuffle:20191228172415j:plain

成功で通りました。
ちなみに、存在しないHTMLタグをアサーションすると、

f:id:chief-shuffle:20191228172747j:plain

「そんなのないよー。ぷんぷん。」と優しく怒ってくれます。

実行時になにやら警告が出ています。

(node:5064) UnhandledPromiseRejectionWarning: TypeError: Cannot read property 'commit' of undefined
(node:5064) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 1)
(node:5064) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
 PASS  tests/unit/Life.spec.js

Veux系の警告のよう。
そういえば、まだデータストアをモック化してませんでした。

import axios from 'axios'
import Vuex from 'vuex'
import Life from '@/components/Life.vue'
import {shallowMount, createLocalVue} from '@vue/test-utils'

const localVue = createLocalVue()
localVue.use(Vuex)
jest.mock('axios')

describe('Life.vue', () => {
  let store
  let storeList

  beforeEach(() => {
    let response = [
      'あなたはアジアの小国の王様に生まれ変わりました。',
      'アジアの小国の王様は今から1000年前に生まれました。',
      '安定した治世で民に敬われながら生き',
      '40歳で死にました。',
    ]
    axios.get.mockResolvedValue(response)

    storeList = jest.fn()
    store = new Vuex.Store({
      state: {
        list: [],
      },
      mutations: {
        STORE_LIST: storeList 
      },
    })
  })

  it('正:疎通', () => {
    const wrapper = shallowMount(Life, {
      store,
      localVue
    })
    expect(storeList).not.toHaveBeenCalled()
    expect(wrapper.find('div#life').exists()).toBe(true)
    expect(wrapper.find('div#life ul').exists()).toBe(false)
  })
})

データストアを丸々モック化しました。

f:id:chief-shuffle:20191228174609j:plain

警告が消えました。

Was die Mode streng geteilt;(時流が強く切り離したものを)


ところで、現在のアサーションは画面表示直後を前提にしています。
mounted()で呼ばれるaxiosAjax処理を実行する前ですね。
<ul>タグもなければMutationも呼ばれてません。

    expect(storeList).toHaveBeenCalledTimes(0)
    expect(wrapper.find('div#life ul').exists()).toBe(false)

Ajax処理が実行されたあとの表示もアサーションしましょう。

Vueの$nextTick()を使います。
HTMLのDOMが変化するまでまってくれます。

import axios from 'axios'
import Vuex from 'vuex'
import Life from '@/components/Life.vue'
import {shallowMount, createLocalVue} from '@vue/test-utils'

const localVue = createLocalVue()
localVue.use(Vuex)
jest.mock('axios')

describe('Life.vue', () => {
  let store
  let storeList
  let wrapper
  let list

  beforeEach(() => {
    list = [
      'あなたはアジアの小国の王子に生まれ変わりました。',
      'アジアの小国の王子は今から980年前に生まれました。',
      '民に敬われた王の嫡男として生き',
      '50歳で死にました。',
    ]
    axios.get.mockResolvedValue({data: list})

    storeList = jest.fn((state, payload) => {
      state.list = payload.list
    })
    store = new Vuex.Store({
      state: {
        list: [],
      },
      mutations: {
        STORE_LIST: storeList,
      },
    })
  })

  it('正:mounted', done => {
    wrapper = shallowMount(Life, {
      store,
      localVue,
    })
    expect(storeList).not.toHaveBeenCalled()
    expect(wrapper.find('div#life').exists()).toBe(true)
    expect(wrapper.find('div#life ul').exists()).toBe(false)

    wrapper.vm.$nextTick(() => {
      expect(storeList).toHaveBeenCalledTimes(1)
      expect(wrapper.find('div#life ul').exists()).toBe(true)
      for (let i = 0; i < list.length; i++) {
        expect(
          wrapper
            .findAll('div#life ul li')
            .at(i)
            .text()
        ).toEqual(list[i])
      }
      done()
    })
  })

mounted()がデータストアのlistにAjax通信の結果を格納したのちは<ul>タグが現れます。
<ul>配下の<li>のテキストを1行ずつアサーションしています。

f:id:chief-shuffle:20191230232847j:plain

Alle Menschen werden Brüder,(すべての人々は兄弟となる)


トランジションアサーションしたいのですが一度にやるとテストケースが長すぎるので、afterEnter()afterLeave()を別々にテストします。
2つテストケースを足しました。

  it('正:afterEnter', done => {
    let mounted = jest.spyOn(Life, 'mounted').mockImplementation(() => {})
    wrapper = shallowMount(Life, {
      data() {
        return {
          show: true,
        }
      },
      store,
      localVue,
    })

    wrapper.vm.afterEnter()
    setTimeout(() => {
      expect(storeList).not.toHaveBeenCalled()
      expect(wrapper.find('div#life ul').exists()).toBe(false)
      done()
    }, 3000)
  })

  it('正:afterLeave', done => {
    let mounted = jest.spyOn(Life, 'mounted').mockImplementation(() => {})
    wrapper = shallowMount(Life, {
      store,
      localVue,
    })

    wrapper.vm.afterLeave()
    setTimeout(() => {
      expect(storeList).toHaveBeenCalledTimes(1)
      expect(wrapper.find('div#life ul').exists()).toBe(true)
      for (let i = 0; i < list.length; i++) {
        expect(
          wrapper
            .findAll('div#life ul li')
            .at(i)
            .text()
        ).toEqual(list[i])
      }
      done()
    }, 2000)
  })

いずれもjest.spyOnmounted()をモックで抑止しています。
正:afterEnterではshowtrueから始めてafterEnter()を呼んだあと、一定時間待ってから<ul>タグがないことアサーションしています。
正:afterLeaveではshowfalseから始めてafterEnter()を呼んだあと、一定時間まってから<ul>タグがあるをアサーションしています。
実行すると、カバレッジ100%になりました。

f:id:chief-shuffle:20191230233300j:plain

ちなみにさっきからカバレッジを取得できてるのは--coverageオプションをpackage.jsonに入れたからです。

…
  "scripts": {
    …
    "test:unit": "vue-cli-service test:unit --coverage",
    …
  },
…

よくみると、実行結果の1か所が赤くなっています。

PASS tests/unit/Life.spec.js (5.866s)

「いいけど、時間かかりすぎ-」ということです。
5秒以上だと怒られるようです。
実際にsetTimeoutの遅延時間分だけ待っているので当然です。
ので、テスト用にsetTimeoutの遅延時間をいじれるようにします。

テスト対象のsrc/components/Life.vueで、遅延時間をdataに入れます。

<template>
  <div id="life">
    <transition name="fade" @after-enter="afterEnter" @after-leave="afterLeave">
      <ul v-if="show">
        <li v-for="msg in list" :key="msg">
          {{ msg }}
        </li>
      </ul>
    </transition>
  </div>
</template>

<script>
import axios from 'axios'
import {mapState} from 'vuex'
import * as types from '@/store/mutation-types'

const AFTER_ENTER_DELAY = 3000
const AFTER_LEAVE_DELAY = 2000

export default {
  name: 'Life',
  data() {
    return {
      show: false,
      delayAfterEnter: AFTER_ENTER_DELAY,
      delayAfterLeave: AFTER_LEAVE_DELAY,
    }
  },
  computed: {
    ...mapState(['list']),
  },
  mounted() {
    this.rebirth()
  },
  methods: {
    rebirth() {
      axios.get('/api/rebirth').then(response => {
        this.$store.commit(types.STORE_LIST, {
          list: response.data,
        })
        this.show = true
      })
    },
    afterEnter() {
      var _this = this
      setTimeout(() => (_this.show = false), this.delayAfterEnter)
    },
    afterLeave() {
      var _this = this
      setTimeout(() => _this.rebirth(), this.delayAfterLeave)
    },
  },
}
</script>

<style lang="stylus">
#life
  padding 10%

li
  list-style-type none

.fade-enter-active, .fade-leave-active
  transition all .8s ease

.fade-enter, .fade-leave-to
  opacity 0
</style>

ほんで、テストモジュールでdataの値を差し替えつつ、自分の遅延時間も減らします。

  it('正:afterEnter', done => {
    const AFTER_ENTER_DELAY = 30
    let mounted = jest.spyOn(Life, 'mounted').mockImplementation(() => {})
    wrapper = shallowMount(Life, {
      data() {
        return {
          show: true,
          delayAfterEnter: AFTER_ENTER_DELAY,
        }
      },
      store,
      localVue,
    })

    wrapper.vm.afterEnter()
    setTimeout(() => {
      expect(storeList).not.toHaveBeenCalled()
      expect(wrapper.find('div#life ul').exists()).toBe(false)
      done()
    }, AFTER_ENTER_DELAY)
  })

  it('正:afterLeave', done => {
    const AFTER_LEAVE_DELAY = 20
    let mounted = jest.spyOn(Life, 'mounted').mockImplementation(() => {})
    wrapper = shallowMount(Life, {
      data() {
        return {
          delayAfterLeave: AFTER_LEAVE_DELAY,
        }
      },
      store,
      localVue,
    })

    wrapper.vm.afterLeave()
    setTimeout(() => {
      expect(storeList).toHaveBeenCalledTimes(1)
      expect(wrapper.find('div#life ul').exists()).toBe(true)
      for (let i = 0; i < list.length; i++) {
        expect(
          wrapper
            .findAll('div#life ul li')
            .at(i)
            .text()
        ).toEqual(list[i])
      }
      done()
    }, AFTER_LEAVE_DELAY)
  })

0.1秒程度に縮まりました。

f:id:chief-shuffle:20191230234728j:plain

Wo dein sanfter Flügel weilt.(汝の柔らかな翼が留まる所で)


そういえば、カバレッジがコンソールにしか出ていませんでした。
HTMLにも出力してみます。
package.jsonに以下を追加します。

  "jest": {
    …
    "collectCoverage": true,
    "collectCoverageFrom": ["src/**/*.{js,vue}"],
    "coverageReporters": ["text", "html"],
    "coverageDirectory": "<rootDir>/tests/unit/coverage"
  }

実行すると、対象ソース全体に対してのカバレッジが表示されます。
tests/unit/coverage/index.htmlをプレビューすると、フォルダ/ファイルごとのカバレッジ詳細を確認できます。

f:id:chief-shuffle:20191230235213j:plain

いやー、やっぱりユニットテストは苦行ですね。
jest.spyOn()の利便性を思い出すまで3日掛かりました。
今年中にカバレッジを100%にしたいんだけど、あと1日でできるんだろうか。
無理だった場合は次回が来年になります。
みなさま良いお年を。