# Unit Testing ###### tags: `Vue` `test` `Jest` ## Goals of writing tests 撰寫測試的目的是什麼?大致可歸類以下三點 #### Boosted Confidence: 在一個你沒碰過的專案中,有一個測試包就像有一個前輩在隨時檢查你的code,確保你不會一新增功能就破壞程式碼。 #### Quality Code: 當你在撰寫component testing卻發現困難重重, 那可能表示你的compoent code寫得有問題,需要重構了。 因此,撰寫測試也可以確保你的code有一定的quality。 #### Better Documentation: 撰寫測試可以幫助你的團隊建立好的文件,當新的成員加入時,他可以透過測試來快速了解這個component在做什麼事。 --- ## Identifying what to test Vue app是由許多components組成,因此,最需要被測試的就是components。 ### The Component Contract 每個component都會有Inputs & Outputs **Inputs** * Component Data * Component Props * User Interaction * Ex: user clicks a button * Lifecycle Methods * `mounted()`, `created()`, etc. * Vuex Store * Route Params **Outputs** * What is rendered to the DOM * External function calls * Events emitted by the component * Route Changes * Updates to the Vuex Store * Connection with children * i.e. changes in child components ### Example: AppHeader Component 比如今天有一個header,如果`loggedIn` 是true,就display logout button ```htmlmixed <template> <div> <button v-show="loggedIn">Logout</button> </div> </template> <script> export default { data() { return { loggedIn: false } } } </script> ``` 為了搞清楚哪些是我們要測試的,首先要找出這個component的inputs和outputs **Inputs** * Data (`loggedIn`) * This data property determines if the button shows or not, so this is an input that we should be testing **Outputs** * Rendered Output (`button`) * Based on the inputs (loggedIn), is our button being displayed in the DOM when it should be? --- ## What NOT to test 了解什麼是不需要測試的,可以替我們省下很多不必要的工作。 ### Don’t test implementation details 只關注測試的目標是否正確產出預期的outputs,我們**不在乎它的過程或是他如何做到預期的結果**。 即便未來修改了測試目標內部的logic,只要inputs & outputs不變,我們就不需要擔心要重新撰寫測試。 ### Don’t test the framework Itself 常常會犯的錯就是測試太多東西,例如測試框架有沒有正常運作。比如說compoenent裡有props的validation,像是`Number` or `Object`,不需要去測試component拿到的props是否符合這個type,因為Vue會負責這項工作。 ### Don’t test third party libraries 不需要測試第三方套件是否正常運作,因為通常他們都有自己的測試,如果你不相信這個套件是否會正常運作,那就不要使用它。 --- ## Writing a Unit Test with Jest 建立一個Vue專案,包含rotuer, vuex, test-utils and jest package.json ```json { "name": "unit-testing-vue", "version": "0.1.0", "private": true, "scripts": { "serve": "vue-cli-service serve", "build": "vue-cli-service build", "test": "vue-cli-service test:unit", "lint": "vue-cli-service lint" }, "dependencies": { "core-js": "^3.6.5", "vue": "^2.6.11", "vue-router": "^3.2.0", "vuex": "^3.4.0" }, "devDependencies": { "@vue/cli-plugin-babel": "~4.5.0", "@vue/cli-plugin-eslint": "~4.5.0", "@vue/cli-plugin-router": "~4.5.0", "@vue/cli-plugin-unit-jest": "~4.5.0", "@vue/cli-plugin-vuex": "~4.5.0", "@vue/cli-service": "~4.5.0", "@vue/eslint-config-prettier": "^6.0.0", "@vue/test-utils": "^1.0.3", "babel-eslint": "^10.1.0", "eslint": "^6.7.2", "eslint-plugin-prettier": "^3.1.3", "eslint-plugin-vue": "^6.2.2", "prettier": "^1.19.1", "vue-template-compiler": "^2.6.11" } } ``` add ./components/AppHeader.vue ```htmlmixed <template> <div> <button v-show="loggedIn">Logout</button> </div> </template> <script> export default { data() { return { loggedIn: false } } } </script> ``` 在tests/unit中建立測試AppHeader用的js file ./tests/unit/AppHeader.spec.js ### Identifying what to test 如同前面說的,先確認測試的inputs & outputs,剛好前面已經討論過了 **Inputs** * Data (`loggedIn`) * This data property determines if the button shows or not **Outputs** * Rendered Output (`button`) - Based on the loggedIn input, is our button displayed in the DOM or not ### Scaffolding our first unit test Jest `describe()` function將相關聯的tests集合在一起,argument放component name的string,接著一個callback function開始寫test,run test時,console會出現component name。 ```javascript import AppHeader from '@/components/AppHeader.vue' describe('AppHeader', () => { }) ``` <br> 根據[Jest test的文件](https://jestjs.io/docs/en/api#testname-fn-timeout),可以這樣寫 ```javascript test('a simple string that defines your test', () => { // testing logic } ``` 這裡的test也可以改為it,Jest都認得 因此我們的test會長這樣 ```javascript describe('AppHeader', () => { test('if user is not logged in, do not show logout button', () => { // test body }) test('if a user is logged in, show logout button', () => { // test body }) }) ``` ### Asserting Expectations 在Jest中,我們使用斷言集(assertions)來判斷測試的結果和預期的結果是否相符。 用Jest的`expect()`,同時也可以使用他的許多"matchers"。 e.g. `expect(theResult).toBe(true)` `expect()`中,放入測試的目標,接著用**matchers**來判斷result是否有符合預期。 這裡使用一個常見Jest matcher `toBe()`,裡頭放入預期的結果為true。使用斷言集可以讓讀code的人一目瞭然這個test的目的:we expect the result to be true. 在寫測試時,一開始最好先寫出一定會通過(或是一定不會通過)的初始code,以免往後在找test failed的原因時發現,根本是test本身就寫錯了。 ```javascript describe('AppHeader', () => { test('if a user is not logged in, do not show the logout button', () => { expect(true).toBe(true) }) test('if a user is logged in, show the logout button', () => { expect(true).toBe(true) }) }) ``` 了解什麼matcher可以用就代表了解如何寫test了,不妨花些時間研究[the Jest matchers API](https://jestjs.io/docs/en/expect) `npm run test`確認一下結果 ![](https://i.imgur.com/Tigc84C.png) <br> ### The Power of Vue Test Utils 現在可以寫入真正的logic: 1. If a user is not logged in, do not show the logout button 2. If a user is logged in, show the logout button 要在測試中確認button有沒有render,就必須要把AppHeader mounted,Vue Test Utils的library有許多packages可以做到類似的事,這裡我們需要import `mount` ```javascript import { mount } from '@vue/test-utils' import AppHeader from '@/components/AppHeader' describe('AppHeader', () => { test('if user is not logged in, do not show logout button', () => { const wrapper = mount(AppHeader) // mounting the component expect(true).toBe(true) }) test('if user is logged in, show logout button', () => { const wrapper = mount(AppHeader) // mounting the component expect(true).toBe(true) }) }) ``` 這裡命名為wrapper的原因是,除了mounting component,Vue Test Utils還創建了一個[wrapper](https://vue-test-utils.vuejs.org/api/wrapper/#wrapper),裡面有許多methods可以幫助測試。 為了要確認button是否有依照logged in value出現,使用wrapper中的兩個methods`.find()`, `.isVisible()`,`.find()`可以找出component中的button,`.isVisible()`可以確認button是否有呈現在component上。 ```javascript test('if user is not logged in, do not show logout button', () => { const wrapper = mount(AppHeader) expect(wrapper.find('button').isVisible()).toBe(false) }) ``` 第二個情況,希望button是會出現的,因此`toBe(true)` ```javascript test("if logged in, show logout button", () => { const wrapper = mount(AppHeader) expect(wrapper.find('button').isVisible()).toBe(true) }) ``` 但這裡需要把loggedIn設為true,測試才會通過,[wrapper.setData()](https://vue-test-utils.vuejs.org/api/wrapper/#setdata)可以幫忙改變component的props value。 ```javascript test("if logged in, show logout button", () => { const wrapper = mount(AppHeader) wrapper.setData({ loggedIn: true }) // setting our data value expect(wrapper.find('button').isVisible()).toBe(true) }) ``` 現在如果開心run test 會發現竟然沒過,因為改變loggedIn value後,DOM會重新render,在還沒update完前,expect()就去找button了。 因此需要用async await來解決,確定DOM update完後,才進入expect() ```javascript test("if logged in, show logout button", async () => { const wrapper = mount(AppHeader) await wrapper.setData({ loggedIn: true }) expect(wrapper.find('button').isVisible()).toBe(true) }) ``` run test後發現vue console 建議isVisible()換成Jest-dom的toBeVisible(),用法也有點不一樣。(2020/09) 官方文件說明:https://vue-test-utils.vuejs.org/upgrading-to-v1/#isvisible [Jest-dom library](https://github.com/testing-library/jest-dom#tobevisible): Custom jest matchers to test the state of the DOM 安裝Jest: `npm install --save-dev @testing-library/jest-dom` 用法: ```javascript import '@testing-library/jest-dom' expect(warpper.find('your target').element).toBeVisible() ``` 修改後的code: ```javascript import { mount } from '@vue/test-utils' import AppHeader from '@/components/AppHeader.vue' import '@testing-library/jest-dom' describe('AppHeader', () => { test('if a user is not logged in, do not show the logout button', () => { const wrapper = mount(AppHeader) expect(wrapper.find('button').element).not.toBeVisible() }) test('if logged in, show logout button', async () => { const wrapper = mount(AppHeader) await wrapper.setData({ loggedIn: true }) expect(wrapper.find('button').element).toBeVisible() }) }) ``` result: ![](https://i.imgur.com/oAFMKuI.png) --- ## Testing Props & User Interaction 接下來要建立一個component,來測試傳入不同的props是否會產出預期的output以及模擬user interaction src/components/RandomNumber.vue ```htmlmixed= <template> <div> <span>{{ randomNumber }}</span> <button @click="getRandomNumber">Generate Random Number</button> </div> </template> ``` ```javascript=+ <script> export default { props: { min: { type: Number, default: 1 }, max: { type: Number, default: 10 } }, data() { return { randomNumber: 0 } }, methods: { getRandomNumber() { this.randomNumber = Math.floor(Math.random() * (this.max - this.min + 1) ) + this.min; } } } </script> ``` ### Identifying what to test 首先一樣釐清要測試的input & output **Inputs** Props: * `min` & `max` User Interaction: * Clicking of the Generate Random Number button **Outputs** Rendered Output (DOM) * Is the number displayed on the screen between min and max? 根據以上,可以整理三種情況需要test: 1. 沒有點buuton的情況下,預設output應為0,因為default data value = 0 2. 點button,1 <= `randomNumber` <= 10 3. props改為200 & 300,則 200 <= `randomNumber` <= 300 ### Random Number Tests build test tests/unit/RandomNumber.spec.js ```javascript import { mount } from '@vue/test-utils' import RandomNumber from '@/components/RandomNumber' describe('RandomNumber', () => { test('By default, randomNumber data value should be 0', () => { expect(true).toBe(false); }) test('If button is clicked, randomNumber should be between 1 and 10', () => { expect(true).toBe(false); }) test('If button is clicked, randomNumber should be between 200 and 300', () => { expect(true).toBe(false); }) }) ``` 首先讓所有test failed, pass的是剛剛的AppHeader result: ![](https://i.imgur.com/dhGoK5v.png) ### Checking the default random number 為了確保component的default data value for randomNumber為0,一但有人更改他,就跑不過這一項測試了。 首先要mount 這個component,就可以拿到span裡的randomNumber ```javascript test('By default, randomNumber data value should be 0', () => { const wrapper = mount(RandomNumber) expect(wrapper.html()).toContain('<span>0</span>') }) ``` 藉由wrapper,拿到html,並判斷是否包含span with 0。 ### Simulating User Interaction 接著模擬使用者click button 隨機產生1~10的number。 實作步驟: 1. mount component 2. 用`find()`取得button,用`trigger()`模擬click button,在這裏DOM會被update,因此要用async await來確保DOM update完後再繼續下一個動作。 3. 取得span中的randomNumber,並轉為type Number 4. assertion 判斷是否介於props的值之間 ```javascript test('If button is clicked, randomNumber should be between 1 and 10', async () => { const wrapper = mount(RandomNumber) await wrapper.find('button').trigger('click') const randomNumber = parseInt(wrapper.find('span').element.textContent) expect(randomNumber).toBeGreaterThanOrEqual(1) expect(randomNumber).toBeLessThanOrEqual(10) }) ``` result: 只剩下最後一個test了 ![](https://i.imgur.com/JlhG0uX.png) ### Setting different prop values `mount`的第二個optional argument可以傳入propsData ```javascript test('If button is clicked, randomNumber should be between 1 and 10', () => { const wrapper = mount(RandomNumber, { propsData: { min: 200, max: 300 } }) }) ``` 剩下的code就如同前一個test ```javascript test('If button is clicked, randomNumber should be between 200 and 300', async () => { const wrapper = mount(RandomNumber, { propsData: { min: 200, max: 300 } }) await wrapper.find('button').trigger('click') const randomNumber = parseInt(wrapper.find('span').element.textContent) expect(randomNumber).toBeGreaterThanOrEqual(200) expect(randomNumber).toBeLessThanOrEqual(300) }) ``` result: ![](https://i.imgur.com/HMJcHDa.png) --- ## Testing Emitted Events 問題:如何測試component中的emit event? [官方Doc有說明](https://vue-test-utils.vuejs.org/api/wrapper/emitted.html) `emitted` method 會回傳一個被wrapper emit的自定義事件(custom event) 直接用例子實作: LoginForm.vue ```htmlmixed= <template> <form @submit.prevent="onSubmit"> <input type="text" v-model="name" /> <button type="submit">Submit</button> </form> </template> ``` ```javascript=+ <script> export default { data() { return { name: '' } }, methods: { onSubmit() { this.$emit('formSubmitted', { name: this.name }) } } } </script> ``` 這是一個簡單的表單,點button後,會觸發submit,form綁定的onSubmit method會emit一個custom event 'formSubmitted',帶著payload user name,傳給parent component。 ### Scaffolding the test file 撰寫測試時,最好能盡量模仿end user的所有操作,這裡把user的動作一一列出: LoginForm.spec.js ```javascript import LoginForm from '@/components/LoginForm.vue' import { mount } from '@vue/test-utils' describe('LoginForm', () => { it('emits an event with a user data payload', () => { const wrapper = mount(LoginForm) // 1. Find text input // 2. Set value for text input // 3. Simulate form submission // 4. Assert event has been emitted // 5. Assert payload is correct }) }) ``` `it`等同`test` ### Setting the text input value 找到input然後set value ```javascript describe('LoginForm', () => { it('emits an event with user data payload', () => { const wrapper = mount(LoginForm) const input = wrapper.find('input[type="text"]') // Find text input input.setValue('Vin') // Set value for text input // 3. Simulate form submission // 4. Assert event has been emitted // 5. Assert payload is correct }) }) ``` 現在只有一個input,這樣寫當然沒問題,但在production-level,一個頁面可能有好幾個input,其他人也可能更改id或class name,因此用id或class name去找input也不保險,可以用test-specific attribute解決這問題 ```htmlmixed <input data-testid="name-input" type="text" v-model="name" /> ``` test file中,就可以用data-testid去找到目標input ```javascript const input = wrapper.find('[data-testid="name-input"]') ``` <br> ```javascript it('emits an event with a user data payload', () => { const wrapper = mount(LoginForm) const input = wrapper.find('[data-testid="name-input"]') input.setValue('Vin') // 1. Find text input // 2. Set value for text input // 3. Simulate form submission // 4. Assert event has been emitted // 5. Assert payload is correct }) ``` ### Simulating the form submission 先前有用到`trigger()`來觸發click button,這裡你可能也想用一樣的方法,但有個問題,如果以後移除了button,改用keyup.enter之類其他的方法去submit form怎辦?到時test就要重新修改。因此我們直接模擬submit event ```javascript it('emits an event with a user data payload', () => { const wrapper = mount(LoginForm) const input = wrapper.find('[data-testid="name-input"]') input.setValue('Vin') wrapper.trigger('submit') // 4. Assert event has been emitted // 5. Assert payload is correct }) ``` ### Testing our expectations 接著就可以測試我們預期的結果: * The event has been emitted * The payload is correct ```javascript it('emits an event with a user data payload', () => { const wrapper = mount(LoginForm) const input = wrapper.find('[data-testid="name-input"]') // Find text input input.setValue('Vin') // Set value for text input wrapper.trigger('submit') // Simulate form submission // Assert event has been emitted const formSubmittedCalls = wrapper.emitted('formSubmitted') expect(formSubmittedCalls).toHaveLength(1) }) ``` formSubmitted是component中emit的custom event,根據文件,`wrapper.emitted('formSubmitted')`會回傳一個array(也可以寫作`wrapper.emitted().formSubmitted`),因此判斷array是否length === 1 console.log(formSubmittedCalls)會長這樣:`[ [ { name: 'Vin' } ] ]` 接著要確認這個event emit的payload是否符合user name。 target payload: `formSubmittedCalls[0][0]` 完整的test code: ```javascript import LoginForm from '@/components/LoginForm.vue' import { mount } from '@vue/test-utils' describe('LoginForm', () => { it('emits an event with a user data payload', () => { const wrapper = mount(LoginForm) const input = wrapper.find('[data-testid="name-input"]') // Find text input input.setValue('Vin') // Set value for text input wrapper.trigger('submit') // Simulate form submission // Assert event has been emitted const formSubmittedCalls = wrapper.emitted('formSubmitted') expect(formSubmittedCalls).toHaveLength(1) // Assert payload is correct const expectedPayload = { name: 'Vin' } expect(formSubmittedCalls[0][0]).toMatchObject(expectedPayload) }) }) ``` --- ## Testing API Calls 除非你的Vue專案都只有靜態檔案,否則通常都會需要用到API Calls,在component中測試API Calls時,不希望真的發出請求給後端伺服器,因為這樣做會造成測試code和後端直接連結,容易出現不確定性,且降低測試速度。因此用[Jest mock function](https://jestjs.io/docs/en/mock-functions.html)可以模擬 fetching api calls的過程,並且提供一些methods作為測試使用。 ### The Starting Code 在這個例子中,使用[json-server](https://github.com/typicode/json-server)來做後端資料庫的server,對於不複雜的資料結構來說很夠用。 ./db.json ```javascript { "message": { "text": "Hello from the db!" } } ``` 在根目錄中建立db.json,就可以用`json-server --watch db.json`來建立一個server,用來回傳db.json裡的data。 ./services/axios.js ```javascript import axios from 'axios' export function getMessage() { return axios.get('http://localhost:3000/message').then(response => { return response.data }) } ``` axios.js會export getMessage(),呼叫這個function,他就會用get mothod跟db.json server拿到message。 接著建立呼叫這個API call的component MessageDisplay.vue ```htmlmixed= <template> <p v-if="error" data-testid="message-error">{{ error }}</p> <p v-else data-testid="message">{{ message.text }}</p> </template> ``` ```javascript=+ <script> import { getMessage } from '@/services/axios.js' export default { data() { return { message: {}, error: null } }, async created() { try { this.message = await getMessage() } catch (err) { this.error = 'Oops! Something went wrong.' } } } </script> ``` 當component `created`,會用getMessage()去拿到message,並呈現在view上,如果error發生,也會有對應的message。 ### Inputs & Outputs 從`getMessage()`取得的response是input,output有兩種情況: 1. The call happens successfully and the message is displayed 1. The call fails and the error is displayed 所以在test中,要做的是: 1. Mock a successful call to getMessage, checking that the `message` is displayed 2. Mock a failed call to getMessage, checking that the `error` is displayed ### Mocking Axios 用comment寫下每一個步驟 MessageDisplay.spec.js ```javascript import MessageDisplay from '@/components/MessageDisplay' import { mount } from '@vue/test-utils' describe('MessageDisplay', () => { it('Calls getMessage and displays message', async () => { // mock the API call const wrapper = mount(MessageDisplay) // wait for promise to resolve // check that call happened once // check that component displays message }) it('Displays an error when getMessage call fails', async () => { // mock the failed API call const wrapper = mount(MessageDisplay) // wait for promise to resolve // check that call happened once // check that component displays error }) }) ``` 為了mock API call,先把`getMessage`從axios.js import進來。 將axios.js的路徑傳給`jest.mock()`,便能mock `getMessage()`,以及使用jest的mathods ```javascript import MessageDisplay from '@/components/MessageDisplay' import { mount } from '@vue/test-utils' import { getMessage } from '@/services/axios' jest.mock('@/services/axios') ... ``` 可以將`jest.mock` 想作:我拿了你的`getMessage` function,然後回傳給你一個mocked `getMessage` function 因此,當我們之後呼叫`getMessage`,其實我們用的是mocked的`getMessage`,並不是真的`getMessage` ```javascript import MessageDisplay from '@/components/MessageDisplay' import { mount } from '@vue/test-utils' import { getMessage } from '@/services/axios' jest.mock('@/services/axios') describe('MessageDisplay', () => { it('Calls getMessage and displays message', async () => { const mockMessage = 'Hello from the db' getMessage.mockResolvedValueOnce({ text: mockMessage }) // calling our mocked get request const wrapper = mount(MessageDisplay) // wait for promise to resolve // check that call happened once // check that component displays message }) }) ``` [`mockResolvedValueOnce`](https://jestjs.io/docs/en/mock-function-api.html#mockfnmockresolvedvalueoncevalue)表示:模擬一個回傳的value,讓這個mocked API call可以resolve,所以這裡我們傳入預期的text,也就是mockMessage。 到這裡為止,我們成功模擬了一個API call,又模擬了這個API call回傳的值,而且完全沒有和server有任何關聯。 如果你有注意到這裡已經先寫了`async`,因為axios是asynchronous,必須確定所有mocked call return 的promise都完成resolved,才能去寫assertion,否則一定fail。 ### Awaiting Promises 在搞懂哪裡要寫await之前,要先想:component中的`getMessage`是如何被呼叫的? MessageDisplay.vue ```javascript async created() { try { this.message = await getMessage() } catch (err) { this.error = 'Oops! Something went wrong.' } } ``` 確認了是在`created`階段被呼叫的,但是vue-test-utils並沒有辦法可以取得`created`階段的promises,因此必須借助一個套件[flush-promises](https://www.npmjs.com/package/flush-promises),這個套件可以確保所有的promises都resolved才進行下一個task。 補充:官方文件[這裡](https://vue-test-utils.vuejs.org/guides/#testing-asynchronous-behavior)有寫測試非同步的教學 <br> MessageDisplay.spec.js ```javascript import MessageDisplay from '@/components/MessageDisplay' import { mount } from '@vue/test-utils' import { getMessage } from '@/services/axios' import flushPromises from 'flush-promises' jest.mock('@/services/axios') describe('MessageDisplay', () => { it('Calls getMessage once and displays message', async () => { const mockMessage = 'Hello from the db' getMessage.mockResolvedValueOnce({ text: mockMessage }) const wrapper = mount(MessageDisplay) await flushPromises() // check that call happened once // check that component displays message }) }) ``` ### Our Assertions 首先,確保我們沒有對server做出多次request ```javascript it('Calls getMessage once and displays message', async () => { const mockMessage = 'Hello from the db' getMessage.mockResolvedValueOnce(mockMessage) const wrapper = mount(MessageDisplay) await flushPromises() expect(getMessage).toHaveBeenCalledTimes(1) // check that call happened once // check that component displays message }) ``` 使用`.toHaveBeenCalledTimes()`並傳入數字1。 接著,我們要拿到component裡,透過`getMessage()` request拿到的text,然後比對看看是否和mockMessage相同。 在MessageDisplay.vue裡已經寫好讓test使用的id: `data-testid="message"` MessageDisplay.spec.js ```javascript it('Calls getMessage once and displays message', async () => { const mockMessage = 'Hello from the db' getMessage.mockResolvedValueOnce({ text: mockMessage }) const wrapper = mount(MessageDisplay) await flushPromises() expect(getMessage).toHaveBeenCalledTimes(1) const message = wrapper.find('[data-testid="message"]').element.textContent expect(message).toEqual(mockMessage) }) ``` 此時run test,終於過了! 接著就是測試API call failed的情況。 ### Mocking a failed request 第一步,mocking a failed API call,和前面的test很類似 ```javascript it('Displays an error when getMessage call fails', async () => { const mockError = 'Oops! Something went wrong.' getMessage.mockRejectedValueOnce(mockError) const wrapper = mount(MessageDisplay) await flushPromises() // check that call happened once // check that component displays error }) ``` 不同處在於mockError以及`getMessage().mockRejectValueOnce()`,既然是failed,用reject也是理所當然的。 awaiting flushPromises()後,就可以去判斷component呈現的訊息是否和mockError相同了 ```javascript it('Displays an error when getMessage call fails', async () => { const mockError = 'Oops! Something went wrong.' getMessage.mockRejectedValueOnce(mockError) const wrapper = mount(MessageDisplay) await flushPromises() expect(getMessage).toHaveBeenCalledTimes(1) const displayedError = wrapper.find('[data-testid="message-error"]').element .textContent expect(displayedError).toEqual(mockError) }) ``` 現在run test會出現fail log: **Expected number of calls: 1 Received number of calls: 2** 原來是前面第一個test已經call `getMessage()`,現在第二個test又呼叫一次,所幸jest有辦法清除mock actions ### Clear All Mocks 在jest.mock()的下方寫入解法 ```javascript= jest.mock('@/services/axios') beforeEach(() => { jest.clearAllMocks() }) ``` 現在`beforeEach` test,jest都會把所有的mocks清除,再run test便成功了。 ![](https://i.imgur.com/0RLHKPG.png) --- ## Stubbing Child Components 當你想測試一個component,它的裡頭還有一個child component,且這個child component還調用了一些外部服務(像是axios),我們的目的是測試這個component,child component的事應該要另外做它自己的測試,這時候就需要把child component [stubbing](https://lmiller1990.github.io/vue-testing-handbook/stubbing-components.html#stubbing-components) ### Children with Baggage 為了瞭解stubbing component的概念,先建立一個MessageContainer.vue ```htmlmixed= <template> <MessageDisplay /> </template> ``` ```javascript=+ <script> import MessageDisplay from '@/components/MessageDisplay' export default { components: { MessageDisplay } } </script> ``` **MessageContainer**僅僅只是import **MessageDisplay**,這也就是前面測試過的**MessageDisplay**,所以當**MessageContainer**渲染的時候,**MessageDisplay**也跟著被渲染,然後調用了axios,也就是剛剛才提到的問題:不希望真的去呼叫**MessageDisplay**中`created` hook會觸發的axios `get` request。 MessageDisplay.vue ```javascript async created() { try { this.message = await getMessage() // Don't want this to fire in parent test } catch (err) { this.error = 'Oops! Something went wrong.' } } ``` 所以該如何對**MessageContainer**測試且不會觸發child component的axios `get` request? 其實在這個例子中,只有一個外部服務(or you can call module dependency),我們可以直接mock axios,就像**MessageDisplay.spec.js**裡寫的測試一樣,但萬一child component調用了多組外部服務,就絕對不能如法炮製。這時就會直接mock child component本身,而不去mock它的相關依賴或套件,也就是用**stub**,或者說一個假的、替代版本的child component。 ### The MessageContainer Test MessageContainer.spec.js ```javascript= import MessageContainer from '@/components/MessageContainer' import { mount } from '@vue/test-utils' describe('MessageContainer', () => { it('Wraps the MessageDisplay component', () => { const wrapper = mount(MessageContainer) }) }) ``` 在這裡該如何stub掉**MessageDisplay**?還記得axios是在`created` hook時被呼叫的,因此,我們要在**MessageContainer** mount **MessageDisplay** 前阻止這件事。 這樣一想,在mount(MessageContainer)後面插入一個argument `stubs`就很合理了。([see doc here](https://vue-test-utils.vuejs.org/api/options.html#stubs)) ```javascript import MessageContainer from '@/components/MessageContainer' import MessageDisplay from '@/components/MessageDisplay.vue' import { mount } from '@vue/test-utils' describe('MessageContainer', () => { it('Wraps the MessageDisplay component', () => { const wrapper = mount(MessageContainer, { stubs: { MessageDisplay: MessageDisplay } }) expect(wrapper.findComponent(MessageDisplay).exists()).toBe(true) }) }) ``` 先把**MessageDisplay** import進來,根據[文件說明](https://lmiller1990.github.io/vue-testing-handbook/stubbing-components.html#write-a-test-using-mount),設定stubs,接著寫assertion,只要確定**MessageContainer**有包含**MessageDisplay**就好了,畢竟在這個例子中**MessageContainer**也只有做這件事。 ### The Disadvantages of Stubbing * **maintenance costs**: 由於stub是替代性的code,當真正的code有變動的時候,也需要同步調整test裡的stub,可能會增加維護的成本。 * **reduced confidence**: stub不是一個真的rendered component,會減少你對真正的component的測試覆蓋率,可能導致你對於測試是否真正能夠反應web app的問題產生疑問。 ### What about ShallowMount? 可能很常看到有人使用`shallowMount`,也查到文件說`shallowMount`只會mount最上層component,但前面都沒有使用的原因是? 1. stubs的問題他全部都有 2. 假設你用了一些和Vue Test Utils有關的library,e.g. [Vue Testing Library](https://testing-library.com/docs/vue-testing-library/intro),你會發現`shallowMount`完全不支援這些library。 [For more information of avoid using shallowMount](https://kentcdodds.com/blog/why-i-never-use-shallow-rendering)