Help us understand the problem. What is going on with this article?

Vue.jsのTDD実践開発入門ガイド

:sun_with_face:はじめに

本記事の環境

Vue Node.js Vue CLI jest
v2.6.10 v10.15.1 v3.5.1 v23.6.0

凡例

コードに出てくる 以下のような...は、コードの一部省略を表します。

...
print("hello world")

補足として、公式ドキュメントも置いておきます。
jest公式ドキュメント
vue-test-utils公式ドキュメント

作るもの

TDDの開発手法で下記の様なinputを作ります。
スクリーンショット 2019-08-28 16.39.02.png

機能要件として以下があります

  • inputが存在します
  • inputの初期値は空です
  • inputのvalueが変化すれば、バインディングされたdataも共に変化します
  • inputのvalueが空の場合、Enter押してもmethod実行しません
  • inputのvalueが存在する場合、method実行され、その後valueが空になります

新規プロジェクト

vue/cliをまず入れます、既に入ってる方は何もしなくていいです。

npm install -g @vue/cli
# or
yarn global add @vue/cli

好きなディレクトリに移動してvueプロジェクトを作ります。

cd [好きなディレクトリ]
vue create tdd-vue

Vue CLIでプロジェクト作る時に、色々と聞いてきます。
オプションは下記を参照してください。

Vue CLI v3.x.x
? Please pick a preset: Manually select features
? Check the features needed for your project: Babel, CSS Pre-processors,Linter,Unit
? Pick a CSS pre-processor(PostCS...): Stylus
? Pick a linter / formatter config: Standard
? Pick additional lint features: Lint on save, Lint and fix on commit
? Pick a unit testing solution: jest
? Where do you prefer placing config for Babel,Pos...: In dedicated config files
? Save this as a Preset for future projects?: N

プロジェクト初期化後、以下のコマンドを実行してください。

cd tdd-vue
npm run serve

その後http://localhost:8080/にアクセスして、初期化が成功しているかどうかを確認してください。
キャプチャ.PNG
以上、新規プロジェクトは完了しました。

TDD(テスト駆動)とは何でしょう

簡単に説明するとテストファーストなプログラムの開発手法です。
以下のプロセスがあります。

  1. テストコード書く
  2. テスト実行、失敗する
  3. テスト実行成功させる用の実装をする
  4. テスト成功
  5. コードの最適化を試す

1_ieVWcSsJmeBbZFo6a_dL5g.png

TDDのメリット

バグの持越しが事前に防げる!
テストコードをガッツリ書くことで、その分工数は増えますが、
本番環境にバグを持っていくより、ずっとメリットの方が大きいです。
但し、テストコードのカバレッジ率を100%にすることはほぼ無理なので、
工数とのバランスもよく考えた方がいいです。

:point_up: お試しテスト

現在のプロジェクトのディレクトリは下記の通りです。

|- node_modules
|- public
|- src
|- tests
|- .browserslistrc
|- .editorconfig
|- .eslintrc.js
|- .gitignore
|- babel.config.js
|- jest.config.js
|- package.json
|- postcss.config.js
|- README.md
|- yarn.lock

まず、一回試しにテストを実行しましょう。

npm run test:unit

実行後、下記のメッセージが表示されると思います。
aを押して全てのテストを実行しましょう。

No tests found related to files changed since last commit.
Press `a` to run all tests, or run Jest with `--watchAll`.
....

しばらくしたら、テストの実行結果の詳細が確認できると思います。
意味としてはexample.spec.jsというテストファイルがHelloworld.vueに対するテスト実行しました。

 PASS  tests/unit/example.spec.js
  HelloWorld.vue
    √ renders props.msg when passed (15ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.727s, estimated 4s
Ran all test suites.

Watch Usage: Press w to show more.

example.spec.jsファイルはtests/unitの配下にありますが、
何でexample.spec.jsはテストファイルとして認識できるでしょうか:point_up_tone1:
jest.config.jsを見てみましょう。

jest.config.js
  ...
  testMatch: [
    '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
  ],
  ...

このtestMatchがどんなファイルをテストファイルとして認識するのかのマッチング用の正規表現を書いてあります。
では、example.spec.jsファイルはどんな動作をしたのでしょうか。
一行づつ説明していきます:point_up_tone1:

example.spec.js
import { shallowMount } from '@vue/test-utils'
import HelloWorld from '@/components/HelloWorld.vue'

describe('HelloWorld.vue', () => {
  it('renders props.msg when passed', () => {
    const msg = 'new message'
    const wrapper = shallowMount(HelloWorld, {
      propsData: { msg }
    })
    expect(wrapper.text()).toMatch(msg)
  })
})

先ずは一行目から。

describe('HelloWorld.vue', () => {})

jestGlobals関数であるdescribeを使用し、テストコードをブロックとして宣言します。
ここではHelloWorld.vue用のテストブロックを宣言しています。
describe公式説明

 it('renders props.msg when passed', () => {})

itdescribeと同じようにテストコードのブロック宣言用の関数です。
describeの配下にあります

const msg = 'new message'

msgという定数を宣言します。

 const wrapper = shallowMount(HelloWorld, {
      propsData: { msg }
    })

shallowMount関数でmsgという名のpropsDataを含むHelloWorldコンポーネントを生成し、wrapperに代入します。
shallowMount公式説明

expect(wrapper.text()).toMatch(msg)

wrapperのテキスト内容が、msgという定数の値と一致するかのテストを実行します。

以上の内容を踏まえて、inputを作っていきます。

:sunny: inputを作っていきます

下準備

package.json修正を行います

package.jsonのtest:unitコマンドの最後に--watchを追加します。
そうすることで、一回test:unit実行後、ファイル修正するたびにテストが自動的に実行されます。

package.json
  "scripts": {
    ...
    "test:unit": "vue-cli-service test:unit --watch" 
  },

App.vueとHelloWorld.vueの内容修正を下記の通りに変更

App.vue
<template>
  <div id="app">
    <HelloWorld />
  </div>
</template>

<script>
import HelloWorld from './components/HelloWorld.vue'

export default {
  name: 'app',
  components: {
    HelloWorld
  }
}
</script>
<style lang="stylus">
#app {}
</style>
HelloWorld.vue
<template>
  <div class="hello">
  </div>
</template>

<script>
export default {
  name: 'HelloWorld'
}
</script>

<style scoped lang="stylus">
</style>

以上の修正完了後、機能要件に満たすテストコードを書いていきます。

要件1.inputが存在します

まず、HelloWorld.vueinputの存在を確認します。

example.spec.js
import { shallowMount } from '@vue/test-utils'
import HelloWorld from '@/components/HelloWorld.vue'

describe('HelloWorld.vue', () => {
  it('input存在します', () => {
    const wrapper = shallowMount(HelloWorld)
    const input = wrapper.findAll('[data-test="input"]')
    expect(input.exists()).toBe(true)
  })
})

ファイル内容修正後、自動的にテストが走ったはずです。
走ってなかった場合、下記のコードを実行してください

npm run test:unit

結果は下記の通り。

 FAIL  tests/unit/example.spec.js
  HelloWorld.vue
    × input存在します (4ms)

  ● HelloWorld.vue › input存在します

    expect(received).toBe(expected) // Object.is equality

    Expected: true
    Received: false

       6 |     const wrapper = shallowMount(HelloWorld)
       7 |     const input = wrapper.findAll('[data-test="input"]')
    >  8 |     expect(input.exists()).toBe(true)
         |                            ^
       9 |   })
      10 | })
      11 | 

      at Object.toBe (tests/unit/example.spec.js:8:28)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        0.35s, estimated 1s
Ran all test suites related to changed files.

8行目にエラーが出てるようです。
Expected: true Received: false期待値はtrueに対して、falseが返ってきました。:fist:
現在HelloWorld.vueinputそもそも作ってないので、falseが返ってくるのは予想通りです。

HelloWorld.vueにinput追加

HelloWorld.vue
<template>
  <div class="hello">
    <input data-test="input"/>
  </div>
</template>
 ...

data-test="input"はテストコードに識別させるためのものです。
保存後、テストが自動的に走って、コンソールに以下の結果が表示されます。

 PASS  tests/unit/example.spec.js
  HelloWorld.vue
    √ input存在します (4ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.364s, estimated 1s
Ran all test suites related to changed files.

テストに成功したようです。

要件2.inputの初期値は空です

同じdescribe内にitブロックを一個追加します。

example.spec.js
...
describe('HelloWorld.vue', () => {
  ...
  it('inputの初期値は空です', () => {
    const wrapper = shallowMount(HelloWorld)
    const inputValue = wrapper.vm.$data.inputValue
    expect(inputValue).toBe('')
  })
})

保存後、コンソールに下記の内容が表示されます。

 FAIL  tests/unit/example.spec.js
  HelloWorld.vue
    √ input存在します (4ms)
    × inputの初期値は空です (10ms)

  ● HelloWorld.vue › inputの初期値は空です

    expect(received).toBe(expected) // Object.is equality

    Expected: ""
    Received: undefined

    Difference:

      Comparing two different types of values. Expected string but received undefined.

      11 |     const wrapper = shallowMount(HelloWorld)
      12 |     const inputValue = wrapper.vm.$data.inputValue
    > 13 |     expect(inputValue).toBe('')
         |                        ^
      14 |   })
      15 | })
      16 | 

      at Object.toBe (tests/unit/example.spec.js:13:24)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        0.418s, estimated 1s
Ran all test suites related to changed files.

Expected: "" Received: undefined期待値は""に対して、結果がundefinedでした。
inputValueの定義はまだなので、正しい結果です。

HelloWorld.vueを修正します。

HelloWorld.vue
<template>
  <div class="hello">
    <input
      data-test="input"
      v-model="inputValue"
    />
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  data () {
    return {
      inputValue: ''
    }
  }

}
</script>
...

修正後、コンソールに下記の内容が表示されます。

 PASS  tests/unit/example.spec.js
  HelloWorld.vue
    √ input存在します (4ms)
    √ inputの初期値は空です (2ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.413s, estimated 1s
Ran all test suites related to changed files.

テストに成功しました。

要件3.inputのvalueが変化すれば、バインディングされたdataも共に変化します

同じdescribe内にitブロックを一個追加して、テストコードを書きます。

example.spec.js
describe('HelloWorld.vue', () => {
  ...
  it('inputのvalueが変化すれば、バインディングされたデータも共に変化します', () => {
    const wrapper = shallowMount(HelloWorld)
    const input = wrapper.findAll('[data-test="input"]')
    input.setValue('Python is the best language')
    const inputValue = wrapper.vm.$data.inputValue
    expect(inputValue).toBe('Python is the best language')
  })
})

修正後、コンソールに下記の内容が表示されます。

 PASS  tests/unit/example.spec.js
  HelloWorld.vue
    √ input存在します (5ms)
    √ inputの初期値は空です (5ms)
    √ inputのvalueが変化すれば、バインディングされたデータも共に変化します (10ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        0.453s, estimated 1s

テストに成功しました。
v-modelでデータをバインディングしてるため、vueファイルを修正しなくてもテストは成功します。

要件4.inputのvalueが空の場合、Enter押してもmethod実行しません

同じdescribe内にitブロックを一個追加して、テストコードを書きます。

example.spec.js
describe('HelloWorld.vue', () => {
  ...
  it('inputのvalueが空の場合、Enter押してもmethod実行しません', () => {
    const wrapper = shallowMount(HelloWorld)
    const input = wrapper.findAll('[data-test="input"]')
    input.setValue('')
    input.trigger('keyup.enter')
    expect(wrapper.emitted().search).toBeFalsy()
  })
})

以上のコードはinputのvalueが空の場合に、
keyup.enterイベントが実行されて、HelloWorldコンポーネントは親コンポーネントにsearchを伝達しません。

triggerについては以下を読んでください。
triggerの公式ドキュメント

この状況でテスト実行すれば、実はテストが成功します。

 PASS  tests/unit/example.spec.js
  HelloWorld.vue
    √ input存在します (17ms)
    √ inputの初期値は空です (2ms)
    √ inputのvalueが変化すれば、バインディングされたデータも共に変化します (3ms)
    √ inputのvalueが空の場合、Enter押してもmethod実行しません (1ms)

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        1.585s, estimated 2s
Ran all test suites related to changed files.

原因としては、keyup.enterイベントによって実行するメソッドはまた定義されていません。
なので、何をしても親コンポーネントにsearchが伝達しません。

以下のとおりtoBeFalsytoBeTruthyに修正します。

example.spec.js
describe('HelloWorld.vue', () => {
  ...
  it('inputのvalueが空の場合、Enter押してもmethod実行しません', () => {
    ...
    expect(wrapper.emitted().search).toBeTruthy()
  })
})

修正後、テストの実行結果は下記になります

 FAIL  tests/unit/example.spec.js
  HelloWorld.vue
    √ input存在します (4ms)
    √ inputの初期値は空です (1ms)
    √ inputのvalueが変化すれば、バインディングされたデータも共に変化します (2ms)
    × inputのvalueが空の場合、Enter押してもmethod実行しません (4ms)

  ● HelloWorld.vue › inputのvalueが空の場合、Enter押してもmethod実行しません

    expect(received).toBeTruthy()

    Received: undefined

      25 |     input.setValue('')
      26 |     input.trigger('keyup.enter')
    > 27 |     expect(wrapper.emitted().search).toBeTruthy()
         |                                   ^
      28 |   })
      29 | })
      30 | 

      at Object.toBeTruthy (tests/unit/example.spec.js:27:35)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 3 passed, 4 total
Snapshots:   0 total
Time:        0.356s, estimated 1s

undefinedが返ってきました。
searchkeyup.enterイベント存在しないので、予想通りの結果です。
HelloWorld.vueを修正します。

HelloWorld.vue
<template>
  <div class="hello">
    <input
      data-test="input"
      v-model="inputValue"
      @keyup.enter="searchContent"
    />
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  data () {
    return {
      inputValue: ''
    }
  },
  methods: {
    searchContent() {
      this.$emit('search', this.inputValue)
    }
  }
}
</script>

修正後、テストの結果は以下になります

 PASS  tests/unit/example.spec.js
  HelloWorld.vue
    √ input存在します (3ms)
    √ inputの初期値は空です (2ms)
    √ inputのvalueが変化すれば、バインディングされたデータも共に変化します (2ms)
    √ inputのvalueが空の場合、Enter押してもmethod実行しません (1ms)

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        0.37s, estimated 1s
Ran all test suites related to changed files.

テストが成功しました。
でも機能要件は「inputのvalueが空の場合、Enter押してもmethod実行しません」なので、正しい結果ではありません。

今度は、toBeTruthytoBeFalsyに修正します。

example.spec.js
describe('HelloWorld.vue', () => {
  ...
  it('inputのvalueが空の場合、Enter押してもmethod実行しません', () => {
    ...
    expect(wrapper.emitted().search).toBeFalsy()
  })
})

修正を保存後、結果は以下になります。

FAIL  tests/unit/example.spec.js
  HelloWorld.vue
    √ input存在します (2ms)
    √ inputの初期値は空です (2ms)
    √ inputのvalueが変化すれば、バインディングされたデータも共に変化します (1ms)
    × inputのvalueが空の場合、Enter押してもmethod実行しません (9ms)

  ● HelloWorld.vue › inputのvalueが空の場合、Enter押してもmethod実行しません

    expect(received).toBeFalsy()

    Received: [[""]]

      25 |     input.setValue('')
      26 |     input.trigger('keyup.enter')
    > 27 |     expect(wrapper.emitted().search).toBeFalsy()
         |                                   ^
      28 |   })
      29 | })
      30 | 

      at Object.toBeFalsy (tests/unit/example.spec.js:27:35)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 3 passed, 4 total
Snapshots:   0 total
Time:        0.344s, estimated 1s

テストに失敗しました、HelloWorld.vueを修正します。
searchemitする前に、valueの条件分岐を追加します。

HelloWorld.vue
...
<script>
export default {
  name: 'HelloWorld',
  data () {
    return {
      inputValue: ''
    }
  },
  methods: {
    searchContent() {
      if (this.inputValue) {
        this.$emit('search', this.inputValue)
      }
    }
  }
}
</script>
...

修正後、テストの実行結果は以下になります。

 PASS  tests/unit/example.spec.js
  HelloWorld.vue
    √ input存在します (5ms)
    √ inputの初期値は空です (2ms)
    √ inputのvalueが変化すれば、バインディングされたデータも共に変化します (1ms)
    √ inputのvalueが空の場合、Enter押してもmethod実行しません (2ms)

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        0.431s, estimated 1s
Ran all test suites related to changed files.

機能要件を満たすテストの実行に成功しました。

要件5.inputのvalueが存在する場合、method実行され、その後valueが空になります

同じdescribe内にitブロックを一個追加して、テストコードを書きます。

example.spec.js
...
describe('HelloWorld.vue', () => {
  ...
  it('inputのvalueが存在する場合、Enter押したら、親コンポーネントにメソッドを伝達します。その後、valueが空になります', () => {
    const wrapper = shallowMount(HelloWorld)
    const input = wrapper.findAll('[data-test="input"]')
    input.setValue('Python is the best language')
    input.trigger('keyup.enter')
    expect(wrapper.emitted().search).toBeTruthy()
    expect(wrapper.vm.$data.inputValue).toBe('')
  })
})

テストの実行結果は以下です。

 FAIL  tests/unit/example.spec.js
  HelloWorld.vue
    √ input存在します (2ms)
    √ inputの初期値は空です (2ms)
    √ inputのvalueが変化すれば、バインディングされたデータも共に変化します (1ms)
    √ inputのvalueが空の場合、Enter押してもmethod実行しません (1ms)
    × inputのvalueが存在する場合、Enter押したら、親コンポーネントにメソッドを伝達します。その後、valueが空になります (6ms)

  ● HelloWorld.vue › inputのvalueが存在する場合、Enter押したら、親コンポーネントにメソッドを伝達します。その後、valueが空になります

    expect(received).toBe(expected) // Object.is equality

    Expected: ""
    Received: "Python is the best language"

      33 |     input.trigger('keyup.enter')
      34 |     expect(wrapper.emitted().search).toBeTruthy()
    > 35 |     expect(wrapper.vm.$data.inputValue).toBe('')
         |                                         ^
      36 |   })
      37 | })
      38 | 

      at Object.toBe (tests/unit/example.spec.js:35:41)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 4 passed, 5 total
Snapshots:   0 total
Time:        0.389s, estimated 1s
Ran all test suites related to changed files.

Expected: ""
Received: "Python is the best language"

テスト結果として期待していたのは""(空)ですが、表示はPython is the best languageでした。
なので、HelloWorld.vueを修正します。

HelloWorld.vue
...
<script>
export default {
  name: 'HelloWorld',
  data () {
    return {
      inputValue: ''
    }
  },
  methods: {
    searchContent() {
      if (this.inputValue) {
        this.$emit('search', this.inputValue)
        this.inputValue = ''
      }
    }
  }
}
</script>
...

保存後、テストの結果は以下になります。

 PASS  tests/unit/example.spec.js
  HelloWorld.vue
    √ input存在します (6ms)
    √ inputの初期値は空です (1ms)
    √ inputのvalueが変化すれば、バインディングされたデータも共に変化します (2ms)
    √ inputのvalueが空の場合、Enter押してもmethod実行しません (1ms)
    √ inputのvalueが存在する場合、Enter押したら、親コンポーネントにメソッドを伝達します。その後、valueが空になります (2ms)

Test Suites: 1 passed, 1 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        0.403s, estimated 1s
Ran all test suites related to changed files.

テストが成功しました。
以上、inputの開発が終了しました
サーバーを立ち上げて実物の動作を確認してみましょう。

npm run serve

キャプチャ.PNG
見た目は質素ですが、
機能要件を満たすinputが存在することが確認できます。

おまけ

少し見た目をよくします。

HelloWorld.vue
<template>
  <div class="hello">
    <div class="content">
      <input
        class="main-input"
        data-test="input"
        v-model="inputValue"
        @keyup.enter="searchContent"
        placeholder="キーワードを入力"
      />
    </div>
  </div>
</template>

<script>
 ...
</script>

<style scoped lang="stylus">
  .hello{
    line-height 60px
    background #59bb0c
  }
  .content{
    width 600px
    margin 0 auto
    color #ffffff
    font-size 24px
    border-radius 4px
  }
  .main-input{
      width 360px
      margin-top 16px
      line-height 24px
      color #333
      text-indent 10px
      border-radius 4px
  }
</style>

完成です:point_up:
キャプチャ.PNG

:speech_left: 後書き

少し長くなりましたが、最後まで読んでいただいてありがとうございます。
もし分かりにくい所などがあれば、コメントください:hatched_chick:

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away