はじめに
###本記事の環境
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を作ります。
機能要件として以下があります
- 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/
にアクセスして、初期化が成功しているかどうかを確認してください。
以上、新規プロジェクトは完了しました。
TDD(テスト駆動)とは何でしょう
簡単に説明するとテストファーストなプログラムの開発手法です。
以下のプロセスがあります。
- テストコード書く
- テスト実行、失敗する
- テスト実行成功させる用の実装をする
- テスト成功
- コードの最適化を試す
TDDのメリット
バグの持越しが事前に防げる!
テストコードをガッツリ書くことで、その分工数は増えますが、
本番環境にバグを持っていくより、ずっとメリットの方が大きいです。
但し、テストコードのカバレッジ率を100%にすることはほぼ無理なので、
工数とのバランスもよく考えた方がいいです。
お試しテスト
現在のプロジェクトのディレクトリは下記の通りです。
|- 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
はテストファイルとして認識できるでしょうか
jest.config.js
を見てみましょう。
...
testMatch: [
'**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
],
...
このtestMatch
がどんなファイルをテストファイルとして認識するのかのマッチング用の正規表現を書いてあります。
では、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', () => {})
jest
のGlobals
関数であるdescribe
を使用し、テストコードをブロックとして宣言します。
ここではHelloWorld.vue
用のテストブロックを宣言しています。
describe公式説明
it('renders props.msg when passed', () => {})
it
はdescribe
と同じようにテストコードのブロック宣言用の関数です。
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を作っていきます。
inputを作っていきます
##下準備
package.json修正を行います
package.jsonのtest:unitコマンドの最後に--watch
を追加します。
そうすることで、一回test:unit実行後、ファイル修正するたびにテストが自動的に実行されます。
"scripts": {
...
"test:unit": "vue-cli-service test:unit --watch"
},
App.vueとHelloWorld.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>
<template>
<div class="hello">
</div>
</template>
<script>
export default {
name: 'HelloWorld'
}
</script>
<style scoped lang="stylus">
</style>
以上の修正完了後、機能要件に満たすテストコードを書いていきます。
要件1.inputが存在します
まず、HelloWorld.vue
にinput
の存在を確認します。
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が返ってきました。
現在HelloWorld.vue
にinput
そもそも作ってないので、falseが返ってくるのは予想通りです。
HelloWorld.vueにinput追加
<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
ブロックを一個追加します。
...
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
を修正します。
<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
ブロックを一個追加して、テストコードを書きます。
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
ブロックを一個追加して、テストコードを書きます。
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
が伝達しません。
以下のとおりtoBeFalsy
をtoBeTruthy
に修正します。
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
を修正します。
<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
に修正します。
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の条件分岐を追加します。
...
<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
ブロックを一個追加して、テストコードを書きます。
...
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
を修正します。
...
<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
おまけ
少し見た目をよくします。
<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>
後書き
少し長くなりましたが、最後まで読んでいただいてありがとうございます。
もし分かりにくい所などがあれば、コメントください