TechFULの中の人

TechFULスタッフ・エンジニアによる技術ブログ。IT関連のことやTechFULコーディングバトルの超難問の深掘り・解説などを毎週更新

FizzBuzzを題材としたvue3での単体テスト入門

こんにちは、TechFULでアルバイトをしているAtria(@AtriaSoft)です。
現在はTechFULで出題される問題の作成をしています。

本記事では、FizzBuzz問題を題材にvue3で単体テストに入門してみます。
Vue Test Utilsを使ってテストフレームワークにMocha、アサーションChaiを利用しています。

また、記事中で掲載されているソースコードGitHub上に公開していますので、併せて学習に役立てていただけると幸いです。

github.com

FizzBuzz問題とは?

FizzBuzz問題についてすでに知ってる方は飛ばしてもらっても構いません。

FizzBuzz問題とは英語圏での言葉遊びであるFizzBuzzプログラミング言語で記述するというプログラミング界隈では鉄板の問題です。 具体的には

  • 与えられた数字が3の倍数でかつ5の倍数でないとき、 "Fizz" と回答する。
  • 与えられた数字が5の倍数でかつ3の倍数でないとき、 "Buzz" と回答する。
  • 与えられた数字が15の倍数であるとき、"FizzBuzz" と回答する。
  • 与えられた数字が上3つの条件のどれにも当てはまらないとき、与えられた数字をそのまま回答する。

といったものになります。(この記事では簡単のため、0の場合0と回答するものとします)

実際に1から20までFizzBuzzをしてみると
1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz, 11, Fizz, 13, 14, FizzBuzz, 16, 17, Fizz, 19, Buzz
となります。

環境構築

まずは Vue CLIを利用してvue3プロジェクトを作成します。 vue create vuetesting より、Default (Vue 3) ([Vue 3] babel, eslint) を選択します。

f:id:techful-444:20210818205538p:plain

その後、vue add unit-mochaユニットテストの環境を追加します。 追加終了次第、npm run test:unit単体テストが動くようになります。

今回利用する環境

加えて package.json も貼っておきます。
package.json

{
  "name": "vuetesting",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "test:unit": "vue-cli-service test:unit",
    "test": "vue-cli-service test:unit",
    "lint": "vue-cli-service lint"
  },
  "dependencies": {
    "core-js": "^3.6.5",
    "vue": "^3.0.0"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "~4.5.0",
    "@vue/cli-plugin-eslint": "~4.5.0",
    "@vue/cli-plugin-unit-mocha": "^4.5.13",
    "@vue/cli-service": "~4.5.0",
    "@vue/compiler-sfc": "^3.0.0",
    "@vue/test-utils": "^2.0.0-0",
    "babel-eslint": "^10.1.0",
    "chai": "^4.1.2",
    "eslint": "^6.7.2",
    "eslint-plugin-vue": "^7.0.0"
  },
  "eslintConfig": {
    "root": true,
    "env": {
      "node": true
    },
    "extends": [
      "plugin:vue/vue3-essential",
      "eslint:recommended"
    ],
    "parserOptions": {
      "parser": "babel-eslint"
    },
    "rules": {},
    "overrides": [
      {
        "files": [
          "**/__tests__/*.{j,t}s?(x)",
          "**/tests/unit/**/*.spec.{j,t}s?(x)"
        ],
        "env": {
          "mocha": true
        }
      }
    ]
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead"
  ]
}

注 : 筆者はわざわざ npm run test:unit と打つのが面倒なので npm test でテストが走るように scripts を編集しています。

Counterコンポーネントの作成

とりあえず +1 ボタンを押すとカウントが増えるコンポーネント Counter を作ります。 vuetesting/src/components 下にCounter.vue ファイルを記述しましょう。

Counter.vue

<template>
  <div class="counter">
    <h1>FizzBuzz</h1>
    <h2>{{ count }}</h2>
    <button id="button" v-on:click="count+=1">+1</button>
  </div>
</template>

<script>
export default {
  name: 'counter',
  data() {
    return {
      count : 0
    }
  }
}
</script>

表示さえできれば良いので、App.vue のはじめの部分でコンポーネント呼び出しを行っておきます。

App.vue

<template>
  <img alt="Vue logo" src="./assets/logo.png">
  <Counter/>
  <HelloWorld msg="Welcome to Your Vue.js App"/>
</template>

<script>
import HelloWorld from './components/HelloWorld.vue'
import Counter from './components/Counter.vue'
export default {
  name: 'App',
  components: {
    HelloWorld,
    Counter
  }
}
</script>

ここで一度プロジェクトを起動してみましょう。 npm run serve を叩くと無事にプロジェクトが起動するはずです。

f:id:techful-444:20210818210639p:plain
Vueロゴの下にCounterコンポーネントが追加された

"+1"ボタンを押すとカウンターがしっかりと1づつ増えていくはずです。

f:id:techful-444:20210818211100p:plain
クリックすると増える

今回はこのCounterコンポーネントがしっかりとFizzBuzzを返しているかどうかをテストしていきます。

テストファイルの作成

それではテストファイルを作成していきましょう。 vuetesting/tests/unit 以下に counter.spec.js というファイルを作成します。

counter.spec.js

import { expect } from 'chai'
import { mount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'

describe('Counter.vue', function () {
  it('初期状態0で表示', function ()  {
    const wrapper = mount(Counter)
    expect(wrapper.html()).to.include('<h2 id="count">0</h2>')
  })
  it('ボタンがある', function () {
    const wrapper = mount(Counter)
    expect(wrapper.find('button').exists()).to.equal(true)
  })
  it('1回クリックしたら1と表示', async function () {
    const wrapper = mount(Counter)
    const button = wrapper.find('button')
    await button.trigger('click')
    expect(wrapper.html()).to.include('<h2 id="count">1</h2>')
  })
})

このままではよくわからないので、コードを上から順に説明します。

1-3行目

import { expect } from 'chai'
import { mount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'

ここの部分では単体テストを実行するのに必要なファイルやライブラリの呼び出しを行っています。

1行目では 「expectという関数はchaiのものを利用する」
2行目では 「mountという関数は@vue/test-utilsのものを利用する」
3行目では 「Counterというコンポーネントファイルは@/components/Counter.vueにあるものを利用する」
という処理が走っています。

5-9行目

describe('Counter.vue', function () {
  it('初期状態0で表示', function ()  {
    const wrapper = mount(Counter)
    expect(wrapper.html()).to.include('<h2 id="count">0</h2>')
  })

ここの部分からは実際に単体テストを行っています。

1行目のdesctibeはMocha特有のテストを行う前のきまり文句です。 各テストはdescribeの中にit関数を作ることで作成していきます。 2-5行目は初期状態のとき0と表示できているかを問うテストになります。

3行目では 「Counter.vueファイルを参照しwrapperというオブジェクトを作成する」
4行目では 「wrapperというオブジェクトからhtmlの情報を取り出し'<h2 id="count">0</h2>'が含まれているかどうかをChaiアサーションする」
という処理が走っています。

余談ですが、Mochaでのテスト記述ではラムダ式は推奨されていません。
このため、記事中では実装に function () を利用しています。
Mocha - the fun, simple, flexible JavaScript test framework

10-13行目

  it('ボタンがある', function () {
    const wrapper = mount(Counter)
    expect(wrapper.find('button').exists()).to.equal(true)
  })

2行目では 「Counter.vueファイルを参照しwrapperというオブジェクトを作成する」
3行目では 「wrapperというオブジェクトに"htmlで記述されたidがbuttonとなっているもの"が含まれているかどうかをChaiアサーションする」
という処理が走っています。

14-19行目

  it('1回クリックしたら1と表示', async function () {
    const wrapper = mount(Counter)
    const button = wrapper.find('button')
    await button.trigger('click')
    expect(wrapper.html()).to.include('<h2 id="count">1</h2>')
  })

ボタンのクリックなどをテストする場合、非同期処理を伴うため14行目には async の記述が追加されています。 順に処理を説明すると、 2行目では 「Counter.vueファイルを参照しwrapperというオブジェクトを作成する」
3行目では 「wrapperというオブジェクトからidがbuttonとなっているオブジェクトを取り出しbuttonに格納」
4行目では 「非同期処理を用いてbuttonのクリック処理をトリガー」
5行目では 「wrapperというオブジェクトからhtmlの情報を取り出し'<h2 id="count">1</h2>'が含まれているかどうかをChaiアサーションする」
という処理が走っています。

はじめてのテスト実行

それでは実際にテストを実行してみましょう。テストの実行は npm run test:unit です。

実行結果

f:id:techful-444:20210818215159p:plain
テストが実行された

Counter.vueで正しくテストが3つスルーされるはずです。

FizzBuzzのテストケースを追加する

それでは早速FizzBuzz問題周りのテストケースを追加していきましょう。
テストケースファイル counter.spec.js の下の方に「3-5-6-10-15回クリックしたときFizzと表示されるかどうか」を問うテストケースを記述します。
処理は簡単で、clickトリガーを3回呼んでFizzと出ているかを確認するだけです。

counter.spec.js

import { expect } from 'chai'
import { mount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'

describe('Counter.vue', function () {
  it('初期状態0で表示', function ()  {
    const wrapper = mount(Counter)
    expect(wrapper.html()).to.include('<h2 id="count">0</h2>')
  })
  it('ボタンがある', function () {
    const wrapper = mount(Counter)
    expect(wrapper.find('button').exists()).to.equal(true)
  })
  it('1回クリックしたら1と表示', async function () {
    const wrapper = mount(Counter)
    const button = wrapper.find('button')
    await button.trigger('click')
    expect(wrapper.html()).to.include('<h2 id="count">1</h2>')
  })
  it('3回クリックしたらFizzと表示', async function () {
    const wrapper = mount(Counter)
    const button = wrapper.find('button')
    await button.trigger('click')
    await button.trigger('click')
    await button.trigger('click')
    expect(wrapper.html()).to.include('<h2 id="count">Fizz</h2>')
  })
  it('5回クリックしたらBuzzと表示', async function () {
    const wrapper = mount(Counter)
    const button = wrapper.find('button')
    for(let i = 0; i < 5; i++){
        await button.trigger('click')
    }
    expect(wrapper.html()).to.include('<h2 id="count">Buzz</h2>')
  })
  it('6回クリックしたらFizzと表示', async function () {
    const wrapper = mount(Counter)
    const button = wrapper.find('button')
    for(let i = 0; i < 6; i++){
        await button.trigger('click')
    }
    expect(wrapper.html()).to.include('<h2 id="count">Fizz</h2>')
  })
  it('10回クリックしたらBuzzと表示', async function () {
    const wrapper = mount(Counter)
    const button = wrapper.find('button')
    for(let i = 0; i < 10; i++){
        await button.trigger('click')
    }
    expect(wrapper.html()).to.include('<h2 id="count">Buzz</h2>')
  })
  it('15回クリックしたらFizzBuzzと表示', async function () {
    const wrapper = mount(Counter)
    const button = wrapper.find('button')
    for(let i = 0; i < 15; i++){
        await button.trigger('click')
    }
    expect(wrapper.html()).to.include('<h2 id="count">FizzBuzz</h2>')
  })
})

npm run test:unit を叩いてテストを実行すると、無事に(?)テストが落ちるはずです。

実行結果

f:id:techful-444:20210819084307p:plain
FizzBuzzの実装は行なっていないためテストに失敗します。

Counter.vueを書き換えてテストケースを通す

このままではテストに失敗するため、しっかりとCounter.vueの方でFizzBuzzを実装します。 FizzBuzzの結果表示には computed プロパティを利用します。

詳細な説明はこちら。
算出プロパティとウォッチャ — Vue.js

Counter.vue

<template>
  <div class="counter">
    <h1>FizzBuzz</h1>
    <h2 id="count">{{ FizzBuzz }}</h2>
    <button id="button" v-on:click="count+=1">+1</button>
  </div>
</template>
<script>
export default {
  name: 'counter',
  data() {
    return {
      count : 0
    }
  },
  computed: {
      FizzBuzz: function () {
          if (this.count == 0) return 0
          if (this.count % 15 == 0) {
              return "FizzBuzz"
          }
          else if (this.count % 3 == 0) {
              return "Fizz"
          }
          else if (this.count % 5 == 0) {
              return "Buzz"
          } else {
              return this.count
          }
      }
}
</script> 

npm run test:unit を叩いてテストを実行すると、無事にテストが成功するはずです。

実行結果

f:id:techful-444:20210819084956p:plain
FizzBuzzの実装を行なったためテストに成功します。

テストケースをもっと見やすくリファクタリングしてみる

先程のコードを見ると下部分の大半が同じような感じになっていることがわかります。
テストは見やすく拡張しやすくあるべきですので、テストコードのリファクタリングを行います。

結果から、改善したコードはこちら。

counter.spec.js

import { expect } from 'chai'
import { mount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'

const testcases = [
    {clicks : 1,out : 1},
    {clicks : 3,out : 'Fizz'},
    {clicks : 5,out : 'Buzz'},
    {clicks : 6,out : 'Fizz'},
    {clicks : 10,out : 'Buzz'},
    {clicks : 15,out : 'FizzBuzz'}
];

describe('Counter.vue', function () {
  it('初期状態0で表示', function ()  {
    const wrapper = mount(Counter)
    expect(wrapper.html()).to.include('<h2 id="count">0</h2>')
  })
  it('ボタンがある', function () {
    const wrapper = mount(Counter)
    expect(wrapper.find('button').exists()).to.equal(true)
  })
  testcases.forEach(async function (testcase) {
    it(`${testcase.clicks}回クリックしたら${testcase.out}と表示`, async function () {
        const wrapper = mount(Counter)
        const button = wrapper.find('button')
        for(let i = 0; i < testcase.clicks; i++){
            await button.trigger('click')
        }
        expect(wrapper.html()).to.include(`<h2 id="count">${testcase.out}</h2>`)
    })
  });
})

4-11行

4-11行目では testcases を宣言し、クリック回数と期待する出力値を clicksout で定義しています。
こうすることで、今後別のクリック回数と出力値のテストケースを追加する際、testcases を変更するだけで済みます。便利!

counter.spec.js

const testcases = [
    {clicks : 1,out : 1},
    {clicks : 3,out : 'Fizz'},
    {clicks : 5,out : 'Buzz'},
    {clicks : 6,out : 'Fizz'},
    {clicks : 10,out : 'Buzz'},
    {clicks : 15,out : 'FizzBuzz'}
];

21-30行

21-30行目では testcases.forEach を利用して testcases に記述された分のテストを実行しています。 テンプレート文字列を利用して見やすいコードを作成しているのが個人的に凝ったポイントです。

テンプレート文字列についての詳細な説明はこちら。 テンプレートリテラル (テンプレート文字列) - JavaScript | MDN

counter.spec.js

  testcases.forEach(async function (testcase) {
    it(`${testcase.clicks}回クリックしたら${testcase.out}と表示`, async function () {
        const wrapper = mount(Counter)
        const button = wrapper.find('button')
        for(let i = 0; i < testcase.clicks; i++){
            await button.trigger('click')
        }
        expect(wrapper.html()).to.include(`<h2 id="count">${testcase.out}</h2>`)
    })
  });

npm run test:unit を叩いてテストを実行すると、無事にテストが成功します。

実行結果

f:id:techful-444:20210819090327p:plain
リファクタリング成功

より良いテストライフを

今回はvue3+Vue Test Utils+Mocha+Chai環境で単体テストに入門してみました。
本当はもっと書きたいことがあったのですが、文章量がとんでもないことになってしまうのでこのへんで締めることにします。(ここまでで1.3万字程度です)

ご精読ありがとうございました。

サンプルコード

github.com

参考文献