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

# :sun_with_face:はじめに

###本記事の環境

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

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

```python
...
print("hello world")
```

補足として、公式ドキュメントも置いておきます。
[jest公式ドキュメント](https://jestjs.io/ja/)
[vue-test-utils公式ドキュメント](https://vue-test-utils.vuejs.org/ja/)

###作るもの
TDDの開発手法で下記の様な**input**を作ります。
<img width="310" alt="スクリーンショット 2019-08-28 16.39.02.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320164/45e381fe-1deb-9891-b548-44ae41b5152c.png">
#### 機能要件として以下があります

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

# 新規プロジェクト
**vue/cli**をまず入れます、既に入ってる方は何もしなくていいです。

```shell
npm install -g @vue/cli
# or
yarn global add @vue/cli
```
好きなディレクトリに移動してvueプロジェクトを作ります。

```shell
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
``` 
プロジェクト初期化後、以下のコマンドを実行してください。

```shell
cd tdd-vue
npm run serve
```
その後`http://localhost:8080/`にアクセスして、初期化が成功しているかどうかを確認してください。
<img width="819" alt="キャプチャ.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320164/1293faa5-f806-12fe-de58-1859ff026da4.png">
以上、新規プロジェクトは完了しました。

# TDD(テスト駆動)とは何でしょう
簡単に説明すると**テストファーストなプログラムの開発手法**です。
以下のプロセスがあります。

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


![1_ieVWcSsJmeBbZFo6a_dL5g.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320164/b3d0a671-dd0d-0cc5-17af-630558410030.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
```
まず、一回試しにテストを実行しましょう。

```bash
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`を見てみましょう。

```javascript:jest.config.js
  ...
  testMatch: [
    '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
  ],
  ...
```
この`testMatch`がどんなファイルをテストファイルとして認識するのかのマッチング用の正規表現を書いてあります。
では、`example.spec.js`ファイルはどんな動作をしたのでしょうか。
一行づつ説明していきます:point_up_tone1:

```javascript: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)
  })
})
```
先ずは一行目から。

```javascript

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

`jest`の`Globals`関数である`describe`を使用し、テストコードをブロックとして宣言します。
ここでは`HelloWorld.vue`用のテストブロックを宣言しています。
[describe公式説明](https://jestjs.io/docs/en/api#describename-fn)


```javascript

 it('renders props.msg when passed', () => {})
```
`it`は`describe`と同じようにテストコードのブロック宣言用の関数です。
`describe`の配下にあります

```javascript

const msg = 'new message'
```
`msg`という定数を宣言します。

```javascript

 const wrapper = shallowMount(HelloWorld, {
      propsData: { msg }
    })
```
`shallowMount`関数で`msg`という名の`propsData`を含む`HelloWorld`コンポーネントを生成し、`wrapper`に代入します。
[shallowMount公式説明](https://vue-test-utils.vuejs.org/ja/api/#shallowmount)

```javascript
expect(wrapper.text()).toMatch(msg)
```
`wrapper`のテキスト内容が、`msg`という定数の値と一致するかのテストを実行します。

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

# :sunny: inputを作っていきます
##下準備
### package.json修正を行います
package.jsonの**test:unit**コマンドの最後に`--watch`を追加します。
そうすることで、一回**test:unit**実行後、ファイル修正するたびにテストが自動的に実行されます。

```bash:package.json
  "scripts": {
    ...
    "test:unit": "vue-cli-service test:unit --watch" 
  },
```
### App.vueとHelloWorld.vueの内容修正を下記の通りに変更
```vuejs: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>
```

```vuejs:HelloWorld.vue
<template>
  <div class="hello">
  </div>
</template>

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

<style scoped lang="stylus">
</style>
```
以上の修正完了後、機能要件に満たすテストコードを書いていきます。

## 要件1.inputが存在します
まず、`HelloWorld.vue`に`input`の存在を確認します。

```javascript: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.vue`に`input`そもそも作ってないので、falseが返ってくるのは予想通りです。
#### HelloWorld.vueにinput追加

```vuejs: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`ブロックを一個追加します。

```javascript: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`を修正します。

```vuejs: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`ブロックを一個追加して、テストコードを書きます。

```javascript: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`ブロックを一個追加して、テストコードを書きます。

```javascript: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の公式ドキュメント](https://vue-test-utils.vuejs.org/ja/api/wrapper-array/#trigger-eventname-options)

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

```
 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`が伝達しません。

以下のとおり`toBeFalsy`を`toBeTruthy`に修正します。

```javascript: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`が返ってきました。
`search`も`keyup.enter`イベント存在しないので、予想通りの結果です。
`HelloWorld.vue`を修正します。

```vuejs: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実行しません」なので、正しい結果ではありません。

今度は、`toBeTruthy`を`toBeFalsy`に修正します。

```javascript: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`を修正します。
`search`を`emit`する前に、valueの条件分岐を追加します。

```vuejs: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`ブロックを一個追加して、テストコードを書きます。

```javascript: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`を修正します。

```vuejs: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
```
<img width="955" alt="キャプチャ.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320164/db221c48-3611-e535-8647-bf3fe9b963a2.png">
見た目は質素ですが、
機能要件を満たすinputが存在することが確認できます。

## おまけ
少し見た目をよくします。

```vuejs: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:
<img width="487" alt="キャプチャ.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320164/2648d9d8-2fca-d379-2c95-38d5afb4e13a.png">

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