參考網站
參考網站
參考網站
參考網站
網站建置不是件簡單的事,我們都知道網站做好之後,有好多細節需要兼顧,所以許多公司花了大量的時間與金錢,耗用人力對維護中的網站進行不斷的、重複的人工測試,想達到的目的不外乎是希望網站不要出錯,可以給客戶/使用者最好的網站使用體驗。本篇章說明使用 Vue3 開發的專案,導入 Vitest 進行極速單元測試的體驗!
測試的種類
在不同的測試類型中,所需要保護的面向不太相同,以下是不同測試類型的大致介紹:
單元測試 (Unit testing)
以程式碼的最小單位進行測試,保護程式邏輯不會在系統維護的過程中遭到破壞,也進一步確保維護中的程式碼品質。
這種測試類型通常由開發人員自行撰寫,自己寫的 Code 自己寫測試,有經驗的開發人員可以用非常有效率的方式撰寫單元測試,因為測試範圍小,這種類型的測試通常不需要設立測試環境,因此可以得到較高的撰寫效率,也是所有測試類型中最容易撰寫的測試類型。不過,對於一個沒有經驗的開發者來說,撰寫單元測試可能會耗用大量時間,寫測試程式的時間很有可能會遠大於實際撰寫程式碼的時間,有蠻多人會因為這樣而放棄撰寫單元測試。
整合測試 (Integration testing)
整合多方資源進行測試,確保模組與模組之間的互動行為正確無誤,也讓不同模組在各自開發維護的過程中不會因為功能調整而遭到破壞。
這種類型的測試通常介於單元測試與端對端測試之間,有時候會由專職的測試人員進行開發,但大部分還是由開發團隊中負責特定模組的人來撰寫。有時候單一模組即便完全通過單元測試,獨立運作也正常,但是當需要與其他模組互動時,也是有可能發生錯誤,這時就是整合測試的主要負責領域。
以下是未通過整合測試的案例:(兩個元件在整合時發現問題)
端對端測試 (End-to-end testing) (E2E testing)
所謂的「端對端」(E2E) 是指從使用者的角度出發(一端),對真實系統(另一端)進行測試。
這種類型的測試對許多公司來說,就是「人工測試」的主要範圍,因為你可以透過人工對已經完整部屬的網站進行測試,因此可以驗證出系統是否符合客戶的實際需求。這部分也可以透過撰寫 E2E 測試程式來進行自動化,增加測試效率。
這裡有個未通過 E2E 測試的案例相當有趣,雖然每個整套系統每個模組都通過所有單元測試與整合測試,但最後組裝起來後,從使用者的角度無法接受!
測試案例中的 3A 模式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| describe('貓咪', () => {
it('摸摸,應該會發出「呼嚕嚕」聲', () => {
// ...
})
it('餵食,應該會發出「呼嚕嚕」聲', () => {
// ...
})
it('拿玩具逗,應該會發出「呼嚕嚕」聲', () => {
// ...
})
it('什麼都不做,應該推倒眼前看到的所有東西', () => {
// ...
})
})
|
當我們設立好測試情境與測試案例的敘述結構之後,要開始撰寫測試案例內部的實作時就可以利用所謂的 3A 模式來安排。
3A 模式主要是為(Arrange-Act-Assert)三個英文字的縮寫,而他們分別代表了:
- 準備(Arrange):準備好受測目標需要的一切,包含依賴的隔離等
- 操作(Act):操作受測物目標
- 斷言(Assert):預期受測物的某個狀態是否為我們所預期
以上方第一項測試案例來套用 3A 模式的話就會像是:
1
2
3
4
5
6
7
8
9
10
| it('摸摸,應該會發出「呼嚕嚕」聲', () => {
// Arrange: 準備好一隻貓
const target = new Cat()
// Act: 摸摸那隻貓咪
target.touch()
// Assert: 觀察那隻貓是否發出呼嚕嚕叫聲
expect(target.speaking).toBe('呼嚕嚕')
})
|
那如果這時候你可能會想到每個測試案例都要準備一隻貓貓,對於測試案例來說就會一直不斷地去做「準備(Arrange)」這個行為,因此你可能會很直覺的這麼處理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| describe('貓咪', () => {
const target = new Cat()
it('摸摸,應該會發出「呼嚕嚕」聲', () => {
target.touch()
expect(target.speaking).toBe('呼嚕嚕')
})
it('餵食,應該會發出「呼嚕嚕」聲', () => {
target.feed('乾乾')
expect(target.speaking).toBe('呼嚕嚕')
})
it('拿玩具逗,應該會發出「呼嚕嚕」聲', () => {
target.play()
expect(target.speaking).toBe('呼嚕嚕')
})
})
|
但這樣的後果就是每個測試案例之間就會有關聯了,比方貓貓其實摸太多下他也會覺得厭煩,從而導致測試失敗:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| describe('貓咪', () => {
const target = new Cat()
it('摸摸下巴,應該會發出「呼嚕嚕」聲', () => {
target.touch()
expect(target.speaking).toBe('呼嚕嚕')
})
it('再摸一次下巴,應該會發出「呼嚕嚕」聲', () => {
target.touch()
expect(target.speaking).toBe('呼嚕嚕')
})
it('再摸一次下巴,應該會發出「呼嚕嚕」聲', () => {
target.touch()
expect(target.speaking).toBe('呼嚕嚕') // 預期呼嚕嚕,結果貓咪生氣了
})
})
|
而要寫好測試案例的其中幾個概念就是要盡量讓每個測試案例之間「不受順序影響測試結果」與「保持獨立」。
因此大多數的「測試環境」的工具都會提供類似相關的 API 來協助處理測試開始前的「Setup」與結束後的「Teardown」環節。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| describe('貓咪', () => {
const target = new Cat()
beforeEach(() => {
// 每個測試案例開始前要做的事情
target.init() // 初始化貓貓的各種狀態
})
afterEach(() => {
// 每個測試案例結束後要做的事情
})
it('摸摸,應該會發出「呼嚕嚕」聲', () => {
target.touch() // 這時候的 target 已經是經過 init() 的版本了
expect(target.speaking).toBe('呼嚕嚕')
})
it('餵食,應該會發出「呼嚕嚕」聲', () => {
target.feed('乾乾') // 這時候的 target 已經是經過 init() 的版本了
expect(target.speaking).toBe('呼嚕嚕')
})
it('拿玩具逗,應該會發出「呼嚕嚕」聲', () => {
target.play() // 這時候的 target 已經是經過 init() 的版本了
expect(target.speaking).toBe('呼嚕嚕')
})
})
|
綜合上述 3A 與處理 Setup & Teardown 的觀念,之後再寫測試案例時,我們可以先從基礎的 3A 模式開始撰寫,而到有需要處理重複的事前準備(Setup)與後續清理時(Teardown),就可以藉由工具來替我們統一處理。
看到這邊讀者應該會發現,測試的基本概念其實不難懂,而在瞭解測試的概念後,剩下的就是把概念轉換為測試工具可讀懂測試程式碼就好了!
專案加入 Vitest
在初始化專案過程加入 Vitest
建立專案時若要加入單元測試要注意到的是:Node.js 版本必須為 14 以上,否則 Vitest 會無法順利執行!
確認 Node.js 版本後,在要建立專案的父層路徑底下透過終端機指令輸入 npm create vite@latest
來建立一個基於 Vite 所建構的專案,接著終端機會出現一些問題,視專案需求選擇:
- Project name ⇒ 輸入自訂的專案名稱後按下 Enter
- Select a framework ⇒ 選擇
Vue
後按下 Enter - Select a variant ⇒ 選擇
Customize with create-vue
後按下 Enter - 中間可能還會有 TypeScript, ESLint⋯⋯等等問題,請視專案需要加入
- Add Vitest for Unit Testing(y/n) ⇒ 選擇
Yes
後按下 Enter (最重要的部分
回答完上面的問題後,建構工具就會依據剛才答覆的內容,自動生成需要的部分。
比方在 Add Vitest for Unit Testing
問題回答 Yes
的話,這時建構工具就會在專案中生成單元測試所需要的相關內容如下:
- 一個位於
~專案根目錄/src/components/__test__/HelloWorld.spec.js
的測試範例
1
2
3
4
5
6
7
8
9
10
11
| import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import HelloWorld from '../HelloWorld.vue'
describe('HelloWorld', () => {
it('renders properly', () => {
const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
expect(wrapper.text()).toContain('Hello Vitest')
})
})
|
- 在
~專案根目錄/package.json
自動新增一個啟動單元測試的 scripts 指令
1
2
3
4
5
| {
"scripts": {
"test:unit": "vitest --environment jsdom"
},
}
|
- 在
~專案根目錄/package.json
自動新增單元測試所需要的工具們
1
2
3
4
5
6
7
| {
"devDependencies": {
"@vue/test-utils": "^2.0.2",
"jsdom": "^20.0.0",
"vitest": "^0.23.0"
}
}
|
接著如剛剛終端機後方的提示:
- 執行
cd {剛才專案名稱}
切換到專案目錄底下 - 執行
npm install
安裝專案所需要的內容
安裝完畢後,接著就可以執行 npm run dev
確認環境,後續要執行測試的話,執行 npm run test:unit
就能立即運作了。
在既有專案中加入 Vitest
在既有專案中加入 Vitest
來作為執行測試的環境時,需確認專案本身是由 Vite
(2.7.10
版本以上)所建構的之外,其 Node.js
版本也必須為 14
以上,否則會無法順利執行測試!
確認好必要條件後就可以開始安裝測試工具:
vitest
:單元測試框架(提供了執行測試的環境、斷言、隔離庫⋯⋯等等功能與 API)@vue/test-utils
:測試 Vue
元件的工具jsdom
:讓我們可以在 Node
環境模擬出瀏覽器中的 DOM
環境(方便測試)
- 在專案根目錄下執行:
1
| npm install -D vitest @vue/test-utils jsdom
|
- 新增 npm 執行單元測試的指令:
package.json
1
2
3
4
5
| {
"scripts": {
"test:unit": "vitest --environment jsdom"
},
}
|
這時若心急的執行 npm run test:unit
會發現終端機告訴你 No test files found, exiting with code 1
,原因是你尚未加入任何一個測試案例。
而一開始 Vitest
預設測試的比對規則是 */*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}
,簡單來說你在專案底下使用 A.test.js
或是 B.spec.ts
的方式都會被 Vitest 認為是測試程式檔。
這時,我們可以在 ~專案根目錄/src/components/__test__/
底下新建 HelloWorld.spec.js
:
src/components/test/HelloWorld.spec.js
1
2
3
4
5
6
7
| import { describe, it, expect } from 'vitest'
describe('HelloWorld', () => {
it('1 + 1 should be 2', () => {
expect(1 + 1).toBe(2)
})
})
|
再次執行 npm run test:unit
終端機就會顯示:
到這裡就算是成功安裝好囉,接下來我們會再把 Vitest Config
初期需要設定的部分調整好後,建置的部分就完成了。
Vitest Config 設定方式
Vitest 測試在執行的時候預設會基於原先專案中的 vite.config.js
設定檔,所以沒有需要調整的話不太需要另外設置,需要調整的話 Vitest 也提供了三種方法讓你在不同開發情境下選擇調整方式:
- 執行
npm
指令時帶參數指定設定檔案路徑 - 直接在原先
vite.config.js
中調整 - 透過
vitest.config.js
檔案調整測試設定
執行 npm 指令時帶參數指定設定檔案路徑
若想要在執行的時候去引入不同的設定檔案,則可以在 package.json
指令中透過 --config
加上設定檔案的路徑(e.g. vitest --config ./src/scripts/vitest.config.js
):
package.json
1
2
3
4
5
6
7
| {
//...
"scripts": {
"test:unit": "vitest --config ./src/__test__/config/vitest.config.js",
}
//...
}
|
接著該檔案就如設定 vitest.config.js
一樣調整就可以了:
1
2
3
4
5
6
7
8
9
10
11
12
| import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
],
test: {
// 在這邊加入設定
},
})
|
在專案中的 vite.config.js 中調整測試設定
若想在原先的 vite.config.js
中調整測試設定,有兩種方式可以使用:
- 第一種是直接在最上方加入
/// <reference types="vitest" />
後,在 test 屬性中加入設定:
vite.config.js
1
2
3
4
5
6
7
8
| /// <reference types="vitest" />
import { defineConfig } from 'vite'
export default defineConfig({
test: {
// 在這邊加入設定
},
})
|
- 第二種,把原先的
defineConfig
改由 vitest/config
傳入後,在 test
屬性中加入設定:
vite.config.js
1
2
3
4
5
6
7
| import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
// 在這邊加入設定
},
})
|
透過 vitest.config.js 檔案調整測試設定
若想區隔開發與測試用的設定時,可以在專案根目錄中新增 vitest.config.js
檔案,這會比原先參考的 vite.config.js
擁有更高的優先權。
不想將原先在 vite.config.js
設定都重新全寫一次的話,也可以在 vitest.config.js
中,使用 mergeConfig
來融合 vite.config.js
的設定:
1
2
3
4
5
6
7
8
9
| import { mergeConfig } from 'vite'
import { defineConfig } from 'vitest/config'
import viteConfig from './vite.config' // 原先的 vite 設定檔案
export default mergeConfig(viteConfig, defineConfig({
test: {
// 在這裡加入測試設定
},
}))
|
Vitest config option
至於 Vitest config 能調整什麼內容呢,這裡列了幾個常見的調整選項:
- include
- exclude
- globals
- environment
註:以下部分資料引用 Vitest Config Option 並翻譯與補充。
include
- Type: string[]
- 預設值: [’**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}']
藉由這個欄位我們可以提供 glob 格式讓 vitest 去比對哪些是測試檔案,也可以放入多個條件在陣列中。
vitest.config.js
1
2
3
4
5
6
7
| import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['**/*.spec.js'],
},
})
|
這個設定主要會影響到後續我們要如何擺放測試程式碼,因此做規劃時可以按需求考量調整。
exclude
- Type: string[]
- 預設值: [’/node_modules/’, ‘/dist/’, ‘/cypress/’, ‘/.{idea,git,cache,output,temp}/’]
藉由這個欄位我們可以提供 glob 格式讓 vitest 去排除哪些不是測試檔案,也可以放入多個條件在陣列中。
vitest.config.js
1
2
3
4
5
6
7
| import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**', '**/.{idea,git,cache,output,temp}/**'],
},
})
|
與 include
相反,這次則是要排除哪些路徑不需要尋找是否有測試檔案,其中如果有用到 cypress
做 E2E 測試的話,預設規則中就已經有另外排除了,所以沒必要的話不必特別設置,但可以先記得有這個方便的欄位。
Globals
由於在撰寫測試時,Vitest 預設是需要自己按需要引入對應的方法等等,如果要類似像 Jest 以全域的方式注入到測試中,就可以透過在執行時加上 --globals
選項,或是在 vitest config
選項中加入 globals: true
。
vitest.config.js
1
2
3
4
5
6
7
| import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
},
})
|
原先設定前,測試程式碼需要如下方引入:
1
2
3
4
5
6
7
| import { describe, it, expect } from 'vitest'
describe('HelloWorld', () => {
it('1 + 1 should be 2', () => {
expect(1 + 1).toBe('2')
})
})
|
加入 Globals: true
後,就不需要顯示引入 vitest 測試相關的 API,讓測試看起來更乾淨:
1
2
3
4
5
| describe('HelloWorld', () => {
it('1 + 1 should be 2', () => {
expect(1 + 1).toBe('2')
})
})
|
environment
- Type: ’node’ | ‘jsdom’ | ‘happy-dom’ | ’edge-runtime’ | string
- 預設值: ’node’
由於 Vitest 本身默認環境 Node.js,因此若要在測試中仿造瀏覽器的應用程式,可以透過類似 jsdom
等工具來取代,而已經介紹過的 npm 指令的寫法之外 -environment jsdom
,還可以在測試檔案上以 docblock 或 comment 風格的方式註記。
Docblock 風格:
1
2
3
4
5
6
7
8
9
| /**
* @vitest-environment jsdom
**/
it('use jsdom in this test file', () => {
const element = document.createElement('div')
element.innerHTML = '<p>Hello, HTML!</p>'
expect(element.innerHTML).toBe('<p>Hello, HTML!</p>')
})
|
Comment 風格:
1
2
3
4
5
6
7
| // @vitest-environment jsdom
it('use jsdom in this test file', () => {
const element = document.createElement('div')
element.innerHTML = '<p>Hello, HTML!</p>'
expect(element.innerHTML).toBe('<p>Hello, HTML!</p>')
})
|
寫在 vitest config
中:
1
2
3
4
5
6
7
| import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
},
})
|
如此一來在測試中我們就可以模擬 Web 端環境來操作了。
以上是 Vitest 幾個比較常見的設定,其餘的設定後續也會按需求陸續提到,若對於其他設定選項有興趣的,也可以直接到官方文件中的 config 分頁 查看。
測試的本質
測試的本質究竟是什麼?測試簡單的來說主要就是藉由操控受測物(SUT, System Under Test),觀察由受測物產生的最終狀態是否如我們所預期的樣子:
若最終狀態符合我們的預期,對於開發者的含義來說,就是受測物符合了我們的期待。
但這點好處看似沒有做「測試」的必要,畢竟我們平時開發能驗收完成不就是符合了規格書的期待嗎?
因此為了能夠更加體會測試背後的價值,我們接下來將實際做一個測試工具來來檢驗我們自己所寫的函式。
情境舉例
首先,情境假設在專案中有一處邏輯判斷需要檢測傳進來資料的是否為數值(Numeric),而傳進來的資料可能有 1
, null
, '100'
, NaN
等。
我們希望當 1
和 '100'
時,該判斷應該要為 true
,而 null
與 NaN
則是要為 false
。
接下來的目標我們要做的就是:
- 製作一個簡易的測試工具
- 寫測試案例
- 寫受測程式碼的實作
簡易的測試工具
首先規劃一下工具我最後希望用起來像是這個樣子:
expect(受測物).toBe(預期狀態)
- 回傳
true
表示測試成功 (即為最終狀態與預期狀態相同) - 回傳
false
表示測試失敗 (即為最終狀態與預期狀態不同),然後加上 error 提示預期狀態應該要是什麼,最終狀態目前是什麼。
接著按照上面的設想,先定義了一個宣告函式 expect
,參數則是預計輸入受測物(input
)與:
1
2
3
| const expect = (input) => {
// ...
}
|
再來工具本身呼叫時需回傳了一個叫做 toBe
的驗證方法,該驗證方法的參數為預期目標(expected
):
1
2
3
4
5
6
| const expect = (input) => {
const toBe = (expected) => {}
return {
toBe
}
}
|
接著設計該驗證的方法,使其能夠回應測試的結果:
1
2
3
4
5
6
| const expect = (input) => {
const toBe = (expected) => input === expected
return {
toBe
}
}
|
現在我們透過網頁瀏覽器的 devtool console 控制台,就可以透過該測試工具做簡易的測試案例了:
1
2
3
4
5
6
7
8
| const expect = (input) => {
const toBe = (expected) => input === expected
return {
toBe
}
}
expect(1 === 1).toBe(true)
expect(2 !== 1).toBe(true)
|
但我們希望他能夠在測試案例失敗的時候回應一下當下預期與結果的狀況,後續我們才能針對紀錄的結果做修正。因此我們再修改一下測試方法:
1
2
3
4
5
6
7
8
9
10
| const expect = (input) => {
const handleOnError = (result, expected) => {
console.error(`測試失敗:預期應該為 ${expected},結果現在為 ${result}`)
return false
}
const toBe = (expected) => input === expected ? true : handleOnError(input, expected)
return {
toBe
}
}
|
再執行一個故意寫錯的測試案例:
1
| expect(2 === 1).toBe(true)
|
現在可以看到當測試案例發生錯誤的時候,除了會回傳測試結果之外,還會多個錯誤提醒目前錯誤的原因了!
寫測試案例
在完成測試工具後,現在我們要來寫測試案例,首先我們先寫一個還沒有寫實作部分的判斷函式(isNumeric
):
1
| const isNumeric = (val) => {}
|
接著按照題目所設定的條件,來撰寫測試案例:
題目設定:
當 1
和 '100'
時,該判斷應該要為 true
,
而 null
與 NaN
則是要為 false
。
1
2
3
4
5
6
| const isNumeric = (val) => {}
expect(isNumeric(1)).toBe(true)
expect(isNumeric('100')).toBe(true)
expect(isNumeric(null)).toBe(false)
expect(isNumeric(NaN)).toBe(false)
|
此時執行後會發現會發現四個案例都報錯,因為目前我們還沒撰寫 isNumeric
的判斷實作,但到這一步測試案例就已經算是寫好了,因為我們的目的是要寫對的測試案例,讓實作去符合。
寫受測程式碼的實作
完成測試案例後,我們將要來寫測試程式碼的實作部分:
1
| const isNumeric = (value) => !isNaN(value - parseFloat(value))
|
最後,將整個測試與實作合併起來就會像這樣子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| // 測試工具程式碼部分
const expect = (input) => {
const handleOnError = (result, expected) => {
console.error(`測試失敗:預期應該為 ${expected},結果現在為 ${result}`)
return false
}
const toBe = (expected) => input === expected ? true : handleOnError(input, expected)
return {
toBe
}
}
// 實作程式碼部分
const isNumeric = (value) => !isNaN(value - parseFloat(value))
// 測試案例部分
expect(isNumeric(1)).toBe(true) // true,即為通過測試
expect(isNumeric('100')).toBe(true) // true,即為通過測試
expect(isNumeric(null)).toBe(false) // true,即為通過測試
expect(isNumeric(NaN)).toBe(false) // true,即為通過測試
|
完成!現在已經不會顯示測試失敗的訊息了,也就表示 isNumeric
方法符合我們的預期囉。
而現在假使我想使實作程式碼的部分更加的完善,只要增加合適的測試案例進去就可以增添受測物本身的信賴與穩定度:
1
2
3
4
5
6
7
| expect(isNumeric(1)).toBe(true)
expect(isNumeric(1.123)).toBe(true)
expect(isNumeric(0xFFF)).toBe(true)
expect(isNumeric('100')).toBe(true)
expect(isNumeric(undefined)).toBe(false)
expect(isNumeric(null)).toBe(false)
expect(isNumeric(NaN)).toBe(false)
|
甚至將來改寫實作(isNumeric
)的時候,既有的測試內容就會提醒你是否違反了之前所寫的測試案例。
測試工具的選擇
上面有提到簡易的測試工具是如何手刻出來的,也稍微談到了手刻測試工具的困難,因此選擇一個適當的測試工具來輔助我們進行測試是有必要性的。
但測試工具百百種,許多工具在提供的功能上又重疊,那麼我們該如何選擇工具呢?
若是以「單元測試」來說的話,我們在撰寫測試程式碼時可能至少就會有以下的需求:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| /* 引入相關檔案 */
describe('測試情境描述', () => {
it('測試案例描述', () => {
const wrapper = mount(component)
expect(wrapper.text()).toBe('Hello, World')
})
it('另一個測試案例描述', () => {
const wrapper = mount(component, {
props: {
content: 'Unit-test!'
}
})
expect(wrapper.text()).toBe('Unit-test!')
})
})
|
- 測試環境(test runner):提供上方測試程式碼執行的環境。
- 測試情境(test suite):如上方的
describe
,用來包裹多個測試案例,以及描述測試情境⋯⋯等功能。 - 測試案例(test case):如上方的
it
,用來包裹該測試案例的實際情況,若有錯誤時需讓我們能夠輕易找到是哪個案例發生的⋯⋯等等功能。 - 斷言(Assertion):如上方的 expect,主要是用來判斷受測物與預期結果是否一致的方法,甚至依據不同判斷方式內部也提供了多種判斷方式,如 .toBe 可用來判斷選取的目標與預期結果是否相等。
除此之外,在必要的情況下甚至會需要:
- 測試替身(test double):用來模仿依賴工具或函式原先的功能狀況
- 解析或模擬元件容器
- 模擬瀏覽器中 DOM 的環境
而根據上述需求,就能夠列出一個需求表,來評估各個測試工具是否符合我們測試需要的部分:
測試工具列表可參考個框架中的測試建議指南,比方 Vue 的測試建議指南
需求 | Vitest | Jest | Mocha | Chai.js | Sinon.js | Vue-test-utils | jsdom |
---|
測試環境(test runner) | ✔(註1) | ✔ | ✔ | | | | |
測試情境(test suite) | ✔ | ✔ | ✔ | | | | |
測試案例(test case) | ✔ | ✔ | ✔ | | | | |
斷言(Assertion) | ✔ | ✔ | | ✔ | | | |
測試替身(test double) | ✔ | ✔ | | | ✔ | | |
解析或模擬元件容器 | | | | | | ✔ | |
提供模擬 DOM 環境 | | | | | | | ✔ |
註1:Vitest 本身基於 Vite 環境,因此專案若非透過 Vite 所構建的話就無法使用。
現在透過這個表我們可以很清楚的看見,假設專案是基於 Vite
所建立的那麼我可以選擇下列這個組合:
Vitest
+ Vue-test-utils
+ jsdom
如果專案是基於 Vue-cli
所建立的,那麼就無法使用了 Vitest
作為測試運行的環境了,此時根據表中我們就可以替換為:
- 方案一:
Jest
+ Vue-test-utils
+ jsdom
- 方案二:
Mocha
+ Chai.js
+ Sinon.js
+ Vue-test-utils
+ jsdom
而在做工具替換時,需要注意到的是,不同的測試環境(test runner)可能會對於引入(import
)檔案時發生解析上的問題,比方若用到 Vue 中的 SFC
類型檔案作為開發,那在 Jest
做為測試環境時我們就需要另外安裝 vue-jest
來做轉換 SFC
上的處理。
當然,上述的需求表只是一個簡單的範例,而隨著撰寫測試的經驗越多,後續慢慢就會瞭解到專案中會需要哪些測試工具來協助我們進行測試,甚至比較各種測試工具的優缺點從而選出最適合團隊的測試工具!
現在我們已經了解要如何挑選測試工具了,接下來要開始聊聊撰寫測試案例時應該要如何思考!
決定測試情境與測試案例
再來談談在選擇這些工具後,我們要如何來思考針對「單元測試」的部分,測試情境與測試案例要怎麼撰寫以及需要注意的地方。
測試情境(test suite)測試案例(test case)
提供測試環境的工具一般會給予測試情境與測試案例的相關 API,而測試情境與案例最主要的用意是用來幫助我們規劃與整理整個測試邏輯,並且在我們測試案例錯誤的時候同時提供相對應的資訊。
接下來我們以「貓咪作為元件」為故事主軸來談談單元測試的思路會是怎麼撰寫。
首先我們假設「貓咪」元件會提供下列功能:
而以使用者角度上來看,我們會對「貓咪」做出這些事情:
接著我們就可以以元件作為情境來定義出各種使用上的測試案例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| describe('貓咪', () => {
it('摸摸,應該會發出「呼嚕嚕」聲', () => {
// ...
})
it('餵食,應該會發出「呼嚕嚕」聲', () => {
// ...
})
it('拿玩具逗,應該會發出「呼嚕嚕」聲', () => {
// ...
})
it('什麼都不做,應該推倒眼前看到的所有東西', () => {
// ...
})
})
|
但真實的測試案例不會這麼簡單,總會有一些例外狀況,那麼測試案例應該要包含哪些要素比較好?
案例路徑(happy path、sad path、bad path)
在設定測試案例時,總會有百百種情況,但是我們不可能每個都寫出來,而根據測試案例的類型主要可以分為三種路徑:
- Happy Path:在我們定義所謂「正常的使用狀況」下,針對「正確的內容」,我們預期應該要做出的反應。
- Sad Path:在我們定義所謂「正常的使用狀況」下,針對「錯誤的內容」,我們預期應該要做出的反應。
- Bad Path:在我們定義所謂「錯誤」的使用狀況下,我們預期應該要做出的反應。
同樣以貓被摸摸作為例子來看,假設對於貓來說能夠接受摸摸的情況只限於「頭與下巴」,而喜歡被摸的部分只有「下巴」,那麼測試案例路徑就可以這麼定義:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| describe('貓咪', () => {
// Happy Path
it('摸摸下巴,應該會發出「呼嚕嚕」聲', () => {
// ...
})
// Sad Path
it('摸摸頭,應該沒反應', () => {
// ...
})
// Bad Path
it('摸摸肚子,應該拋出錯誤', () => {
// ...
})
})
|
路徑優先度
而以各個路徑優先度來說,我們應當盡可能的先完善 Happy Path 與 Sad Path 的各種案例,因為對於實際專案上來說,這些內容是我們預期元件或函式本身應提供的內容。
然而 Bad Path 通常是建立已知哪些情況會是「不正確的使用」,我們只能就已知的狀況來預防。然而,這一部分其實再怎麼縝密,都還是會有極其例外的事情發生:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| describe('貓咪', () => {
// ...
// Bad Path
it('摸摸肚子,應該拋出錯誤', () => {
// ...
})
it('摸摸尾巴,應該拋出錯誤', () => {
// ...
})
it('摸摸手,應該拋出錯誤', () => {
// ...
})
it('摸摸腳,應該拋出錯誤', () => {
// ...
})
// 舉不完
})
|
因此透過測試來作防禦所有不合理的行為是不治本的行為,我應該從受測物本身提供的操作或是產品規劃上來去考量相對治本的方式;比方在「貓咪」元件上透過程式設計的部分,規劃「摸摸」行為只開放「**頭」**與「下巴」的部位,從開發階段就阻止其他開發者誤用「摸摸」方法,到最後真的有其必要特別拋出時才針對這一部分寫 Bad Path 案例。
最後,再用一個實際一點的例子說明,假設今天有個登入的表單元件,那麼最簡單預期會有的案例就會是:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| describe('登入元件', () => {
// Happy Path
it('輸入正確帳號密碼,應登入到OO頁面', () => {
// ...
})
// Sad Path
it('只輸入帳號,應該顯示請輸入密碼', () => {
// ...
})
it('只輸入密碼,應該顯示請輸入帳號', () => {
// ...
})
it('輸入錯誤帳號密碼,應該顯示登入資訊錯誤', () => {
// ...
})
})
|
以上就是測試情境與測試案例最基本需要瞭解的部分,接著我們來關注於測試案例中內部應該要如何來撰寫。
撰寫測試程式碼的前言
撰寫測試程式碼需保持的心境
談到撰寫測試,不得不提到 Robert Cecil Martin 有本經典的《無瑕的程式碼:敏捷軟體開發技巧守則》,作者在其中針對了程式碼的部分提了很多優良的建議,不論是撰寫程式碼中的命名、函式參數等等做探討,也在實際的案例中做一些討論與重構,最後回顧那些程式碼中的氣味、清理程式碼,一氣呵成。
而其中有一個章節最主要就是在講有關「單元測試」的部分,也就是系列文前言提到「被推坑」的部分,我把裡面的概念結合我的經驗來重新闡述一次:
若帶著單純學習的心態來看待測試程式碼,其實很容易把它當作一項輔助工具在處理,覺得沒有也沒什麼關係,甚至礙手礙腳的,甚至可能覺得還要特地花了不少心力來瞭解它,但這是一體兩面的。
若你把它當作產品中的一部分就會發現,雖然開發時會受到測試程式碼的「限制」,但同時他其實也是在做「守護」產品程式碼這件事情;甚至因為有了「可信賴的測試」,所以當我們在進行「重構」等等調整時也才能更有信心地去處理。
因此只要確保我們在適當的時機加入它,那麼它就能夠帶來可觀的後續效益,而既然他能夠替我們帶來效益,那我們對待測試程式碼的態度其實也應該要同理產品程式碼,可是除了讓測試程式碼如同產品程式碼一樣保持「整潔」與「可讀性」之外,我們還有什麼辦法呢?
對於這個問題 Martin 給了一個撰寫測試碼的優良準則,那就是 F.I.R.S.T 法則。
F.I.R.S.T 法則
F.I.R.S.T 法則顧名思義其實就是五個英文單字的縮寫,他們分別是:
- Fast
- Independent
- Repeatable
- Self-Validating
- Timely
Fast(快)
簡單來說就是快,因為快才能讓我們快速重複大量地檢驗產品程式碼;這意味著我們在測試程式碼中有可能會遇到要 call API 的情況,那麼我們可以將其隔離並立刻回傳我們預定好的資料來減少等待回應的時間。
Independent(獨立)
如我們上一篇文章所提到的,每個測試案例應該要互相獨立,彼此不受干擾之外,甚至連執行順序上都不會影響到最終結果。
Repeatable(可重複)
可重複性主要提的就是不論在什麼設備狀況下,應該都要保持著一致的結果,這樣我們才能排除掉其他不必要的原因,專注在發生問題的程式碼上。
Self-Validating(能自我驗證)
測試的最終狀態應該要能夠顯示「通過與否」,而這一部分測試工具或框架都會幫我們處理好,甚至都還有提供額外的 diff 差異讓我們快速查看錯誤的地方。
Timely(即時)
測試程式碼要盡可能的與產品程式碼同進退,如果測試程式碼落後產品程式碼太多,測試程式碼會越來越難跟上產品程式碼的步調。
而根據對待測試程式碼心態的優良法則,在撰寫測試程式碼時就能更容易體會到他的魅力。
describe & it 與 輔助 API
describe & it 基礎用法
稍早有提到的測試情境與測試案例在 Vitest
工具中主要便是透過 describe
與 it
(或 test
) 來撰寫,而他們的用意除了規劃測試的脈絡之外,最主要是用來包裝斷言結果的資訊:
1
2
3
4
5
6
7
8
| import {describe, it} from 'vitest'
describe('測試情境的描述', () => {
it('第一個測試案例的描述', () => {
const add = (x, y) => x + y
expect(add(1, 2)).toBe(2)
})
})
|
假設在終端機執行上方的測試程式碼時,依據工具預設設定會在終端機顯示「錯誤的相關資訊」與「綜合結果」。
錯誤的相關資訊:
- FAIL:發生斷言錯誤檔案路徑 + 情境描述 + 案例描述(視當下錯誤所屬的情境與案例)。
- AssertionError:發生斷言錯誤的原因,與發生錯誤的段落。
- Expected:預期結果。
- Received:實際結果。
綜合結果:
- Test Files:總共測試了幾隻測試檔案,並顯示成功、失敗與跳過的數量。
- Tests:總共測了幾個測試案例,並顯示成功、失敗與跳過的數量。
- Start:測試開始時間。
- Duration:測試過程總共耗費時間。
而除了上述基本用法之外,在同一個測試情境中也能容納數個測試案例。
1
2
3
4
5
6
7
8
| describe('測試情境的描述' , () => {
it('第一個測試案例的描述', () => {
// ...
})
it('第二個測試案例的描述', () => {
// ...
})
})
|
甚至測試情境較為複雜的情況,測試情境(describe
)也允許巢狀的方式來建構測試:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| describe('父層情境', () => {
describe('基於父層情境的情境一', () => {
it('測試案例', () => {
// ...
})
})
describe('基於父層情境的情境二', () => {
it('測試案例', () => {
// ...
})
it('出現提示寄件者姓名與電話將直接註冊成會員', () => {
// ...
})
})
})
|
相反地,若在簡單的情境的之下,只有測試案例也是允許的方式之一:
1
2
3
4
5
6
7
8
9
| it('測試案例', () => {
// ...
})
it('測試案例', () => {
// ...
})
it('測試案例', () => {
// ...
})
|
describe & it 輔助 API
除了上述的基本用法之外,透過 describe
與 it
撰寫測試的過程中也能透過輔助的 API
來整理測試程式碼:
.only
:測試情境、測試案例皆可使用.skip
:測試情境、測試案例皆可使用.todo
:測試情境、測試案例皆可使用.fails
:測試案例才能使用
only
若在測試情境用了此指令,則在同個測試檔案中只會執行帶有 .only
的測試情境,而其餘測試情境底下所有的測試案例將會被跳過(skipped
):
1
2
3
| describe.only('測試情境 1', () => { /* */ })
describe('測試情境 2', () => { /* */ }) // skipped
describe('測試情境 3', () => { /* */ }) // skipped
|
若在測試案例中使用,則除了帶有 .only
之外的測試案例都將會被跳過:
1
2
3
4
5
6
7
8
9
10
11
| describe('測試情境 1', () => {
it.only('測試案例', () => { /* */ })
it('測試案例', () => { /* */ }) // skipped
})
describe('測試情境 2', () => {
it.only('測試案例', () => { /* */ })
it('測試案例', () => { /* */ }) // skipped
})
describe('測試情境 3', () => { /* */ }) // skipped
|
skip
測試情境或測試案例被標注時,將自動跳過該範疇內的測試案例:
1
2
3
4
5
6
7
8
9
| describe.skip('測試情境 1', () => {
it('測試案例', () => { /* */ }) // skipped
it('測試案例', () => { /* */ }) // skipped
})
describe('測試情境 2', () => {
it('測試案例', () => { /* */ })
it.skip('測試案例', () => { /* */ }) // skipped
})
|
todo
測試情境或測試案例被標注時,同樣將自動跳過該範疇內的測試案例,但 todo
含義比較接近待加入測試的區塊,並且將來若產出報告時也會特別整理出還有哪些地方需要補上測試。
fails
最後一個介紹的是測試案例才能使用的輔助 API,還記得列測試案例時的 sad path
嗎?當測試案例應該要失敗的時候就可以透過 fails
顯性標註他們:
1
2
3
4
| it.fails(`'1' + '1' should not to be '11'`, () => {
const add = (x, y) => Number(x) + Number(y)
expect(add('1', '1')).toBe('11')
})
|
當然你也可以單純藉由斷言中的 .not
達到同樣的效果:
1
2
3
4
| it(`'1' + '1' should not to be '11'`, () => {
const add = (x, y) => Number(x) + Number(y)
expect(add('1', '1')).not.toBe('11')
})
|
以上便是測試程式碼中測試情境與測試案例的部分,有關於輔助 API 的部分有些內容不一定會使用的到,若想更加瞭解所有可用的部分可以參考 Vitest 測試情境 與 測試案例 的文件囉。
準備(Setup)與清理(Teardown)
經過了基本的測試情境與測試案例語法,加上簡單的斷言語法 expect().toBe()
就能夠測試許多簡單的東西了,然而在測試過程中有時會遇到大量重複的「前置操作」或是每次測試後「需要清理測試中的環境」,這時我們可以透過 Vitest
提供的 Setup
& Teardown
API 來處理:
- beforeEach:在每個測試案例執行前呼叫一次。
- beforeAll:在所有測試案例執行前呼叫一次。
- afterEach:在每個測試案例執行後呼叫一次。
- afterAll:在所有測試案例執行後呼叫一次。
1
2
3
4
| beforeEach(() => {
// 針對測試案例重新初始化
initTestEnv()
})
|
Setup & Teardown API 的範疇
Setup
& Teardown
API 「所有」的定義是根據當下的範疇(context
)來決定,除了測試檔案本身之外,使用 describe
來定義測試情境也會形成一個 context
,因此假如測試情境有巢狀的情況如下:
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| const history = []
describe('父層情境', () => {
beforeAll(() => {
history.push('beforeAll - 父層情境')
})
beforeEach(() => {
history.push('beforeEach - 父層情境')
})
afterAll(() => {
history.push('afterAll - 父層情境')
})
afterEach(() => {
history.push('afterEach - 父層情境')
})
describe('子層情境 A', () => {
beforeAll(() => {
history.push('beforeAll - 子層情境 A')
})
beforeEach(() => {
history.push('beforeEach - 子層情境 A')
})
afterAll(() => {
history.push('afterAll - 子層情境 A')
})
afterEach(() => {
history.push('afterEach - 子層情境 A')
})
it('案例 1', () => {
history.push('子層情境 A 案例 1')
})
it('案例 2', () => {
history.push('子層情境 A 案例 2')
})
})
describe('子層情境 B', () => {
beforeAll(() => {
history.push('beforeAll - 子層情境 B')
})
beforeEach(() => {
history.push('beforeEach - 子層情境 B')
})
afterAll(() => {
history.push('afterAll - 子層情境 B')
})
afterEach(() => {
history.push('afterEach - 子層情境 B')
})
it('案例 1', () => {
history.push('子層情境 B 案例 1')
})
it('案例 2', () => {
history.push('子層情境 B 案例 2')
})
})
})
|
此時將透過 console.log(history)
查看並歸納整理就能得到以下結果:
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
| --- 進入測試程式碼本身的 Context
--- 進入父層情境的 Context
beforeAll - 父層情境
--- 進入子層情境 A 的 Context
beforeAll - 子層情境 A
beforeEach - 父層情境
beforeEach - 子層情境 A
子層情境 A 案例 1 // 執行 情境 A 案例 1 的時間點
afterEach - 子層情境 A
afterEach - 父層情境
beforeEach - 父層情境
beforeEach - 子層情境 A
子層情境 A 案例 2 // 執行 情境 A 案例 2 的時間點
afterEach - 子層情境 A
afterEach - 父層情境
afterAll - 子層情境 A
--- 離開子層情境 A 的 Context
--- 進入子層情境 B 的 Context
beforeAll - 子層情境 B
beforeEach - 父層情境
beforeEach - 子層情境 B
子層情境 B 案例 1 // 執行 情境 B 案例 1 的時間點
afterEach - 子層情境 B
afterEach - 父層情境
beforeEach - 父層情境
beforeEach - 子層情境 B
子層情境 B 案例 2 // 執行 情境 B 案例 2 的時間點
afterEach - 子層情境 B
afterEach - 父層情境
afterAll - 子層情境 B
--- 離開子層情境 B 的 Context
afterAll - 父層情境
--- 離開父層情境的 Context
--- 離開測試程式碼本身的 Context
|
因此我們在使用這類 API 時要注意當下 context
所包含的範圍。
避免誤區:在 expect 後做清掃處理
除了上面的用法,有時候你可能會認為既然要清掃,那我何不在斷言後處理就好呢:
1
2
3
4
5
6
7
| describe('', () => {
it('', () => {
expect().toBe()
// 在這裡做清除
resetTestingEnv()
})
})
|
這麼做當你在測試案例都是通過的情況下都沒有問題,但是一但某個測試案例發生了錯誤,由於測試案例就會在斷言時拋出 AssertionError
後停止,因此很有可能因為一個測試案例壞了導致接下來所有測試都受到影響:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| describe('', () => {
it('', () => {
expect().toBe() // AssertionError,這個測試案例就停在這了
resetTestingEnv()
})
it('', () => {
// 在沒有經過 `resetTestingEnv()` 下進行測試
})
it('', () => {
// 在沒有經過 `resetTestingEnv()` 下進行測試
})
it('', () => {
// 在沒有經過 `resetTestingEnv()` 下進行測試
})
it('', () => {
// 在沒有經過 `resetTestingEnv()` 下進行測試
})
})
|
因此較佳的作法還是使用 Setup
& Teardown
API 來處理會比較好:
1
2
3
4
5
6
7
8
9
10
11
12
13
| describe('', () => {
beforeEach('', () => {
setupTestingEnv()
})
afterEach('', () => {
resetTestingEnv()
})
it('', () => {})
it('', () => {})
it('', () => {})
it('', () => {})
it('', () => {})
})
|
避免過度使用 Setup & Teardown API
一名對於測試領域頗有研究的 Kent C. Dodds 在 twitter 上發表:
許多人一看了紛紛表示中槍,心想這不就是我在寫的東西嗎?因此發文一出不少人就好奇那麼到底為什麼上方的用法會比較好呢?且讓我們從抽象光譜介紹起。
抽象光譜(The Spectrum of Abstraction)
Kent C. Dodds 提出在抽象光譜中主要分成了三種概念:
- ANA:Absolutely No Abstraction
- AHA:Avoid Hasty Abstraction
- DRY:Don’t Repeat Yourself
而後並將此概念應用在 Testing 身上並分別解說了三種抽象型態下的測試的優劣分析。
其中我們往往一開始學習可能因為對語法不熟稔,因此可能會寫出 ANA Testing 形式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| describe('', () => {
it('' , () => {
// 準備
// 操作
// 斷言
})
it('' , () => {
// 準備:重複的準備類似的內容
// 操作
// 斷言
})
it('' , () => {
// 準備:重複的準備類似的內容
// 操作
// 斷言
})
})
|
而隨著測試經驗越來越熟稔之後,我們可能會想盡各種方法來「節省」撰寫測試上的時間,甚至參考 DRY 心法寫出這樣的測試:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| describe('', () => {
// 統一的事前準備
const testEnv = new TestEnv()
beforeEach(() => {
testEnv.init()
})
it('' , () => {
// 直接操作事先準備好的內容
// 斷言
})
it('' , () => {
// 直接操作事先準備好的內容
// 斷言
})
})
|
而這樣的下場將會在複雜的巢狀測試情境下越來越難以閱讀與調整:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| describe('', () => {
const testEnv = new TestEnv()
beforeEach(() => {
testEnv.init()
})
describe('', () => {
const testEnv = new TestEnv()
beforeEach(() => {
testEnv.init()
})
// ...
})
describe('', () => {
const testEnv = new TestEnv()
beforeEach(() => {
testEnv.init()
})
// ... 等等 這裡的初始準備有什麼 ???
})
// ...
})
|
而除了巢狀情境本身是個議題之外,Kent C. Dodds 認為我們應該兼容的方式去看待他,也就是說他不排斥我們去做抽象這件事情,但是首先要做的應該是先保持單純,直到我們看到足夠多共同的案例來分析能抽取出的部分,如果不夠多那甚至原先 inline
的測試案例版本也比過多抽象的版本要好得多。
避免過度巢狀情境
最後,共用的部分除非真的有必要透過 setup
& teardown
實作處理,否則共用的部分大多也可以透過諸如工廠模式
(factory pattern
)的形式產生。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| /* 擷取自我某個專案內的元件測試 */
const factory = (
options = {
createSpy: vi.fn,
},
) => {
const wrapper = mount(component, {
global: {
plugins: [createTestingPinia(options)],
},
})
const store = useMainStore()
return { wrapper, store }
}
|
所以往後若有使用到這類 API 時不仿先思考一下我們真的需要嗎,還是有更加優雅的方式能夠解決重複的問題呢?
斷言語法與 Matchers
斷言(Assertion)在程式設計領域中主要指的是「針對一個結果指出為真(true
)或假(false
)」的邏輯判斷式。
而在測試中斷言主要指的部分如先前提到的 3A 模式中的(Assert)步驟:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| describe('', () => {
it('', () => {
// 準備:Arrange
const wrapper = mount(component, {
props: {
content: 'Hello, Unit-test!'
}
})
// 操作:Act
wrapper.find('[data-test="button"]').click()
// 斷言:Assert
expect(wrapper.find('[data-test="content"]')).toBe('Hello, Unit-test!')
})
})
|
其中斷言(Assert)階段中在語法的部分則會使用到所謂的「Matchers」,以上方程式碼為例的話就會是 expect()
後方的 toBe()
這個語法,而不同的「Matchers」能比對的東西也不太一樣,瞭解什麼時候該選什麼 Matchers 基本上寫斷言的時候就能信手捻來。
斷言語法
依據不同工具或框架所提供的斷言語法(Assertion),對於 Matchers 來說就會有不同的寫法,所以首先我們要先來簡單認識一下不同工具之間的斷言語法風格上的差異,挑選能接受的斷言風格後再來看該風格的 Matcher 用法,而風格的部分以下將依序介紹:
- Chai 斷言庫
- Jest 框架中的斷言語法
- Vitest 中所能用的部分
Chai Assertion
Chai.js 本身是一個專注在提供斷言語法的工具庫,它提供了三種寫法:
1
2
3
4
5
| it('Chai/Assert', () => {
const x = 'Orange tabby cat'
const y = 'fat'
assert(x !== y, 'Orange tabby cat is not fat')
})
|
1
2
3
| it('Chai/Expect', () => {
expect([1, 2]).to.be.an('array').that.does.not.include(3)
})
|
Should
(BDD style):透過擴充物件 prototype
給予 should
屬性的方式使我們可以直接鏈式加入 Matchers 在定義好的變數後。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // 選擇一:在斷言前需要呼叫 `chai.should` 方法,
import chai from 'chai'
chai.should()
// 選擇二:直接引入下方
import 'chai/register-should'
// 底下的測試案例就能直接鏈式加上 Matchers
it('Chai/Assert', () => {
const foo = 'bar'
const beverages = { tea: ['chai', 'matcha', 'oolong'] }
foo.should.be.a('string')
foo.should.equal('bar')
foo.should.have.lengthOf(3)
beverages.should.have.property('tea').with.lengthOf(3)
})
|
Jest Assertion
以 Jest 測試框架中所提供的斷言方法則只有:
1
2
3
| it('expect/ BDD style', () => {
expect(1).toBe(1)
})
|
需特別注意的是 Jest 中的 expect
與 Chai 的 expect
所提供的 Matchers 是不一樣的。
Vitest
以 Vitest 測試工具來說,斷言(Assertion)語法的部分他內建了 Chai 斷言庫與兼容了 Jest 中的斷言語法,因此主要就是在以上介紹的四種寫法中選擇一種使用!
而接下來主要著重介紹 Jest Assertion 中的 expect
語法的 Matchers 要如何使用!
Vitest(Jest expect Matchers)
要學習這類 Matchers 除了把 API doc 翻一輪之外,最快的方式之一就是針對不同的測試結果目標類型去歸納,而依照經驗常見種類有:
- 常用:toBe, not
- 純值比對(Primitive)類型:String, Number, Boolean…, etc.
- 陣列比對與檢查
- 物件比對與檢查
- 監聽函式
- 快照
- Error
由於 Matchers 繁多,所以從常用跟概念容易搞混的幾個開始先介紹起:
常用
- toBe:對資料類型為純值(Primitive Value)來說就是比對值相等
1
2
| expect(1).toBe(1) // passed
expect('1').toBe(1) // failed
|
對非純值(Non-Primitive Value)來說就是比記憶體位置(reference)是否相等
1
2
3
4
5
6
7
8
9
| const obj = {}
const obj2 = obj
expect(obj).toBe(obj2) // passed
expect({}).toBe({}) // failed
// `not`:用於反轉斷言的邏輯
expect(1).toBe(1) // passed
expect(1).not.toBe(1) // failed
expect(1).not.toBe(2) // passed
|
純值資料類型比對
1
2
| expect(0.1 + 0.2).toBe(0.3) // failed 符點溢出,結果應該會為 0.30000000000000004
expect(0.1 + 0.2).toBeCloseTo(0.3) // passed
|
- toBeGreaterThan、toBeGreaterThanOrEqual、toBeLessThan、toBeLessThanOrEqual
1
2
3
4
| expect(5).toBeGreaterThan(1) // passed
expect(5).toBeGreaterThanOrEqual(5) // passed
expect(6).toBeLessThan(7) // passed
expect(6).toBeLessThanOrEqual(6) // passed
|
- toBeDefined、toBeUndefined
1
2
3
4
| var a = ''
var b
expect(a).toBeDefined() // passed
expect(b).toBeUndefined() // passed
|
1
2
| expect(1).toBeTruthy() // passed
expect(0).toBeFalsy() // passed
|
1
| expect(null).toBeNull() // passed
|
1
| expect('0912345678').toMatch(/^09[0-9]{8}$/) // passed
|
陣列比對與檢查
1
2
| expect(['1', '2']).toContain('1') // passed
expect(['1', '2']).toContain('4') // failed
|
- toContainEqual: 陣列是否含有該值(類型為純值時檢驗是否相等,類型為物件時檢驗結構是否全等)
1
2
3
4
5
6
| // passed
expect(['1', '2']).toContainEqual('1') // passed
expect([{ val: '1' }, { val: '2' }]).toContainEqual({ val: '1' })
// failed
expect([{ val: '1', something: 'other' }]).toContainEqual({ val: '1' })
|
- toHaveLength:確認其屬性的長度是否相等
1
2
3
| expect('12').toHaveLength(2) // passed
expect([1, 2]).toHaveLength(2) // passed
expect({ length: 2 }).toHaveLength(2) // passed
|
物件比對與檢查
- toEqual:比對物件結構是否相同,而非比對參照來源(reference),而結構中若值為 undefined 會忽略
1
2
3
| const A = { num: 100 }
const B = { num: 100, secret: undefined }
expect(A).toEqual(B) // passed
|
- toStrictEqual:與 toEqual 類似,但 undefined 不會被忽略
1
2
3
4
5
| const A = { num: 100 }
const B = { num: 100 }
const C = { num: 100, secret: undefined }
expect(A).toStrictEqual(B) // passed
expect(A).toStrictEqual(C) // failed
|
甚至 Class 所創造的物件與物件實字(Object Literals)相比也視為不同。
1
2
3
4
5
6
7
| class MockClass {
constructor(num) {
this.num = num
}
}
expect({num: 1}).toStrictEqual({num: 1}) // passed
expect(new MockClass(1)).toStrictEqual({num: 1}) // failed
|
- toHaveProperty:檢查物件含有屬性與其屬性值
1
2
3
4
5
| const obj = { num: 100 }
expect(obj).toHaveProperty('num') // passed
expect(obj).toHaveProperty('num', 100) // passed
expect(obj).toHaveProperty('num', 200) // failed
|
1
2
3
4
5
6
| const obj = { nested: { num: 200 }, num: 100 }
expect(obj).toMatchObject({ num: 100 }) // passed
expect(obj).toMatchObject({ num: 200 }) // failed
expect(obj).toMatchObject({ nested: { num: 100 } }) // failed
expect(obj).toMatchObject({ nested: { num: 200 } }) // passed
|
繼續講解下列幾個比較特別的 Matcher
監聽函式
在測試的過程中,有時候我們不僅只是斷言受測物(SUT, System Under Test)的狀態,有時候可能會對受測目標的「依賴物」(DOC, Depended-on Component)狀態有興趣,而這時我們就無法單純以 Matchers 來斷言,因為我們需要監聽依賴物前後的變化。
而在程式測試領域中,測試替身(test double
)主要就是負責處理這一類非受測物本身所做的事情,並且在需要時還能幫我們紀錄必要的資訊。
假若我們今天測試案例受測物本身會去呼叫到的依賴目標是個「函式」時,這時我們可以透過 Vitest
所提供的 vi.fn()
來模仿(spy)函式。
vi.fn()
本身會回傳一個實體(CallableMockInstance
),在這個實體中會記錄著有關測試函式時會需要的資料與方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| console.log(vi.fn())
/*
called: false,
callCount: 0,
results: [],
calls: [],
impl: [Function (anonymous)],
reset: [Function: i],
nextError: [Function (anonymous)],
nextResult: [Function (anonymous)],
restore: [Function: w],
...
*/
|
因此將受測的目標函式放入 vi.fn
中,後續只要測試過程中如果測試目標去調用了已被 Spy 過的函式時,CallableMockInstance
就會幫我們紀錄相關的資訊,接著我們就可以使用斷言語法相關的 Matcher 去比對我們預期的結果,比方說:
- toHaveBeenCalled:斷言函式有被呼叫過
1
2
3
4
5
| const sayHi = (something) => something
const spyOnSayHi = vi.fn(sayHi)
spyOnSayHi()
expect(spyOnSayHi).toHaveBeenCalled()
|
- toHaveBeenCalledTimes:斷言函式被呼叫過幾次
1
2
3
4
5
6
7
8
| const sayHi = (something) => something
const spyOnSayHi = vi.fn(sayHi)
spyOnSayHi()
spyOnSayHi()
spyOnSayHi()
expect(spyOnSayHi).toHaveBeenCalledTimes(3)
|
- toHaveBeenCalledWith:斷言函式被呼叫時所帶的參數
1
2
3
4
5
6
| const sayHi = (something) => something
const spyOnSayHi = vi.fn(sayHi)
spyOnSayHi('Hello, Unit-Test!')
expect(spyOnSayHi).toHaveBeenCalledWith('Hello, Unit-Test!')
|
- toHaveReturned:斷言函式呼叫後應該至少要返回值一次
1
2
3
4
5
6
| const sayHi = (something) => something + ' Hello, Spy!'
const spyOnSayHi = vi.fn((val) => sayHi(val))
spyOnSayHi('Hello, Unit-Test!')
expect(spyOnSayHi).toHaveReturned()
|
- toHaveReturnedTimes:斷言函式經過操作後應該要返回值幾次
1
2
3
4
5
6
7
| const sayHi = (something) => something
const spyOnSayHi = vi.fn((val) => sayHi(val))
spyOnSayHi('Nice to meet you!')
spyOnSayHi('See you again!')
expect(spyOnSayHi).toHaveReturnedTimes(2)
|
- toHaveLastReturnedWith:斷言函式經過操作後最後應該要返回的值
1
2
3
4
5
6
7
| const sayHi = (something) => something
const spyOnSayHi = vi.fn((val) => sayHi(val))
spyOnSayHi('Nice to meet you!')
spyOnSayHi('See you again!')
expect(spyOnSayHi).toHaveLastReturnedWith('See you again!')
|
- toHaveNthReturnedWith:斷言函式經過操作後第 N 次應該要返回的值
1
2
3
4
5
6
7
| const sayHi = (something) => something
const spyOnSayHi = vi.fn((val) => sayHi(val))
spyOnSayHi('Nice to meet you!')
spyOnSayHi('See you again!')
expect(spyOnSayHi).toHaveNthReturnedWith(1, 'Nice to meet you!')
|
- toHaveReturnedWith:斷言函式呼叫後返回的值
1
2
3
4
5
6
| const sayHi = (something) => something + ' Hello, Spy!'
const spyOnSayHi = vi.fn((val) => sayHi(val))
spyOnSayHi('Hello, Unit-Test!')
expect(spyOnSayHi).toHaveReturnedWith('Hello, Unit-Test! Hello, Spy!')
|
而測試替身(test double)除了像是 vi.fn() 這類間諜類型(Spy)之外,還有許多不同的測試替身,它們在測試中都有各自的用途來協助我們更好的測試。
快照測試(Snapshot Testing)與快照 matchers
Jest Snapshot
在 Jest 的 Snapshot Testing 說明文件的定義中主要指的是用來防止 UI 出現尚未預期的變化:
1
2
3
| <template>
<a data-test="link" href="http://ithelp.ithome.com.tw"> Ithelp </a>
</template>
|
1
2
3
4
5
| import { mount } from '@vue/test-utils'
it('渲染連結', () => {
const wrapper = mount(component)
expect(wrapper.find([data-test="link"])).toMatchSnapshot();
});
|
然而這邊的變化,並非你所想像的把視覺畫面給照相下來比對像素或比例的那種視覺回歸測試(Visual Regression Testing),而是藉由將目標元件透過渲染(Render)元件產生了一個 DOM 結構的文字,並在測試程式檔路徑底下的 __snapshots__
資料夾生成一個 .snap
檔案來做紀錄:
1
2
3
4
5
6
7
8
| exports[`渲染連結`] = `
<a
data-test="link"
href="http://ithelp.ithome.com.tw"
>
Ithelp
</a>
`;
|
截至 Jest Snapshot 頁面中的程式碼
在第二次執行測試的時候,就會再次做一次同樣的流程,而這次所產生的結果會與先前的 .snap
紀錄做比對。
假設比對上有落差就會拋出錯誤:
1
2
3
4
5
6
7
8
| - Snapshot - 1
+ Received + 1
<a
- href="http://ithelp.ithome.com.tw"
+ href="https://ithelp.ithome.com.tw"
> Ithelp
</a>
|
藉由這個比對機制從而實現「防止 UI 出現尚未預期的變化」的功能。
Vitest Snapshot
而前面有提到 Vitest
本身兼容了 Jest
的斷言(assertion)語法,所以快照(Snapshot)的 matcher 自然也是不能放過。
在 Vitest
中主要兼容的 matcher 部分有:
- toMatchSnapshot
- toMatchInlineSnapshot
基本上概念與 Jest Snapshot 相似,都是在做結構快照這件事情,然而 Vitest
文件部分則是沒有特地強調 UI 的部分,而是關注在值(value
)的比對。
因此我們單純放入一個陣列物件
1
2
3
4
5
6
7
8
9
10
11
12
13
| it('cat snapshot', () => {
const target = [
{
name: 'Orange',
age: 4,
},
{
name: 'Blank',
age: 6,
},
]
expect(target).toMatchSnapshot()
})
|
.snap
的結果:
1
2
3
4
5
6
7
8
9
10
11
12
| exports[`component > cat snapshot 1`] = `
[
{
"age": 4,
"name": "Orange",
},
{
"age": 6,
"name": "Blank",
},
]
`;
|
甚至也可以引入 JSON 檔案來做快照:
1
2
3
4
5
| import Area from './area.json'
it('static json snapshot', () => {
expect(Area).toMatchSnapshot()
})
|
當然元件快照也是能做的:
1
2
3
4
5
6
7
| import { mount } from '@vue/test-utils'
it('snapshot', () => {
const wrapper = mount(component)
const target = wrapper.find('[data-test="content"]')
expect(target).toMatchSnapshot()
})
|
而以上快照部分如果你覺得要生成一個檔案來管理有點囉唆,那麼你可以透過 toMatchInlineSnapshot
來處理這類的需求,其差別在於生成的位置會是在 toMatchInlineSnapshot()
函式本身裡面:
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
| it('cat snapshot', () => {
const target = [
{
name: 'Orange',
age: 4,
},
{
name: 'Blank',
age: 6,
},
]
// 原先 toMatchInlineSnapshot 內沒有任何東西
// 在執行測試後就會將快照內容自動生成在 toMatchInlineSnapshot 裡
expect(target).toMatchInlineSnapshot(`
[
{
"age": 4,
"name": "Orange",
},
{
"age": 6,
"name": "Blank",
},
]
`)
})
|
最後,如果遇到變更的部分是我們所預期的時候,此時就需要更新快照的部分,可以在執行測試的 watch
模式時按下 u
鍵,就將新的快照保存起來,或是透過新增 npm scripts
指令來執行命令:
1
2
3
4
5
| {
"scripts": {
"test:update": "vitest -u",
},
}
|
Error
最後介紹到的則是錯誤類型的 Matcher:
- toThrowError
- toThrowErrorMatchingSnapshot
- toThrowErrorMatchingInlineSnapshot
先前我們有提到測試案例預想的三個路徑(Happy path、sad Path、Bad Path),Bad Path
從產品角度上來說 End-User 使用上的錯誤,應該考慮從產品使用角度上去思考要怎麼協助他們去使用,所以不應該為了拋出錯誤而拋出。
但假設今天拋出錯誤的情境是較為合理的部分,比方是針對開發人員在開發時期誤用導致的錯誤⋯⋯等等情況,這時我們就可以透過這一類 Matcher 來處理。
而使用上要比較小心的是,受測目標若會拋出錯誤則要透過 wrap function 的形式來處理,否則拋出的錯誤會造成測試案例的斷言錯誤:
1
2
3
4
5
6
7
8
9
10
| it('', ()=>{
const food = (name) => {
// ...
if(name === '小黃瓜'){
throw new Error('我不吃小黃瓜')
}
}
expect(food('小黃瓜')).toThrowError('我不吃小黃瓜') // 若這樣寫的話裡頭的 Error 會導致測試案例失敗
expect(() => food('小黃瓜')).toThrowError('我不吃小黃瓜') // 需要透過這種方式才能正確斷言
})
|
元件測試
Vue Test Utils 與元件測試
接著,若我們想進一步在專案中測試 Vue 元件的話,我們除了基於前面的概念與語法之外,還得依靠能夠幫助能夠解析 Vue 元件並與其模擬互動的工具:
- Vue Test Utils
- Cypress
- Vue Testing Library
而本篇將著重在介紹要怎麼透過 Vue Test Utils 來進行與 Vue 元件有關的「元件測試」,因此本文要來介紹一下 Vue Test Utils 與元件測試是在做什麼,又這又跟單元測試有什麼關係呢。
Vue-Test-Utils
首先區辨一下他與我們目前用到的測試工具的各自用處,才能更瞭解接下學的內容是由哪個工具負責的,將來有必要查閱資料時就能更清楚的區別他們。
Vue Test Utils 主要是作為提供測試 Vue 元件的相關工具集,讓我們能更輕易的模擬操作元件來進行測試,但本身並沒有運行測試環境(test runner)的功能,因此我們會需要藉由 Vitest 來作為測試環境以及相關的測試情境案例與斷言 Matcher;除此之外我們還需要用來在 Node 環境中模擬瀏覽器環境的 jsdom
來協助處理有關 DOM 上的操作,如此一來才能順利地使用 Vue Test Utils。
而版本的部分要特別注意到,由於 Vue Test Utils 為了相容 Vue 不同版本之間的 API 因此我們要安裝對應的版本未來才不會出錯:
確認版本沒問題後,模擬元件的部分交給 Vue Test Utils 基本上就沒問題了,但關於「元件測試」目標主要是要測試什麼東西呢?
元件測試的目標
測試本質主要是在預期結果與實際是否相等,而在元件測試中作為受測物的元件,其本身主要是負責 UI 上的一切內容,因此我們測試目標在著重於它的「畫面」與「行為」上是否如我們所預期。
因此,在測試案例的操作過程中(也就是 3A 中的 Action 階段),我們主要是藉由操作對元件介面中的:
- data
- props
- slot
- provide
- directive
- Event(瀏覽器中的互動行為)
- API response(模擬回應)
來判斷下列是否如我們所預期:
好的元件測試
當然,做到上面的事項基本上已經可以算是個元件測試了,若我們想讓元件測試寫得更好,除了先前在單元測試提過的一些概念之外,還有一些值得注意的事項:
首先,在撰寫元件測試時的角度,我們並沒有要當個全能全知的神,而是作為「使用者的角度」關注元件介面上來預期結果,所以在測試的過程中我們要「避免又寫了一次實作」。
另外,如果內部邏輯過於複雜時,我們則應該先抽取(extract)其邏輯,透過 composables 的寫法來引入,如此一來我們就能「專注於在元件本身的行為上」,另外也能「針對 composable 的邏輯單獨做測試」。
最後,若元件中有使用到 API 的部分,同樣的我們應該把它當作成是他提供給元件去使用,而元件主要是「接收回應」後就能自行處理後續的內容了,所以在測試上我們應該專注在 API 提供了什麼給元件,透過模擬資料的部分來達成先前提過的 FIRST 原則中的 Fast,最後斷言元件最終的行為是否如預期即可。
現在我們知道 Vue Test Utils 在做什麼了,也知道「元件測試」在做什麼了,但這跟單元測試又有什麼關係呢?
元件測試與單元測試的關係
在官方文件中測試章節有提到,元件測試(component testing)在顆粒度上其實是高於單元測試(unit testing)的,甚至可以被視為是整合測試(integration testing)的一種形式。
而坊間有不少文章習慣以單元測試來統稱這一類測試,最主要原因是單元測試的定義為「軟體設計」中最小單位的程式或行為,在 Vue 專案中,若以 Vue SFC 類型檔案(.vue)來看其實測試元件也算是最小單位沒錯。
所以從意義上來劃分單元測試與元件測試,某方面來說很容易出現歧異(甚至單元測試本身就已經有分為獨立型與社交型寫法了)。
然而為了接下來在本系列文中方便區分指的測試對象是哪部分,我將名稱含義分為:
- 元件測試:針對 Vue 元件所進行的測試
- 單元測試:針對元件引入函式、類別等 utils、helper 與 composable 的測試。
- 工具本身:Pinia 測試、Vue Router 測試。
元件測試:容器(Wrapper)
mount
當我們需要在測試案例中引入元件時,我們可以透過 Vue Test Utils 提供的方法 mount
來包裹 Vue 元件。而 mount
所返回的內容除了 Vue 實體之外,還包含了一些方法(Wrapper methods)可以讓我們操作元件:
1
2
3
4
5
6
7
8
9
10
11
12
| import component from '@/component/BaseButton.vue'
it('should emit clicked event after clicking button', () => {
// Arrange
const wrapper = mount(component)
// Act
wrapper.trigger('click')
// Assert
export(wrapper.emitted()).toHaveProperty('clicked')
})
|
假若我們希望在渲染元件的時候同時帶著預設參數與狀態時(如 props
、slots
…等),就能夠透過 mount
方法的第二個參數傳入設定:
1
2
3
4
5
6
7
| const options = {
props: {
content: 'Hello, Props!'
}
}
const wrapper = mounted(component, options)
expect(wrapper.text()).toBe('Hello, Props!')
|
而關於方法(Wrapper methods)與選項(Wrapper options)的部分,會根據元件使用 Options API / Composition API / setup script 語法糖 而有不同的用法與限制,因此使用方法與差異的部分將在後續測試各種元件時陸續會提到。
shallowMount
在正常的專案開發下,元件時常會嵌入了另一個元件:
RootComponent.vue
1
| <ParentComponent></ParentComponent>
|
ParentComponent.vue
1
2
3
| <ChildComponent></ChildComponent>
<ChildComponent></ChildComponent>
<ChildComponent></ChildComponent>
|
ChildComponent.vue
RootComponent 的元件測試:
1
2
3
4
5
6
7
8
9
| import component from './RootComponent.vue'
it('should render corrent content', async () => {
const wrapper = mount(component)
expect(wrapper.html()).toBe(`
<p>baby</p>
<p>baby</p>
<p>baby</p>
`)
})
|
但如此一來若我們在測試 RootComponent 元件的時候,就有可能因為底下的 ChildComponent 元件更改了內容導致 RootComponent 測試案例也跟著失敗:
若「有意」想要避免這種情況發生,此時就可以將 mount
替換掉,改使用 shallowMount
來作為容器使用:
1
2
3
4
5
| import component from './RootComponent.vue'
it('should render corrent content', async () => {
const wrapper = shallowMount(component)
// ...
})
|
shallowMount
與 mount
在使用上幾乎大同小異,而唯一有差別在於他會將原先內部有用到元件的部分以替換為一個模擬元件(stub component),並且命名改以烤肉串命名法(Kebab case
)並且在最末端加上 -stub
供辨識:
RootComponent.vue
1
| <ParentComponent></ParentComponent>
|
經 shallowMount 渲染結果變成:
RootComponent.vue
1
| <parent-component-stub></parent-component-stub>
|
如此一來在撰寫測試案例時,就可以關注在當下「父層元件」與「子層元件」的之間的內容,而不管「子層元件」底下所發生的事情:
1
2
3
4
5
6
| import component from './RootComponent.vue'
it('should render corrent content', async () => {
const wrapper = shallowMount(component)
// ...
expect(wrapper.html()).toBe(`<parent-component-stub></parent-component-stub>`)
})
|
容器的方法(Wrapper methods) - 選擇器
在進行測試時,前面章節有提到我們會使用 mount
或 shallowMount
來包裹元件,從而得到一個 VueWrapper
,而在這個 VueWrapper
裡頭有許多實用的容器方法(Wrapper methods),雖然官方文件中並沒有特意分類,但大意上可分為幾種方法:
- 查詢、選擇指定的元素、元件等選擇器
- 取得目標屬性或內容(class, attribute)
- 觸發 DOM 事件(如滑鼠點擊、鍵盤輸入與按鍵⋯⋯等)
- 取得 emit 事件、設置 data 或 prop、甚至觸發元件
unmmount
等 Vue API 相關的方法
這邊著重在於介紹選擇器的方法使用與測試應用,最後補上相關的討論:
- 元素、元件選擇器
- 判斷目標是否存在:
exists
與 isVisible
- 使用
data-* attribute
選擇器
在進行元件測試(component testing)的過程中,有時我們可能只想關注在某個特定 DOM 或元件的相關資訊時,這時我們就可以透過容器中的選擇器方法來尋找,而選擇器根據選取對象的不同,主要分為:
- 元素(element)選擇器
- 元件(component)選擇器
元素選擇器
元素選擇器語法基本上有兩種寫法,一種是基於元素的 refs
,另一種則是 querySelector
:
1
2
3
4
5
6
| <template>
<p ref="dogcat">???</p>
<p id="dog">科基</p>
<p class="cat">橘貓</p>
<p class="cat">黑黑</p>
</template>
|
1
2
3
4
5
6
| /* ref */
wrapper.find({ ref: 'span' })
/* querySelector */
wrapper.find('#dog')
wrapper.find('.cat')
|
而容器方法有關選取元素的選擇器一共分為三種:
這三種選擇器在選取到目標後主要都是返回 DOMWrapper
,此時我們就可以在串連其他容器方法(Wrapper methods)如 classes
或 text
等等來取得屬性或內容資訊,只是返回的結果內容會有些差異。
比方 find
、findAll
的部分主要差別在於 findAll
返回的內容會被放置於陣列當中:
1
2
3
4
5
| <template>
<p id="dog">科基</p>
<p class="cat">橘貓</p>
<p class="cat">黑黑</p>
</template>
|
而我們可以透過像這樣的方式取得陣列內的資訊:
1
2
3
4
5
6
7
8
9
10
11
12
| it('should be display correct content', () => {
const wrapper = mount(component)
const target= wrapper.find('#dog') // <DOMWrapper>
expect(target.text()).toBe('科基')
})
it('should be display correct content', () => {
const wrapper = mount(component)
const target= wrapper.findAll('.cat') // <DOMWrapper>[]
expect(target[0].text()).toBe('橘貓')
expect(target[1].text()).toBe('黑黑')
})
|
注意:透過 findAll 斷言時是「有序」的,很容易受到順序改變而導致測試案例失敗,除非撰寫測試案例目標之一就是要確保順序不會調動,否則改用 find 斷言會比較不容易受影響。
而 find
與 get
的差別在於 find
找不到目標時返回的是 {}
後續若仍繼續操作、斷言時才會由拋出錯誤(由 ErrorWrapper
提供);然而 get
一開始若找不到目標時就直接拋出錯誤(throw Error
)了。
根據 ErrorWrapper 原始碼,可以看見他主要是針對容器方法中的 exists()
會返回 false
。
因此在針對找不到元素的測試案例合法的寫法可以這麼做:
1
2
| expect(() => wrapper.get('.something-that-does-not-exist')).toThrowError()
expect(wrapper.find('.something-that-does-not-exist').exists()).toBeFalsy()
|
find 判斷元素陷阱
魔鬼藏在細節裡,或許你可能會想說為什麼不直接用 find
還要再另外透過 exists
判斷呢,讓我們看看一個案例:
如果這時你這麼寫了:
1
2
| expect(wrapper.find('.something-that-does-not-exist')).toBeTruthy()
// 通過 !?
|
原因在於 find
在找不到的情況下目標的情況所返回的值會是 ErrorWrapper
物件,所以透過 toBeTruthy
斷言就會通過。
而為了避免這種情況發生,官方文件其實只有輕描淡述地說道:
As a rule of thumb, always use get except when you are asserting something doesn’t exist. In that case use find.
但上方經驗考量在哪,只有在追了他們相關的 issue 才會逐漸明白考量的根據,而關於這部分,甚至開發團隊未來可能也會考慮將 find 方法給拔掉,剩下 exists 本身,但礙於這會是個 breaking change 的做法,所以在 vue-test-utils 跳大版本號之前應該都會暫時維持原樣。
元件(component)選擇器
- findComponent
- findAllfindComponents (注意有個 s)
- getComponent
原則上使用方式與元素選擇器差不多,只是選取語法上除了 refs
與 querySelector
語法之外,還多了:
- Component name:
findComponent({name: '元件名稱'})
- 將 import SFC 直接放入方法中:
findComponent(Component)
但由於使用 querySelector
上也有一些小陷阱,所以個人建議以 SFC 方式引入或是乾脆透過 shallowMount
將子層元件 stub 掉也是一種方式。
判斷目標存在
經由剛剛陷阱的部分大家應該都很清楚 exists()
的存在了,而判斷選取目標其實他還有個好朋友就是 isVisible()
,但他們判斷存在的定義上有一些差別:
- exists():主要判斷的是該目標存不存在 DOM 上:
1
2
3
4
5
| it(exist, () => {
const wrapper = mount(Component)
expect(wrapper.find('p').exists()).toBe(true)
expect(wrapper.find('span').exists()).toBe(false)
})
|
- isVisable():主要判斷的是該目標存在 DOM 上之外,視覺上有無顯示在畫面中:
1
2
3
4
5
| <template>
<p v-if="false" class="dog">科基</p>
<p v-show="true" class="orange-cat">橘貓</p>
<p v-show="false" class="black-cat">黑黑</p>
</template>
|
1
2
3
4
5
6
7
8
9
10
11
12
| it('v-if false', () => {
const wrapper = mount(component)
expect(wrapper.find('.dog').isVisible()).toBeFalsy() // Error: 直接噴錯
})
it('v-show true', () => {
const wrapper = mount(component)
expect(wrapper.find('.orange-cat').isVisible()).toBeTruthy() // 測試通過
})
it('v-show false', () => {
const wrapper = mount(component)
expect(wrapper.find('.black-cat').isVisible()).toBeFalsy() // 測試通過
})
|
關於 isVibile
更為詳細的判斷如下:
- CSS style 中 含有 display: none =>
false
- CSS style 中 含有 visibility: hidden =>
false
- CSS style 中 含有 opacity :0 =>
false
- 元素中 hidden 屬性為 true =>
false
綜合結論上方陷阱與判斷方法:
- 若要判斷元素是否存在 或
v-if
:使用 get
方法是最保險的,真的要用 find
則一定要搭配 exists
- 若要判斷
v-show
:使用 find().isVisible()
使用 data-* attribute
在撰寫測試情境時,若依照上面的 querySelector
選了元素、id 或 class 時,初期一定會很開心,因為不會遇到太多困難,但往後在開發的過程執行測試時就很容易有機會遇到各種問題。
原先:
1
2
3
| <template>
<p class="content">content</p>
</template>
|
改成:
1
2
3
| <template>
<p class="content" data-test="content">content</p>
</template>
|
使用 data-*
作為選擇目標好處最主要在於顯著標記測試內容,而這影響到的範圍有:
- 開發期,過程不用擔心會影響到測試,如上方所見,我只要變更 data-test 以外的內容,預期應該不會影響到測試選擇的目標,使測試案例錯誤能更專注在斷言的目標上,而非選擇目標被替換導致的錯誤。
- 重構(refactor)過程,能清楚比較範圍,若將來調整結構,只要將屬性轉移到對應的位置即可。
- 生產期,能夠針對特定的屬性移除,避免留下各種測試痕跡,對於像是輔助閱讀裝置等技術來說就不會被影響到。
在生產環境刪除 data-* attribute
若想在 vitest
中移除 data-*
也非常的簡單,我們只需要在 vite.config.js
設定中,針對 Vue 底下的編譯選項做一些調整即可(底下示範的版本為移除 data-test
,若使用其他命名請自行調整囉):
vite.config.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
| const isProd = process.env.NODE_ENV === 'production'
const removeDataTestAttrs = (node) => {
const NodeTypes = Object.freeze({
ELEMENT: 1,
ATTRIBUTE: 6,
})
if (node.type === NodeTypes['ELEMENT']) {
node.props = node.props.filter((prop) => (prop.type === NodeTypes['ATTRIBUTE'] ? prop.name !== 'data-test' : true)) // 請自行替換命名 data-test
}
}
export default defineConfig(() => {
return {
plugins: [
vue({
template: {
compilerOptions: {
nodeTransforms: isProd ? [removeDataTestAttrs] : [],
},
},
}),
]
}
})
|
容器的方法(Wrapper methods) - 取得目標資訊
判斷屬性
attributes
當你想要確認 <a>
中的 href
是否帶有指定的連結或是檢查 <img>
上的屬性時,這時就可以透過 attributes
取得屬性。
而 attributes
用法主要有兩種,直接使用時會獲取目標:
1
| <a data-test="link" href="https://ithelp.ithome.com.tw/" target="_blank">ithelp</a>
|
1
| wrapper.find('[data-test="link"]').attributes() // { href: 'https://ithelp.ithome.com.tw/', 'target': '_blank' }
|
當想要直接取得特定屬性值時也可以直接將屬性名稱帶入參數中:
1
| wrapper.find('a').attributes('target') // '_blank'
|
classes
同樣的,若想要尋找 class
屬性的話可以透過 classes
語法取得資訊。你可能會直覺地想到 class
也是屬性的一種那應該用 attributes('class')
查詢,然而這兩者的差別最主要在於產出的結果會有所不同。
1
2
3
| <section data-test="wrap" class="container w-full h-full">
<p>Title</p>
</section>
|
若使用 attributes('class')
查詢結果將會是字串形式的資料,後續要處理比對上的問題會稍微麻煩一點:
1
| wrapper.find('[data-test="wrap"]').attributes('class') // "container w-full h-full"
|
若是使用 classes
語法查詢的話將得到一個陣列的結果:
1
| wrapper.find('[data-test="wrap"]').classes() // ["container", "w-full", "h-full"]
|
此時就可以配合 toContain
等陣列的斷言 Matcher 快速處理測試案例:
1
| expect(wrapper.find('[data-test="wrap"]').classes()).toContain('container')
|
這對於測試樣式採用原子化設計的工具(Tailwind CSS、Windi CSS 等)有奇效,我們可以將視覺層的邏輯透過 classes
的結果斷言。
因為這類原子化設計工具往往依賴 class
屬性來做樣式上的調整與擴充,通常為了美觀與管理方便會順便在使用這類工具時加裝自動排序(sorting) class
屬性等擴充工具,因此 class
的順序將變成不穩定的狀態。
因此元素若從:
1
| <p data-test="content" class="A B C">content</p>
|
變成:
1
| <p data-test="content" class="A C B">content</p>
|
對於快照測試等 Matcher (toMatchSnapshot
、toMatchInlineSnapshot
)來說將會顯示測試失敗:
1
| expect(wrapper.find('.content')).toMatchSnapshot()
|
快照比對結果:
1
2
| - class="A B C"
+ class="A C B"
|
而以陣列比對是否「包含」的方式,將不受影響:
1
2
3
| expect(wrapper.find('.content').classes()).toContain('A')
expect(wrapper.find('.content').classes()).toContain('B')
expect(wrapper.find('.content').classes()).toContain('C')
|
判斷內容物
text
text
主要是取得目標元素節點後代的所有文字:
RootComponent.vue
1
2
3
4
5
6
| <div data-test="target">
Root
<child-component />
<child-component />
<child-component />
</div>
|
ChildComponent.vue
使用 mount
包裝元件時 wrapper.find('[data-test="target"]').text()
的結果將會是:
使用 shallowMount
包裝元件時 wrapper.find('[data-test="target"]').text()
的結果將會是:
html
與 text
類似,但 html
會將目標元素後代所有元素都記錄下來:
RootComponent.vue
1
2
3
4
5
6
| <div data-test="target">
Root
<child-component />
<child-component />
<child-component />
</div>
|
ChildComponent.vue
使用 mount
包裝元件時 wrapper.find('[data-test="target"]').html()
的結果將會是:
1
2
3
4
| Root
<p>child</p>
<p>child</p>
<p>child</p>
|
使用 shallowMount
包裝元件時 wrapper.find('[data-test="target"]').html()
的結果將會是:
1
2
3
4
| Root
<child-component-stub></child-component-stub>
<child-component-stub></child-component-stub>
<child-component-stub></child-component-stub>
|
因此在使用 text
與 html
來進行斷言時,要注意到「測試所需包含的範圍」以及「元件容器」應採用 mount
還是 shallowMount
等議題,才能讓測試更加強韌且不會讓後續開發無相關的內容時一直被測試攔住。
容器的方法(Wrapper methods) - 模擬事件
滑鼠事件
常見的 DOM 事件有下列幾種:
- 滑鼠事件:點擊(click)
- 鍵盤事件:按下某鍵(keydown)、鬆開某鍵(keyup)
- 表單事件:針對
<input>
輸入內容、checkbox
與 radio
勾選或 <select>
中的選擇內容
首先,語法方面除了表單事件之外,要模擬大部分的事件我們可以透過容器方法中的 trigger
方法即可觸發事件:
1
| wrapper.trigger(event, options)
|
用法是在第一個參數中傳入要觸發的 DOM
事件名稱(e.g. click
、drag
),如果有需要補充觸發事件的條件,比方像是按下哪個鍵盤,就可以透過第二個參數帶入資訊(e.g. { keyCode: 65 }
)。
而要注意到的是由於這些事件基本上都會是非同步的用法,所以在撰寫測試案例時可以使用 async/await
來讓斷言(Assertion)保持正確的結果:
1
2
3
4
5
6
7
8
9
10
| it('...', async () => {
// Arrange
const wrapper = mount(component)
// Act
await wrapper.trigger(event, options)
// Assert
expect(/* ... */).toBe(/* ... */)
})
|
有了這個概念之後,接下來便可以快速來看看實際上各個事件觸發的實際案例與事件名稱,並且著重關注要特別注意的地方。
滑鼠事件
- 點擊:
click
- 雙擊:
dblclick
- 指定點擊:
click.left
、click.middle
、click.right
點擊與雙擊
點擊與雙擊的部分最主要需要注意的部分是,以事件來說的概念「雙擊(dblclick)」是一個事件,並非「兩個」「點擊(click)」事件:
元件
1
2
3
4
5
6
7
8
| <script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<p data-test="content">{{ count }}</p>
<button data-test="button" @click="count++">Add</button>
</template>
|
測試程式碼
1
2
3
4
5
6
7
8
9
10
| it('after click button should display correct content', async () => {
const wrapper = mount(component)
await wrapper.find('[data-test="button"]').trigger('click')
expect(wrapper.find('[data-test="content"]').text()).toBe('1')
await wrapper.find('[data-test="button"]').trigger('click')
expect(wrapper.find('[data-test="content"]').text()).toBe('2')
await wrapper.find('[data-test="button"]').trigger('dblclick') // 觸發不到 @click 事件
expect(wrapper.find('[data-test="content"]').text()).toBe('4') // AssertionError
})
|
指定按鍵點擊
有時候你可以會想要指定滑鼠的按鍵來觸發事件,這時就可以使用類似修飾符(modifier)一樣的方式來觸發事件:
click.left
click.middle
click.right
元件
1
2
3
4
5
6
7
8
| <script>
const result = ref('')
</script>
<template>
<button data-test="button" @click.right="result = 'here we go!'">Are You ready?</button>
<p data-test="target">{{ result }}</p>
</template>
|
測試程式碼
1
2
3
4
5
| it('should be display correct text', async () => {
const wrapper = mount(component)
await wrapper.find('[data-test="button"]').trigger('click.right')
expect(wrapper.find('[data-test="target"]').text()).toBe('here we go!')
})
|
除此之外,鍵盤與滑鼠的操作也可以融合再一起:
1
2
| trigger('click.ctrl.left') // 左鍵點擊時同時按著 ctrl 鍵
trigger('click.alt.right') // 右鍵點擊時同時按著 alt 鍵
|
鍵盤事件
鍵盤最常見的核心事件主要為:
- 按下某個按鈕
keydown
- 鬆開某個按鈕
keyup
上述事件如同指定滑鼠按鍵的寫法,一樣擁有修飾符的相關語法,而後方帶的修飾符主要為按鍵的名稱:
1
2
| trigger('keydown.enter') // 按下 enter 鍵
trigger('keyup.up') // 鬆開 上方向鍵
|
同樣地,鍵盤事件也支援多重的組合修飾符寫法:
1
| trigger('keydown.ctrl.tab') // 按下 ctrl 鍵 + tab 鍵
|
若你想要更靈活的使用鍵盤事件,也可以用 trigger
方法的第二個參數帶入鍵盤的名稱,而目前有支援的寫法主要有下列三種:
1
2
3
4
5
| trigger('事件名稱', {
code?: event.code;
key?: event.key;
keyCode?: event.keycode;
})
|
若不清楚鍵盤對應的代號也沒有關係,有不少網站專門提供類似的服務供查詢,比方像這個 網站 只要按下任一按鍵就會即時顯示鍵盤對應的代號、名稱等等相關資訊。
在查到代號後我們就可以將其帶入剛才的方法中:
1
| trigger('keydown', { keyCode: 13 })
|
至於要選擇哪種寫法就看場景本身的需求來做決定囉!
表單事件
input
輸入checkbox
/ radio
勾選<select>
中的選擇內容
原先為了針對不同表單類型,Vue Test Utils 1 版(for Vue2)工具提供了一個專屬的 setChecked
的來對應 checkbox
/ radio
勾選狀態(checked);除此之外替 <select>
元素的選擇提供了 setSelected
方法來模擬選擇行為。
而在 Vue Test utils 2 版時,我們將只要統一使用 setValue
即可自動對應所有表單的行為!
輸入表單或是操作日期選擇器:
1
2
3
4
5
6
| <template>
<input v-model="textResult" type="text" data-test="text"/>
<p data-test="result_text">{{textResult}}</p>
<input v-model="dateResult" type="date" data-test="date"/>
<p data-test="result_date">{{dateResult}}</p>
</template>
|
文字輸入:
1
2
3
4
5
| it('模擬 input 輸入', async () => {
const wrapper = mount(component)
await wrapper.find([data-test="text"]).setValue('Hello, World!')
expect(wrapper.find([data-test="result_text"]).text()).toBe('Hello, World!')
})
|
選擇日期:
1
2
3
4
5
| it('模擬 日期 輸入', async () => {
const wrapper = mount(component)
await wrapper.find([data-test="date"]).setValue('2022/10/06')
expect(wrapper.find([data-test="result_date"]).text()).toBe('2022/10/06')
})
|
模擬 radio 勾選
元件
1
2
3
4
5
| <template>
<input data-test="radio_1" type="radio" v-model="radioResult" value="1" />
<input data-test="radio_2" type="radio" v-model="radioResult" value="2" />
<p data-test="result">{{ radioResult }}</p>
</template>
|
測試程式碼
1
2
3
4
5
6
7
| it('模擬 radio 勾選行為', async () => {
const wrapper = mount(component)
await wrapper.find('[data-test="radio_1"]').setValue(true)
expect(wrapper.find('[data-test="result"]').text()).toEqual('1')
await wrapper.find('[data-test="radio_2"]').setValue(true)
expect(wrapper.find('[data-test="result"]').text()).toEqual('2')
})
|
模擬 checkbox 勾選
元件
1
2
3
4
5
| <template>
<input data-test="checkbox_1" type="checkbox" v-model="checkboxResult" value="1" />
<input data-test="checkbox_2" type="checkbox" v-model="checkboxResult" value="2" />
<p data-test="result">{{ checkboxResult.join(',') }}</p>
</template>
|
測試程式碼
1
2
3
4
5
6
7
8
9
| it('模擬 checkbox 勾選行為', async () => {
const wrapper = mount(component)
await wrapper.find('[data-test="checkbox_1"]').setValue(true)
expect(wrapper.find('[data-test="result"]').text()).toBe('1')
await wrapper.find('[data-test="checkbox_2"]').setValue(true)
expect(wrapper.find('[data-test="result"]').text()).toEqual('1,2')
await wrapper.find('[data-test="checkbox_1"]').setValue(false)
expect(wrapper.find('[data-test="result"]').text()).toEqual('2')
})
|
模擬 select 選擇
元件
1
2
3
4
5
6
7
| <template>
<select data-test="target" v-model="result">
<option value="orange">Orange</option>
<option value="black">Black</option>
</select>
<p data-test="result">{{ result }}</p>
</template>
|
測試程式碼
1
2
3
4
5
| it('模擬 select 選擇', async () => {
const wrapper = mount(component)
await wrapper.find('[data-test="target"]').setValue('orange')
expect(wrapper.find('[data-test="result"]').text()).toEqual('orange')
})
|
模擬 Vue APIs
data
注意:在模擬 data 之前需要注意的是,在大部分的測試時我們通常不必特意去模擬 data,應該讓其與元件本身的私有方法(private method)自然互動即可,若會需要透過模擬 data 來操作元件則要觀察是否資料與元件本身耦合了,這種情況下可能造成元件本身無法高度重複利用。
私有方法(private method)
封閉在元件、物件…等等中的函式,在外部無法存取得到,e.g:
1
2
3
4
5
6
| function add5(x){
const add = (x,y,) => x + y
return add5(5, x)
}
// 裡頭的 add 函式即為私有方法。
|
若是真的有需要用到模擬 data
屬性時,首先要注意元件是用何種方式(options API /composition API 與 <script setup>
)來使用 data
,因為寫法上將會有所不同;且依照設定的時機還可分為「初始狀態」與「後續操作」。
data(options API)
1
2
3
4
5
6
7
8
9
10
11
12
| <template>
<p data-test="target">{{ content }}</p>
</template>
<script>
export default {
data() {
return {
content: ''
}
}
}
</script>
|
data(option API)初始狀態模擬,可透過容器(wrapper)的第二個參數帶入:
1
2
3
4
5
6
7
8
9
10
| /* 測試程式碼 */
const wrapper = mount(component, {
data(){
return {
content: 'test'
}
}
})
expect(wrapper.find('[data-test="target"]').text()).toEqual('test')
|
若要模擬後續才設定 data(option API),則可以透過容器方法中的 setData()
設置,此外由於是非同步的方法,要記得 async/await
才能確保斷言正確:
1
2
3
4
5
6
7
8
| it('...', async () => {
const wrapper = mount(component)
await wrapper.setData({
content: 'test'
})
expect(wrapper.find('[data-test="target"]').text()).toEqual('test')
})
|
data(composition API, <script setup>
語法糖)
composition API:
1
2
3
4
5
6
7
8
9
10
| <script>
export default {
setup() {
const content = ref('')
return {
content
}
}
}
</script>
|
<script setup>
語法糖:
1
2
3
4
| <script setup>
import { ref } from 'vue'
const content = ref('')
</script>
|
這一類透過 setup()
所處理的資料,若要模擬會遇到不少問題。
在容器方法中的第二個參數所提供的方式,原先是供應給 options
API 中的 data
屬性所使用,所以我們必須改以注入的方式嵌入 setup()
:
1
2
3
4
5
6
7
8
| /* 測試程式碼 */
const wrapper = monut(component, {
setup(){
return {
content:''
}
}
})
|
但如此一來,元件中原先其他寫在 setup()
內的狀態等就必須一起模擬,還記得好的元件測試守則之一嗎?沒錯,就是不要重複實踐實作。
那比較好的方式之一就是透過容器方法中(wrapper)取得實體來設置變數,並且透過 nextTick
方法確保渲染結果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| it('...' , async () => {
/* 測試程式碼 */
const wrapper = monut(component)
wrapper.vm.content = 'text'
// 版本1: 直接使用 vm 內的 $nextTick
await wrapper.vm.$nextTick()
// 版本2: 使用 vue 的 nextTick
// import { nextTick } from 'vue'
// await nextTick()
expect(wrapper.find('[data-test="target"]').text()).toEqual('test')
})
|
而關於私有方法或變數的模擬,或許將來 Vue Test Utils 開發團隊會對此有些改觀提供一些逃生艙的方法,不過目前比較一致的角度是盡量別對這一類的私有環境才有的變數與方法去做模擬,即時的相關討論可見於這裡。
props
在不模擬私有情境的前提之下,prop 相較起來單純多了,而關於測試 props 的部分主要有:
- 模擬 props 傳入後,斷言「後續的狀態」。
- 斷言「是否傳入對的參數給子元件」。
模擬 props 傳入
要模擬 props 傳入的方法依照時機可分為「初始狀態」與「後續操作」:
1
2
3
| <template>
<p data-test="target">{{ content }}</p>
</template>
|
初始狀態同樣是藉由容器(Wrapper)中的第二個參數傳入:
1
2
3
4
5
6
7
8
| it('', () => {
const wrapper = mount(component, {
props: {
content: 'Hello'
}
})
expect(wrapper.find('[data-test="target"]').text()).toEqual('Hello')
})
|
若想要後續才傳入 props
則可以透過容器方法 setProps
模擬,並且一樣要小心非同步的問題,記得補上 async / await
:
1
2
3
4
5
6
7
8
9
10
| it('...', async () => {
const wrapper = mount(component, {
props: {
content: 'Hello'
}
})
expect(wrapper.find('[data-test="target"]').text()).toEqual('Hello')
await wrapper.setProps({ content: 'Good bye' })
expect(wrapper.find('[data-test="target"]').text()).toEqual('Good bye')
})
|
斷言是否傳入對 props 參數給子元件
既然有接收的一方,那就至少會有給予的一方。
在 props
驗證中的另一種方式就是驗證給予其他元件的參數是否正確:
1
2
3
| <template>
<BaseLightbox content"Hello" enableMask="true" />
</template>
|
首先我們要做的是透過之前學的選擇器方法選到特定的元件,接著透過容器方法底下的 props()
來取的傳送資訊,接著就可以透過斷言 Matcher 來比對 props 給元件的資訊是否正確:
1
2
3
4
5
6
7
8
9
10
11
12
| import { BaseLightbox } from './BaseLightbox.vue'
it('', async () => {
const wrapper = mount(component)
const target = wrapper.get(BaseLightbox)
expect(target.props(content)).toEqual('Hello')
expect(target.props(enableMask)).toEqual(true)
expect(target.props()).toEqual({
content: 'Hello',
enableMask: true,
})
})
|
emit
在斷言中我們要測試 emit
主要是試圖捕獲元件所發生的事件,並斷言事件與值是否如我們所預期,而這時可以透過容器方法中的 emitted()
來取得事件發送所有的紀錄。
而 emitted
中所紀錄的格式如下:
1
2
3
4
5
6
7
| {
'事件名稱': [
[/* 第一次發送的值 */],
[/* 第二次發送的值 */],
[/* 第 n 次發送的值 */]
]
}
|
因此假設我們有一個 pagination
元件如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| <template>
<div>
<div data-test="first" @click="$emit('changePage', 'first')">
第一頁
</div>
<div data-test="prev" @click="$emit('changePage', 'prev')">
上一頁
</div>
<div data-test="next" @click="$emit('changePage', 'next')">
下一頁
</div>
<div data-test="last" @click="$emit('changePage', 'last')">
最後一頁
</div>
</div>
</template>
|
在斷言的時候我們可以透過下列方式檢查是否發送對應的 emit 事件:
- 透過
toHaveProperty
確認 emitted
屬性確認是否發送「預期事件名稱」 - 透過
emitted().事件名稱
確認該事件名稱「發生次數」 - 透過
emitted().事件名稱[index]
確認該事件名稱,第幾次送出的「值」
1
2
3
4
5
6
7
8
9
| it('...', async () => {
const wrapper = mount(Component)
await wrapper.find('[data-test="first"]').trigger('click')
expect(wrapper.emitted()).toHaveProperty('changePage')
expect(wrapper.emitted().changePage).toHaveLength(1)
expect(wrapper.emitted().changePage[0]).toEqual(['first'])
})
|
provide
對於有使用到 provide
的元件來說,我們需要確保他提供預期的 inject
內容,因此我們要建立一個用來接收 provide
的元件,接著才能透過斷言來測試傳遞的內容是否如預期。
接下來我們以這個元件為例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| <template>
<div>
<ChildComponent />
</div>
</template>
<script>
import { ref, provide, readonly } from 'vue'
export default {
setup() {
const count = ref(1)
provide('count', readonly(count))
},
}
</script>
|
然而我們為了讓測試案例保持獨立性,因此我們要透過造假的元件來測試 inject
所接收到的是否如預期。
透過元件容器中的 global.stubs 模擬假元件
透過元件容器(Wrapper)中的第二個參數 global.stubs
我們可以將子元件渲染成我們想要的樣子,而模擬的方式一共分為兩種:
當 global.stub[目標元件名稱]
為 true
時:
1
2
3
4
5
6
7
| const wrapper = mount(TargetComponent, {
global: {
stubs: {
ChildComponent: true,
},
},
})
|
我們的目標元件將會被渲染為帶有 -stub
後綴的元素:
1
2
3
4
5
| <template>
<div>
<ChildComponent /> <!-- 會變成 <child-component-stub></child-component-stub> -->
</div>
</template>
|
然而這對於我們要測試 provide
來說沒有幫助,因為他沒有辦法接收 inject
資訊,因此我們要採用下面第二種方法。
要渲染成特定元件的方式,就是先製作一個假元件,接著再提供給 global.stubs
來當作原先應該要渲染的元件。
首先,我們可以透過 Vue 提供的 defineComponent
建造元件:
1
2
3
4
5
6
7
8
9
| import { defineComponent } from 'vue'
const TestComponent = defineComponent({
template: '<p data-test="target">{{ count }}</p>',
setup() {
const value = inject('count')
return { value }
},
})
|
接著在測試案例就可以透過 global.stubs
中直接使用這個測試元件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| import { defineComponent } from 'vue'
const TestComponent = defineComponent({
template: '<p data-test="target">{{ count }}</p>',
setup() {
const value = inject('count')
return { value }
},
})
it('...' , () => {
const wrapper = mount(TargetComponent, {
global: {
stubs: {
ChildComponent: TestComponent,
},
},
})
})
|
如此一來元件 ChildComponent
在測試案例中的渲染結果將變成我們指定的樣子。
1
2
3
| <div>
<p data-test="target">1</p>
</div>
|
因此我們現在就可以斷言 provide
提供的內容是否正確了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| const TestComponent = defineComponent({
template: '<p data-test="target">{{ count }}</p>',
setup() {
const count = inject('count')
return { count }
},
})
it('...', async () => {
const wrapper = mount(TargetComponent, {
global: {
stubs: {
ChildComponent: TestComponent,
},
},
})
expect(wrapper.find('[data-test="target"]').text()).toBe('1')
})
|
inject
對於有使用到 inject
的元件來說,我們在意的是提供 provide
特定的值之後,畫面上渲染的結果是否正確。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| <template>
<div data-test="target">{{ count }}</div>
</template>
<script>
import { inject } from 'vue'
export default {
setup() {
const count = inject('count')
return { count }
},
}
</script>
|
此時我們可以透過元件容器(Wrapper)中的第二個參數 global
來指定要提供的 provide
值:
1
2
3
4
5
6
7
| const wrapper = mount(TargetComponent, {
global: {
provide: {
count: 1,
},
},
})
|
最後我們就可以直接斷言渲染的結果是否如預期:
1
2
3
4
5
6
7
8
9
10
11
| it('should be render correct content after providing count', async () => {
const wrapper = mount(TargetComponent, {
global: {
provide: {
count: 1,
},
},
})
expect(wrapper.find('[data-test="target"]').text()).toBe('1')
})
|
slots
而在針對 slots
撰寫測試時,要注意的地方有:
slots
至少會有預設未傳入的狀態與傳入資料後的狀態兩種,斷言時應該至少包含這兩種狀態。- 以
slots
行為作為案例斷言時,斷言內容要專注在 slots
所影響範圍內,否則可能會受其他因素干擾。
預設插槽(Default slots)
1
2
3
4
5
| <template>
<div>
<slot></slot>
</div>
</template>
|
要針對最基本的預設插槽做斷言時,我們可以透過容器中第二個參數帶入 slots
欄位來模擬帶入內容並透過 default
屬性傳入指定的範圍內。
未傳入測試案例:
1
2
3
4
| it('default slot', async () => {
const wrapper = mount(Component)
expect(wrapper.html()).toMatchInlineSnapshot()
})
|
傳入時測試案例:
1
2
3
4
5
6
7
8
9
| it('default slot', async () => {
const wrapper = mount(Component, {
slots: {
default: 'Slot Content',
},
})
expect(wrapper.html()).toContain('Slot Content')
})
|
具名插槽(Named slots)
1
2
3
4
5
6
7
| <template>
<div>
<slot name="header"></slot>
<slot name="body"></slot>
<slot name="footer"></slot>
</div>
</template>
|
若在插槽的型態為具名插槽,在撰寫測試案例時的容器選項 slots
就可以依據插槽名稱 name
指定要傳入的內容。
未傳入測試案例:
1
2
3
4
| it('named slot', async () => {
const wrapper = mount(Component)
expect(wrapper.html()).toMatchInlineSnapshot()
})
|
傳入時測試案例:
1
2
3
4
5
6
7
8
9
10
11
| it('named slot', async () => {
const wrapper = mount(Component, {
slots: {
header: 'ithelp 2022 鐵人賽',
body: 'vue3 單元測試',
footer: 'by Shawn',
},
})
expect(wrapper.html()).toMatchInlineSnapshot()
})
|
作用域插槽(Scoped slots)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| <template>
<div>
<p>貓咪咖啡廳:</p>
<p>店長:Shawn</p>
<slot :staffInfo="staffInfo"></slot>
</div>
</template>
<script setup>
const staffInfo = {
'black-cat': {
name: '黑黑',
'signature-dish': '拿鐵',
},
'orange-cat': {
name: '阿橘',
'signature-dish': '焦糖瑪奇朵',
},
}
</script>
|
有時候我們可能想要給予更高度的彈性,使元件透過 <slot>
傳遞資料給父層時,這時候就會用到作用域插槽。
未傳入測試案例:
1
2
3
4
5
6
7
8
9
| it('scoped slots', async () => {
const wrapper = mount(Component)
expect(wrapper.html()).toMatchInlineSnapshot(`
"<div>
<p>貓咪咖啡廳:</p>
<p>店長:Shawn</p>
</div>"
`)
})
|
傳入時測試案例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| it('scoped slots', async () => {
const wrapper = mount(Component, {
slots: {
default: `
<template #staffInfo>
<p>店員:{{ staffInfo['orange-cat']['name'] }}</p>
<p>限定項目:{{ staffInfo['black-cat']['signature-dish'] }}</p>
</template>
`,
},
})
expect(wrapper.html()).toMatchInlineSnapshot(`
"<div>
<p>貓咪咖啡廳:</p>
<p>店長:Shawn</p>
<p>店員:阿橘</p>
<p>限定項目:拿鐵</p>
</div>"
`)
})
|
插槽載入選項(Mounting options)
若想在測試案例中用不同的方式來模擬 slots,可以透過下面四種語法:
- 傳入 SFC 檔案
render function
- 包含
template
屬性的物件 - 直接傳入一個字串
1
2
3
4
5
6
7
8
9
10
11
| it('should render same layout', () => {
const wrapper = mount(Layout, {
slots: {
default: Component
// default: h('div', '相同內容'),
// default: { template: '<div>相同內容</div>' },
// default: '<div>相同內容</div>',
}
})
expect(wrapper.html()).toContain('<div>相同內容</div>')
})
|
custom directives
客製化指令本身搭配了類似元件生命週期的 Hooks,而我們可以在那些 Hook 中任意對我們所綁定的元素做開發,讓繁複的邏輯透過簡單的指令就能夠重複利用,使我們在開發過程中的開發者體驗(Developer experience, DX)有非常良好的體驗。
但相對的來說,要測試客製化指令就有機會運用到各種我們至今學過的許多技巧來斷言,從而使難度大大提升,但我們只要熟悉之前的模擬技巧後,最後只需要關注的一個地方就是,預期指令放入後應達成什麼目標,接著再去尋找要從什麼角度或可取得的資訊去比對來作為我們斷言的內容即可。
而接下來我們以一個 v-foucs
的客製化指令來介紹如何斷言客製化指令與尋找比對資訊:
1
2
3
| const vFocus = {
mounted: (el) => el.focus(),
}
|
- 註冊指令
首先,要進行斷言我們可以透過容器中第二個參數的 global.directives
來註冊我們的客製化指令:
1
2
3
4
5
6
7
| const wrapper = mount(..., {
global: {
directives: {
Focus: vFocus, // 屬性匹配的名稱規則 Abc 會 match 到 v-abc
},
},
})
|
- 仿造元件並放入指令
接著我們要使用到仿造元件的技巧,讓容器一開始就載入一個假的元件,並且放入指令與 data-test
屬性供捕獲:
1
2
3
4
5
6
7
8
9
10
| const Component = {
template: '<input v-focus data-test="target" type="text"/>',
}
const wrapper = mount(..., {
global: {
directives: {
Focus: vFocus,
},
},
})
|
- 尋找比對目標
再來,由於目標是為了驗證是否該元素為瀏覽器中的 focus
元素,因此要利用到瀏覽器物件 Document
中的 activeElement 屬性,若有被聚焦的情況下那應該要能夠比對到同個元素。(由 jsdom
提供模擬的瀏覽器環境)
- 斷言
最後在斷言的部分,為了取得目標元素,我們可以透過 wrapper.find().element
取得目標的元素實體,最後就可以拿他與 document.activeElement
做比對:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| it('v-focus', async () => {
const Component = {
template: '<input v-focus data-test="target" type="text"/>',
}
const wrapper = mount(Component, {
attachTo: document.body,
global: {
directives: {
Focus: vFocus,
},
},
})
expect(wrapper.find('[data-test="target"]').element).toBe(document.activeElement)
})
|
如此一來我們就可以確保將來在使用 v-focus
指令時,該元素在 mounted
後會是聚焦的元素。
Vitest UI
Vitest UI
Vitest UI 簡單來說,是另一種供我們查看測試與編寫測試的方式,它的特色在於基於 Vite 的 dev server 環境,讓我們可以直接在瀏覽器上直接觀看測試案例的測試結果,甚至支援直接在瀏覽器中編寫測試案例後同步更新測試檔案。
而在使用這項酷酷的功能之前,我們需要另外安裝 @vitest/ui
:
接著就可以在 package.json
中新增一個 vitest
指令,並帶上參數 --ui
:
1
2
3
4
5
| {
"scripts": {
"vitest:ui": "vitest --ui"
}
}
|
如此一來後們只要執行 npm run vitest:ui
,就可以在啟動這項功能,並看到下列畫面:
介面
在 Vitest UI 中,左側是所有測試案例的測試結果(Pass / Fail / Skip),我們可以透過上方的搜尋欄輸入檔名或路徑找到我們要觀察的測試。
點擊左側的測試路徑後,右側會出現測試情境與測試案例的描述讓我們更好觀察當下檔案中的所有案例
除此之外,再往右側會看到四項資訊,分別為:
- Report(預設顯示項目)
- Module Graph
- Console
- Code
Report
點擊 Report 頁籤時,會在右側顯示該測試檔案中的所有測試案例結果。
若為全通過的情況會顯示 All tests passed in this file
:
若有錯誤會顯示是哪一條測試案例發生錯誤:
我們甚至能點擊錯誤訊息右方的開啟視窗 Icon,Vitest UI 將會直接啟動系統預設的程式碼編輯器,開啟測試檔案後將輸入游標聚焦在錯誤的行數與位置。(e.g. 圖中的 toEqual
Assertion Matcher)
Module Graph
接著是第二個頁籤的 Module Graph
,這頁的內容主要是透過檢測行內引入模組,呈現檔案之間依賴關係。
點擊圖中的節點可以看到該模組的 Source 檔與經由 Transformed 後的程式碼。
而這個圖表最大的用處在於,透過可視化的方式呈現出測試程式碼中受測物(SUT)與依賴物(DOC)的依賴狀況。
Console
Console 頁籤主要是顯示在測試程式碼中的 console.log
資訊,由於與測試結果分離的情況下,相較於在終端機中觀察 Console,這裡會顯示得更加清楚。
Code
最後壓軸介紹的部分,則是 Code 頁籤,你可能會覺得這有什麼特別的,不就是顯示測試檔案程式碼嗎?
然而,這裡的測試程式碼除了預覽之外,還能夠直接在瀏覽器中直接編寫並執行測試查看結果,更重要的是寫完的當下按下儲存它還能夠同步調整到實際的程式碼中!
比方在瀏覽器中的測試程式碼中加個註解,並儲存:
實際測試程式碼檔案也加上了註解!
如此一來,我們就可以直接在這個視窗內撰寫我們的測試案例,並且在瀏覽器中即時看到測試結果。
不過這裡有個小小缺點是,若在專案中有設定 eslint + prettier 的話,在程式編輯器撰寫測試時可以設定儲存當下的自動做格式化,但是瀏覽器中的編輯器環境不同,所以寫測試時會收到格式錯誤的警告,但在不考慮這點的情況下,這個工具是個用來檢視測試結果與依賴關係的好工具。
程式碼覆蓋率(Code Coverage)
在測試領域中,覆蓋率(Coverage)一詞泛指的是以百分比表示測試程式碼能夠涵蓋多少產品程式碼的範圍,而依據不同的覆蓋率種類,其細微的定義也會有所不同。
接下來我們以覆蓋率工具庫 c8 來說明覆蓋率的概念與如何透過 c8 在 Vitest 測試環境中檢視當下的測試覆蓋率,最後再來談談有關於覆蓋率的相關議題。
c8
要在 Vitest 測試環境中使用 c8 產生覆蓋率報告,我們首先需要安裝 @vitest/coverage-c8
:
1
| npm i -D @vitest/coverage-c8
|
接著就可以在 package.json
中新增指令:
1
2
3
4
5
| {
"scripts": {
"test:coverage": "vitest --environment jsdom --coverage"
}
}
|
執行 npm run test:coverage
後,預設會在終端機產生出覆蓋率報告:
而 c8 覆蓋率報告總共有四種類型:
- 行數覆蓋率(line coverage):以行數為單位來計算。
- 函式覆蓋率(function coverage):以內部的函式為單位來計算。
- 分枝覆蓋率(branch coverage):以每個判斷式為單位來計算。
- 語句覆蓋率(statement coverage):以每個語句(statement)為單位計算。
測試程式碼涵蓋多少範圍的產品程式碼,甚至於每行程式碼個被執行了幾次
並且若有未覆蓋行數的情況, c8 則會在 Uncovered Line 中顯示未涵蓋的行數範圍,如下圖中的 13-17
、19-28
倘若你覺得這種報告形式不方便觀看,或是希望將它製作成文件,則可以在 config.js 檔中設定 coverage.reporter
的形式:
1
2
3
4
5
6
7
8
| // vite.config.js or vitest.config.js
export default defineConfig({
test: {
coverage: {
reporter: ['text', 'json', 'html'],
},
},
})
|
其中 c8 提供的報告(reporter)選項有以下三種:
- html: 產生一個可互動的 HTML 覆蓋率報告。
其中 HTML 的版本中,我們還可以點擊測試檔案來查看未覆蓋的行數有哪些,相較終端機的版本,我們就不必再拿著行數去對程式碼了:
覆蓋率目標
而在瞭解怎麼查看覆蓋率報告後,那麼究竟覆蓋率目標要達到多少才是合理呢?
以 《可測試的 JavaScript》一書作者 Mark Ethan Trostler 提供的答案是單元測試大約要介於 80 % 左右。
以 Martin Fowler 於 Test Coverage 一文中,則是指出大約會在於 80 至 90 % 附近。
至於為什麼不是達到百分之百呢?接下來我們來探討有關於覆蓋率相關的議題。
覆蓋率議題
覆蓋率與付出的心力
雖然以理論上來說盡可能得達到高覆蓋率,測試程式碼將涵蓋的越完全,然而為了追求 100 % 的覆蓋率所要付出的心理將會截然不同。
Jeroen Mols 在 The 100% code coverage problem 一文中的提到了心力付出與覆蓋率的關係表。
引用自 Jeroen Mols 《The 100% code coverage problem》一文中的圖
從上圖中可以看見,在覆蓋率從 0 提升至 80% 我們僅需要付出一些心力就可以輕易達成,然而要將 90% 提升至接近 100% 我們則幾乎要投入兩到三倍以上的心力才能完成。
因此,有些人雖然會認為 100% 的覆蓋率是一個不錯的目標,但是在實際上,我們可能會因為追求 100% 的覆蓋率而付出過多的心力,而導致測試程式碼的品質反而變差。
覆蓋率 100% 的誤區
在費盡千辛萬苦後,我們終於達到了 100% 覆蓋率的情況了。
然而,100 % 的覆蓋率僅能代表測試程式碼完整(Complete)的覆蓋了產品程式碼,並非代表測試程式碼的品質達到完美(Perfect),接下來我們用個簡單的例子來說明:
1
2
3
4
5
| const max = (x, y) => return x
test('max', () => {
expect(max(2, 1)).toBe(2)
})
|
在上方案例中,我們可以看見 max 測試案例基本上已經完全覆蓋了 max 函式的實作情況,而我們可以從這個案例中看出兩個問題:
- 產品程式碼本身有問題,即便測試程式碼覆蓋了 100% 的情況下,產品程式碼本身仍然是有問題的。
- 測試程式碼案例與路徑不足,導致覆蓋率為 100% 的情況下,並沒有測出產品程式碼中的問題與邊際案例(edge case)。
因此覆蓋率 100% 充其量只是一個指標,用來告訴我們測試程式碼是否完整的覆蓋了產品程式碼,而非「完全沒有問題」。
倘若我們想要盡可能避免這種情況的發生,這時我們就可以透過突變測試(Mutation Testing)來協助我們。
突變測試(mutation testing)
突變測試(Mutation Testing)是一種驗證與改善測試程式碼的測試方法,概念上來說,他會盡可能的抽換產品程式碼中的每個角落,接著依照抽換規則來預期測試程式碼的檢測結果。
假設我們有個比較函式如下:
1
| const greaterThan = (x, y) => x > y
|
而這時我們測試程式碼可能會這麼處理:
1
2
3
4
5
6
7
8
9
10
| it('should return true if x is greater than y', () => {
expect(greaterThan(2, 1)).toBe(true)
expect(greaterThan(0, -1)).toBe(true)
expect(greaterThan(100, 0)).toBe(true)
})
it('should return false if x is less than y', () => {
expect(greaterThan(1, 2)).toBe(false)
expect(greaterThan(-1, 0)).toBe(false)
expect(greaterThan(0, 100)).toBe(false)
})
|
接著突變測試可能會將 greaterThan
函式中的 >
符號替換成 >=
符號,此時理論上來說應該會有測試案例失敗,但是實際上測試程式碼卻沒有檢測到這個問題,那就表示我們的測試程式碼並沒有覆蓋到這個案例。
此時我們就可以針對這種情況,再補上對應的測試案例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| it('should return true if x is greater than y', () => {
expect(greaterThan(2, 1)).toBe(true)
expect(greaterThan(0, -1)).toBe(true)
expect(greaterThan(100, 0)).toBe(true)
})
it('should return false if x is less than y', () => {
expect(greaterThan(1, 2)).toBe(false)
expect(greaterThan(-1, 0)).toBe(false)
expect(greaterThan(0, 100)).toBe(false)
})
// 新增案例
it('should return false if x is equal y', () => {
expect(greaterThan(1, 1)).toBe(false)
})
|
接著突變測試再度將 >
符號替換成各種符號(e.g. <=
),甚至是將 {}
語句(statement)清空,而諸如此類的手法,就是突變測試的核心概念。
若想手動置換產品程式碼中的符號,可以從這些方法下手
- operator: +, -, *, /
- condition: <, >, <=, >=, ==, ===, !=, !==
- statement: {}, if, else, for, return, throw, try, catch
- expression: &&, ||, !
突變測試的工具
雖然說我們可以手動置換產品程式碼中的各種符號,但是這樣的作法會讓我們的工作量變得非常龐大,因此我們可以透過工具來協助我們完成這些工作。
以前端領域來說,我們可以透過 Stryker Mutator 底下的 stryker-js
工具來完成這些工作。
Stryker Mutator 的概念就是透過替換不同的符號產生出突變的內容,而我們的測試程式碼應該要捕捉到那些不合理的情況,藉此來保證我們的測試案例有一定的穩固性。
然而,可惜的是 stryker-js
目前僅支援到 Jest 環境,尚未支援 Vitest 的環境,因此我這邊就暫不做介紹了,有興趣者可以追蹤該 issue 目前的最新進度。