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

<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

{
  "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

<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。

import AppHeader from '@/components/AppHeader.vue'

describe('AppHeader', () => {

})

根據Jest test的文件,可以這樣寫

test('a simple string that defines your test', () => {
  // testing logic
}

這裡的test也可以改為it,Jest都認得

因此我們的test會長這樣

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本身就寫錯了。

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

npm run test確認一下結果


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

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,裡面有許多methods可以幫助測試。

為了要確認button是否有依照logged in value出現,使用wrapper中的兩個methods.find(), .isVisible().find()可以找出component中的button,.isVisible()可以確認button是否有呈現在component上。

  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)

  test("if logged in, show logout button", () => {
    const wrapper = mount(AppHeader)
    expect(wrapper.find('button').isVisible()).toBe(true)
  })

但這裡需要把loggedIn設為true,測試才會通過,wrapper.setData()可以幫忙改變component的props value。

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()


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:
Custom jest matchers to test the state of the DOM

安裝Jest:
npm install --save-dev @testing-library/jest-dom

用法:

import '@testing-library/jest-dom'
expect(warpper.find('your target').element).toBeVisible()

修改後的code:

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:


Testing Props & User Interaction

接下來要建立一個component,來測試傳入不同的props是否會產出預期的output以及模擬user interaction

src/components/RandomNumber.vue

<template> <div> <span>{{ randomNumber }}</span> <button @click="getRandomNumber">Generate Random Number</button> </div> </template>
<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

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:

Checking the default random number

為了確保component的default data value for randomNumber為0,一但有人更改他,就跑不過這一項測試了。

首先要mount 這個component,就可以拿到span裡的randomNumber

  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的值之間
  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了

Setting different prop values

mount的第二個optional argument可以傳入propsData

test('If button is clicked, randomNumber should be between 1 and 10', () => {
    const wrapper = mount(RandomNumber, {
      propsData: {
        min: 200,
        max: 300
      }
    })
  })

剩下的code就如同前一個test

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:


Testing Emitted Events

問題:如何測試component中的emit event?
官方Doc有說明

emitted method 會回傳一個被wrapper emit的自定義事件(custom event)

直接用例子實作:
LoginForm.vue

<template> <form @submit.prevent="onSubmit"> <input type="text" v-model="name" /> <button type="submit">Submit</button> </form> </template>
<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

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

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解決這問題

<input data-testid="name-input" type="text" v-model="name" />

test file中,就可以用data-testid去找到目標input

const input = wrapper.find('[data-testid="name-input"]')

  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

  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
  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:

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可以模擬 fetching api calls的過程,並且提供一些methods作為測試使用。

The Starting Code

在這個例子中,使用json-server來做後端資料庫的server,對於不複雜的資料結構來說很夠用。

./db.json

{
  "message": { "text": "Hello from the db!" }
}

在根目錄中建立db.json,就可以用json-server --watch db.json來建立一個server,用來回傳db.json裡的data。

./services/axios.js

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

<template> <p v-if="error" data-testid="message-error">{{ error }}</p> <p v-else data-testid="message">{{ message.text }}</p> </template>
<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
  2. 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

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

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

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表示:模擬一個回傳的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

async created() {
        try {
           this.message = await getMessage()
        } catch (err) {
           this.error = 'Oops! Something went wrong.'
        }
}

確認了是在created階段被呼叫的,但是vue-test-utils並沒有辦法可以取得created階段的promises,因此必須借助一個套件flush-promises,這個套件可以確保所有的promises都resolved才進行下一個task。

補充:官方文件這裡有寫測試非同步的教學


MessageDisplay.spec.js

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

  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

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很類似

  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相同了

  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()的下方寫入解法

jest.mock('@/services/axios') beforeEach(() => { jest.clearAllMocks() })

現在beforeEach test,jest都會把所有的mocks清除,再run test便成功了。


Stubbing Child Components

當你想測試一個component,它的裡頭還有一個child component,且這個child component還調用了一些外部服務(像是axios),我們的目的是測試這個component,child component的事應該要另外做它自己的測試,這時候就需要把child component stubbing

Children with Baggage

為了瞭解stubbing component的概念,先建立一個MessageContainer.vue

<template> <MessageDisplay /> </template>
<script> import MessageDisplay from '@/components/MessageDisplay' export default { components: { MessageDisplay } } </script>

MessageContainer僅僅只是import MessageDisplay,這也就是前面測試過的MessageDisplay,所以當MessageContainer渲染的時候,MessageDisplay也跟著被渲染,然後調用了axios,也就是剛剛才提到的問題:不希望真的去呼叫MessageDisplaycreated hook會觸發的axios get request。

MessageDisplay.vue

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

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)

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進來,根據文件說明,設定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,你會發現shallowMount完全不支援這些library。
    For more information of avoid using shallowMount