TSUNG-MING TSAI
    • Create new note
    • Create a note from template
      • Sharing URL Link copied
      • /edit
      • View mode
        • Edit mode
        • View mode
        • Book mode
        • Slide mode
        Edit mode View mode Book mode Slide mode
      • Customize slides
      • Note Permission
      • Read
        • Only me
        • Signed-in users
        • Everyone
        Only me Signed-in users Everyone
      • Write
        • Only me
        • Signed-in users
        • Everyone
        Only me Signed-in users Everyone
      • Engagement control Commenting, Suggest edit, Emoji Reply
    • Invite by email
      Invitee

      This note has no invitees

    • Publish Note

      Share your work with the world Congratulations! 🎉 Your note is out in the world Publish Note No publishing access yet

      Your note will be visible on your profile and discoverable by anyone.
      Your note is now live.
      This note is visible on your profile and discoverable online.
      Everyone on the web can find and read all notes of this public team.

      Your account was recently created. Publishing will be available soon, allowing you to share notes on your public page and in search results.

      Your team account was recently created. Publishing will be available soon, allowing you to share notes on your public page and in search results.

      Explore these features while you wait
      Complete general settings
      Bookmark and like published notes
      Write a few more notes
      Complete general settings
      Write a few more notes
      See published notes
      Unpublish note
      Please check the box to agree to the Community Guidelines.
      View profile
    • Commenting
      Permission
      Disabled Forbidden Owners Signed-in users Everyone
    • Enable
    • Permission
      • Forbidden
      • Owners
      • Signed-in users
      • Everyone
    • Suggest edit
      Permission
      Disabled Forbidden Owners Signed-in users Everyone
    • Enable
    • Permission
      • Forbidden
      • Owners
      • Signed-in users
    • Emoji Reply
    • Enable
    • Versions and GitHub Sync
    • Note settings
    • Note Insights New
    • Engagement control
    • Make a copy
    • Transfer ownership
    • Delete this note
    • Save as template
    • Insert from template
    • Import from
      • Dropbox
      • Google Drive
      • Gist
      • Clipboard
    • Export to
      • Dropbox
      • Google Drive
      • Gist
    • Download
      • Markdown
      • HTML
      • Raw HTML
Menu Note settings Note Insights Versions and GitHub Sync Sharing URL Create Help
Create Create new note Create a note from template
Menu
Options
Engagement control Make a copy Transfer ownership Delete this note
Import from
Dropbox Google Drive Gist Clipboard
Export to
Dropbox Google Drive Gist
Download
Markdown HTML Raw HTML
Back
Sharing URL Link copied
/edit
View mode
  • Edit mode
  • View mode
  • Book mode
  • Slide mode
Edit mode View mode Book mode Slide mode
Customize slides
Note Permission
Read
Only me
  • Only me
  • Signed-in users
  • Everyone
Only me Signed-in users Everyone
Write
Only me
  • Only me
  • Signed-in users
  • Everyone
Only me Signed-in users Everyone
Engagement control Commenting, Suggest edit, Emoji Reply
  • Invite by email
    Invitee

    This note has no invitees

  • Publish Note

    Share your work with the world Congratulations! 🎉 Your note is out in the world Publish Note No publishing access yet

    Your note will be visible on your profile and discoverable by anyone.
    Your note is now live.
    This note is visible on your profile and discoverable online.
    Everyone on the web can find and read all notes of this public team.

    Your account was recently created. Publishing will be available soon, allowing you to share notes on your public page and in search results.

    Your team account was recently created. Publishing will be available soon, allowing you to share notes on your public page and in search results.

    Explore these features while you wait
    Complete general settings
    Bookmark and like published notes
    Write a few more notes
    Complete general settings
    Write a few more notes
    See published notes
    Unpublish note
    Please check the box to agree to the Community Guidelines.
    View profile
    Engagement control
    Commenting
    Permission
    Disabled Forbidden Owners Signed-in users Everyone
    Enable
    Permission
    • Forbidden
    • Owners
    • Signed-in users
    • Everyone
    Suggest edit
    Permission
    Disabled Forbidden Owners Signed-in users Everyone
    Enable
    Permission
    • Forbidden
    • Owners
    • Signed-in users
    Emoji Reply
    Enable
    Import from Dropbox Google Drive Gist Clipboard
       Owned this note    Owned this note      
    Published Linked with GitHub
    1
    • Any changes
      Be notified of any changes
    • Mention me
      Be notified of mention me
    • Unsubscribe
    # 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)

    Import from clipboard

    Paste your markdown or webpage here...

    Advanced permission required

    Your current role can only read. Ask the system administrator to acquire write and comment permission.

    This team is disabled

    Sorry, this team is disabled. You can't edit this note.

    This note is locked

    Sorry, only owner can edit this note.

    Reach the limit

    Sorry, you've reached the max length this note can be.
    Please reduce the content or divide it to more notes, thank you!

    Import from Gist

    Import from Snippet

    or

    Export to Snippet

    Are you sure?

    Do you really want to delete this note?
    All users will lose their connection.

    Create a note from template

    Create a note from template

    Oops...
    This template has been removed or transferred.
    Upgrade
    All
    • All
    • Team
    No template.

    Create a template

    Upgrade

    Delete template

    Do you really want to delete this template?
    Turn this template into a regular note and keep its content, versions, and comments.

    This page need refresh

    You have an incompatible client version.
    Refresh to update.
    New version available!
    See releases notes here
    Refresh to enjoy new features.
    Your user state has changed.
    Refresh to load new user state.

    Sign in

    Forgot password
    or
    Sign in via Facebook Sign in via X(Twitter) Sign in via GitHub Sign in via Dropbox Sign in with Wallet
    Wallet ( )
    Connect another wallet

    New to HackMD? Sign up

    By signing in, you agree to our terms of service.

    Help

    • English
    • 中文
    • Français
    • Deutsch
    • 日本語
    • Español
    • Català
    • Ελληνικά
    • Português
    • italiano
    • Türkçe
    • Русский
    • Nederlands
    • hrvatski jezik
    • język polski
    • Українська
    • हिन्दी
    • svenska
    • Esperanto
    • dansk

    Documents

    Help & Tutorial

    How to use Book mode

    Slide Example

    API Docs

    Edit in VSCode

    Install browser extension

    Contacts

    Feedback

    Discord

    Send us email

    Resources

    Releases

    Pricing

    Blog

    Policy

    Terms

    Privacy

    Cheatsheet

    Syntax Example Reference
    # Header Header 基本排版
    - Unordered List
    • Unordered List
    1. Ordered List
    1. Ordered List
    - [ ] Todo List
    • Todo List
    > Blockquote
    Blockquote
    **Bold font** Bold font
    *Italics font* Italics font
    ~~Strikethrough~~ Strikethrough
    19^th^ 19th
    H~2~O H2O
    ++Inserted text++ Inserted text
    ==Marked text== Marked text
    [link text](https:// "title") Link
    ![image alt](https:// "title") Image
    `Code` Code 在筆記中貼入程式碼
    ```javascript
    var i = 0;
    ```
    var i = 0;
    :smile: :smile: Emoji list
    {%youtube youtube_id %} Externals
    $L^aT_eX$ LaTeX
    :::info
    This is a alert area.
    :::

    This is a alert area.

    Versions and GitHub Sync
    Get Full History Access

    • Edit version name
    • Delete

    revision author avatar     named on  

    More Less

    Note content is identical to the latest version.
    Compare
      Choose a version
      No search result
      Version not found
    Sign in to link this note to GitHub
    Learn more
    This note is not linked with GitHub
     

    Feedback

    Submission failed, please try again

    Thanks for your support.

    On a scale of 0-10, how likely is it that you would recommend HackMD to your friends, family or business associates?

    Please give us some advice and help us improve HackMD.

     

    Thanks for your feedback

    Remove version name

    Do you want to remove this version name and description?

    Transfer ownership

    Transfer to
      Warning: is a public team. If you transfer note to this team, everyone on the web can find and read this note.

        Link with GitHub

        Please authorize HackMD on GitHub
        • Please sign in to GitHub and install the HackMD app on your GitHub repo.
        • HackMD links with GitHub through a GitHub App. You can choose which repo to install our App.
        Learn more  Sign in to GitHub

        Push the note to GitHub Push to GitHub Pull a file from GitHub

          Authorize again
         

        Choose which file to push to

        Select repo
        Refresh Authorize more repos
        Select branch
        Select file
        Select branch
        Choose version(s) to push
        • Save a new version and push
        • Choose from existing versions
        Include title and tags
        Available push count

        Pull from GitHub

         
        File from GitHub
        File from HackMD

        GitHub Link Settings

        File linked

        Linked by
        File path
        Last synced branch
        Available push count

        Danger Zone

        Unlink
        You will no longer receive notification when GitHub file changes after unlink.

        Syncing

        Push failed

        Push successfully