直接使用 jest.mock()

若在 Component 使用 Vuex 的 State,對 Component 進行 Unit Test 時,就必須對 State 加以 Mock。

Version


Vue 2.5.22
Vuex 3.0.1
Vue-test-utils 1.0.0-beta.20

Store


books-info.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { fetchBooks } from '@/api/books';

/** mutation */
const setBooks = (state, { books }) => state.books = books;

/** action */
const saveBooks = commit => res => commit('setBooks', res.data);
const loadBooks = ({ commit }) => fetchBooks().then(saveBooks(commit));

/** getter */
const booksCount = ({ books }) => books.length;

export default {
namespaced: true,
state: {
books: []
},
mutations: {
setBooks
},
getters: {
booksCount
},
actions: {
loadBooks
},
};

一個典型的 Vuex store,包含 statemutationsgettersactions

15 行

1
2
3
state: { 
books: []
},

宣告 books state,預設值為 []

Component


book-list.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<template>
<div>
<ul>
<li v-for="(item, index) in books" :key="index">
Title : {{ item.title }}, Price : {{ item.price }}
</li>
</ul>
<p>Count: {{ booksCount }}</p>
</div>
</template>

<script>
import store from '@/store/index';

/** All books */
const books = () => store.state.BooksInfo.books;

/** Books count */
const booksCount = () => store.getters['BooksInfo/booksCount'];

/** Load all books */
const mounted = () => store.dispatch('BooksInfo/loadBooks');

export default {
name: 'BookList',
computed: {
books,
booksCount
},
mounted,
}
</script>

一個典型的 component,包含 mountedcomputed

13 行

1
import store from '@/store/index';

因為 component 要使用 Vuex 的 Store,因此將 store import 進來。

傳統在 Vue 使用 Vuex,都是將 Store 掛在 Vue Instance 下,然後使用 this.$store 存取 Store,但這種方式太依賴 this,導致 Vue 必須使用 Function Declaration 寫法,因為這種寫法才能讓 Vue 底層透過 bind() 修改 this;但一旦使用了 Function Declaration,若 function 重複需要重構,由於有 this,就只能走向 Vue 的 Mixin 一途

若 function 能不使用 this,就能改用 ES2015 的 Arrow Function 寫法,重構就抽成 ES2015 的 module 再 import 進來即可,這種方式較符合 ES2015 module 精神;也由於 function 都沒有 this,沒有任何 Side Effect,更方便實踐 Higher Order Function 、Closure 與 Function Composition,符合 Functional Programming 精神

main.js

1
2
3
4
5
6
7
8
import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

new Vue({
render: h => h(App)
}).$mount('#app')

由於沒將 store 掛在 Vue Instance 下,因此 main.js 也不用 Vue.use(Vuex),也就是 main.js 完全不需修改。

15 行

1
2
/** All books */
const books = () => store.state.BooksInfo.books;

Component 沒有 data,全部由 Vuex 的 state 而來。

注意並沒有使用 this.$store 存取 Store,而是直接用 import 的 store 去存取,且沒使用 Function Declaration,直接使用 Arraw Function

18 行

1
2
/** Books count */
const booksCount = () => store.getters['BooksInfo/booksCount'];

booksCount computed 由 Vuex 的 getter 而來。

注意並沒有使用 this.$store 存取 Store,而是直接用 import 的 store 去存取,且沒使用 Function Declaration,直接使用 Arraw Function

21 行

1
2
/** Load all books */
const mounted = () => store.dispatch('BooksInfo/loadBooks');

mounted 去呼叫 Vuex 的 action

注意並沒有使用 this.$store 存取 Store,而是直接用 import 的 store 去存取,且沒使用 Function Declaration,直接使用 Arraw Function

Unit Test


component-state.spec.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { shallowMount, createLocalVue } from '@vue/test-utils';
import BookList from '@/components/book-list.vue';

jest.mock('@/store/index', () => {
const Vuex = require('vuex');
const { createLocalVue } = require('@vue/test-utils');
const localVue = createLocalVue();
localVue.use(Vuex);

const stub = {
namespaced: true,
state: {
books: [1, 2, 3]
},
actions: {
loadBooks: jest.fn()
}
}

return new Vuex.Store({
modules: {
BooksInfo: stub
}
});
});

test('computed from state', () => {
/** arrange */

/** act */
const localVue = createLocalVue();
const wrapper = shallowMount(BookList, { localVue });

/** assert */
expect(wrapper.vm.books).toEqual([1, 2, 3]);
});

由於 component 並不是使用 this.$store 去存取 Store,因此 Vue Test Utils 或網路上很多教學的 Mock Vuex 的方法並不適用,因為它們都是基於對 this.$store 建立 Fake Store,但我們現在已經不是使用 this.$store,而是直接 import store module,所以我們要做的是改用 jest.mock() 直接 mock store module。

第 1 行

1
import { shallowMount, createLocalVue } from '@vue/test-utils';

直接 import Vue Test Utiles 兩個最重要的 shallowMount()createLocalVue(),稍後會說明。

第 2 行

1
import BookList from '@/components/book-list.vue';

將我們要測試的 BookList component import 進來。

第 4 行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
jest.mock('@/store/index', () => {
const Vuex = require('vuex');
const { createLocalVue } = require('@vue/test-utils');
const localVue = createLocalVue();
localVue.use(Vuex);

const stub = {
namespaced: true,
state: {
books: [1, 2, 3]
},
actions: {
loadBooks: jest.fn()
}
};

return new Vuex.Store({
modules: {
BooksInfo: stub
}
});
});

本文關鍵所在。由於 component 是直接 import store module 進來,因此我們要做的是使用 Jest 的 jest.mock() 來 mock store module。

1
2
jest.mock('@/store/index', () => {
});

使用 jest.mock()store module 進行 mock。

jest.mock()
jest.mock(moduleName, factory, options)
自行對 import 的 module 加以 mock

moduleName:module 須包含完整路徑

factory:factory function,負責產生 mock module

options:其他額外設定

1
2
const Vuex = require('vuex');
const { createLocalVue } = require('@vue/test-utils');

看到 require() 一定覺得很訝異,怎麼前端也出現 CommonJS 的 require(),不是該使用 ES6 的 import 嗎 ?

若將前兩行的 require() 拿掉,改用 ES2015 的 import,實際執行 unit test 時,會出現以下錯誤訊息:

mock000

Jest 抱怨 factory function 無法 reference 在 function 外部所 import 的 Vuex。

Q:為什麼 Jest 會抱怨呢 ?

Jest.mock() 主要的目的在於 mock module,所以在執行時,會自行 Hoisting 到所有 import 前面,這導致任何 ES2015 的 import 都會在 jest.mock() 之後執行,因此 Jest 要求所有 reference 都必須寫在 factory function 內,而不能使用 import,必須改用 CommonJS 的 require()

1
2
const localVue = createLocalVue();
localVue.use(Vuex);

有了 createLocalVue()Vuex 之後,就可使用 createLocalVue() 建立測試用的 Vue instance。

Q:不是改用 store module 了嗎 ? 為什麼還要 localVue.use(Vuex) ?

若拿掉了 localVue.use(Vuex),實際執行 unit test 時,會出現以下錯誤訊息:

mock001

雖然我們是沒使用 this.$store,但可能 Vue Test Utils 內部仍有用到,所以還是必須加上 localVue.use(Vuex),否則無法執行 unit test。

10 行

1
2
3
4
5
6
7
8
9
const stub = {
namespaced: true,
state: {
books: [1, 2, 3]
},
actions: {
loadBooks: jest.fn()
}
};

前面都屬於 Vue Test Utils 部分,接下來才是真正與我們相關的 mock 部分。

建立一個 store stub,也就是假資料部分,其格式如同一般的 store 一樣。

1
namespaced: true,

由於本範例使用到 store 的 module 寫法 (實務上也一定會用到 module),所以必須加上 namespaces: true

1
2
3
state: { 
books: [1, 2, 3]
},

建立假的 books state。

1
2
3
actions: { 
loadBooks: jest.fn
}

使用 jest.fn() 建立假的 loadBooks action。

Q:我們只想 mock state,為什麼連 action 也要 mock 呢 ?

本來只想 mock state,理論上是不需要 mock action,但因為本範例 mounted hook 一定會呼叫 loadBooks() action,若不順便 mock action,執行 unit test 時會說找不到 loadBooks action。

mock002

20 行

1
2
3
4
5
return new Vuex.Store({
modules: {
BooksInfo: stub
}
});

Factory function 目的就是建立假的 Store,最後使用 Vuex.Store() 建立假的 BooksInfo module。

27 行

1
2
3
4
5
6
7
test('computed from state', () => {
/** arrange */

/** act */

/** assert */
});

所有的 unit test 都包在 test() 的第二個參數,以 Arrow Function 表示。

test() 的第一個參數為 description,可描述 test case。

一樣使用 3A 原則寫 unit test。

30 行

1
2
3
/** act */
const localVue = createLocalVue();
const wrapper = shallowMount(BookList, { localVue });

剛剛在 jest.mock() 的 factory function 也建立過 localVue,不過只是為了 jest.mock() 使用而已,這次的 localVue 才是真正 Vue Test Utils 要使用。

使用 shallowMount() 建立假 component 測試,並將 localVue 傳入。

34 行

1
2
/** assert */
expect(wrapper.vm.books).toEqual([1, 2, 3]);

直接使用 wrapper.vm 讀取 books computed,驗證是否等於 stub 的假資料。

若想直接驗證 HTML 的 binding,可使用 wrapper.find() 找 DOM element

1
$ yarn test:unit

mock003

通過 unit test 得到 綠燈

Conclusion

  • 若直接將 store import 進 component,而非使用 this.$store,則 mock state 的方式會有所不同,必須使用 jest.mock() 直接對 store module 進行 mock
  • 使用 store import 寫法,可避免 this.$store 大量依賴 this,造成日後重構困難,且可大大提升 Vue 使用 Higher Order Function、Closure 與 Function Composition 能力
  • jest.mock() 會 Hoisting 到所有 import 前面,若在 factory function 需 reference 其他module,必須使用 require(),不能使用 import

Sample Code

完整範例可以在我的 GitHub 上找到

Reference

Jest, ES6 Class Mocks