[TOC]
# 測試什么
你是否曾在應用中部署了一個新功能,并且希望這不會意外地產生一個新的 bug?那么通過測試應用程序就可以大大減少此類擔憂,并增強你的 Vue 應用程序。
一個經過徹底測試的應用程序通常由多種測試的良好實現組合,包括端到端 (E2E) 測試、有時是集成測試、快照測試和單元測試。本課程專門作為 Vue 中單元測試的初學者指南。正如你將在整個課程中看到的,單元測試是測試良好的應用程序的基礎。
在本課程中,我們將使用流行的 Jest JavaScript 測試庫來運行我們的測試,還有[Vue Test Utils](https://vue-test-utils.vuejs.org/),它是 Vue 的官方單元測試實用程序庫。
## 編寫測試的目標
首先,需要清楚地了解測試應用程序的好處。通過編寫更多的代碼來測試代碼,我們能從這些額外的工作中獲得什么呢?
### 提升信心
除了在新功能部署后晚上睡得更好之外,測試還可以幫助團隊中的每個人達成共識。如果你是一個代碼庫(repository)的新手,看到一套測試就像有一個經驗豐富的開發人員坐在你旁邊,看著你的代碼,確保你正處于代碼應該做的事情的正確軌道上。有了這些測試,您可以確信在添加新功能或更改現有代碼時不會破壞任何東西。
### 質量代碼
當您在編寫組件時考慮到測試時,您最終將創建獨立的、更可重用的組件。如果您開始為組件編寫測試,并且注意到它們不容易測試,那么這是一個明確的信號,表明您可以重構組件,從而最終改進它們。
### 更好的文檔
正如在第一點中提到的,測試的另一個結果是,它可以最終為您的開發團隊生成良好的文檔。當一個人剛剛接觸一個代碼庫時,他們可以從測試中尋找指導,這些測試可以為組件應該如何工作提供洞察力,并為可能需要測試的邊緣案例提供線索。
## 確定要測試的內容
測試是有價值的,但是應該在應用程序中測試什么呢?很容易走極端,測試一些不必要的東西,這不必要地減慢了開發時間。那么,我們在一個 Vue.js 應用程序中測試什么呢?答案其實很簡單:組件。由于 Vue 應用程序只是一個由相互關聯的組件組成的拼圖,我們需要測試它們各自的行為,以確保它們能夠正常工作。
### 組件契約
當我第一次了解 Vue 單元測試的時候,我發現當 Ed Yerburgh (他寫了一本關于[測試 vuejs 應用程序](https://www.oreilly.com/library/view/testing-vuejs-applications/9781617295249/)的書) 談到組件契約(*The Component Contract*)的思考時,這個單元測試很有幫助。通過這種方式,我們指的是組件和應用程序的其余部分之間的協議。
例如,假設一個組件接收了一個`min`和`max` prop,并在這些 prop 值之間生成一個隨機數字,然后將這個數字渲染給 DOM。組件契約上說:我將接受兩個 prop,并用它們產生一個隨機數。本契約內在的是**輸入**和**輸出**的概念。組件同意接收`min`和`max` prop作為輸入,并輸出一個隨機數。因此,我們可以通過考慮組件契約,并確定輸入和輸出,開始挑選我們應該測試的內容。
在高級別,常見的輸入(inputs)和輸出(Outputs)如下:
**輸入**
* 組件 Data
* 組件 Props
* 用戶交互
* Ex: user clicks a button用戶點擊一個按鈕
* 生命周期方法
* `mounted()`,`created()`, etc.等等
* Vuex Store
* 路由參數
**輸出**
* 呈現給 DOM 的內容
* External function calls外部函數調用
* Events emitted by the component組件發出的事件
* 改變路由
* 更新 Vuex Store
* 與子組件之間的聯系
* i.e. 子組件的變化
通過專注于這些方面,您可以避免專注于內部業務邏輯。
單元測試的目標純粹是為了確保您的組件產生預期的結果。我們在這里不關心它是如何達到這個結果的。
我們甚至可能會改變我們以后在邏輯上達到那個結果的方式,所以我們不希望我們的測試對應該如何實現這個結果做出不必要的規定。
這由您的團隊來確定實現這個結果的最有效途徑。這不是測試的工作。就單元測試而言,如果它可行,那么就可以運行。
既然已經知道了要測試什么,那么來看幾個基本示例,并確定可以在每個例子中測試什么。
## 示例:**AppHeader 組件**
在本例中,如果 `loggetin` 屬性為 `true`,則有一個組件將顯示一個`Logout`按鈕。
`AppHeader.vue`:
```
<template>
<div>
<button v-show="loggedIn">Logout</button>
</div>
</template>
<script>
export default {
data() {
return {
loggedIn: false
}
}
}
</script>
```
為了弄清楚我們應該測試這個組件的哪個部分,我們的第一步是確定組件的輸入和輸出。
**輸入**
* Data(`loggedIn`)
* 這個數據屬性決定按鈕是否顯示,因此我們應該測試這個輸入
**輸出**
* 渲染輸出(`button`)
* 基于輸入(`loggedIn`),按鈕是否在應該顯示的時候顯示在 DOM 中?
對于更復雜的組件,將有更多的方面需要測試,但是同樣的一般方法也適用。雖然對應該測試的內容有所了解當然是有幫助的,但是知道不應該測試的內容也是有幫助的。現在讓我們來解開這個謎團。
## 哪些是不用測試的
了解什么不需要測試是測試故事中很重要的一部分,許多開發人員沒有考慮到這一點,反過來這又花費了他們大量的時間,而這些時間本可以花在其他地方。
讓我們再看一下前面的例子,我們有一個組件,它接受一個 `min` 和 `max` props,并輸出一個在這個范圍內的隨機數。我們已經知道我們**應該**測試呈現在 DOM 的輸出,為此我們需要在測試中考慮 `min` 和 `max` prop。但是生成隨機數的實際方法是什么呢?我們需要測試一下嗎?
答案是否定的。為什么?因為我們**不需要迷失在實現細節中**。

### 不要測試實現細節
當進行單元測試時,我們不需要為某些事情*如何*工作而大驚小怪,只需要知道它們*確實*工作。
不需要設置一個測試來調用生成隨機數的函數,確保它以某種方式運行。我們不關心這里的內部結構。
我們只關心組件是否產生了我們所期望的輸出。通過這種方式,我們總是可以稍后再回來并替換實現邏輯 (例如,使用第三方庫來生成隨機數)。
### 不要測試框架本身
開發人員經常嘗試過多的測試,包括框架本身的內部工作。但是框架的作者已經建立了測試來做到這一點。例如,如果我們為我們的 `min` 和 `max` props 設置一些 prop 驗證,指定它們需要是一個**數字**,我們可以相信,如果我們嘗試傳入一個字符串,Vue 將拋出一個錯誤。
我們不需要浪費時間做 Evan You 的工作和測試 Vue.js 框架。這也包括不在 Vue 路由器和 Vuex 上做不必要的測試。
### 不要測試第三方庫
如果您正在使用的第三方庫是高質量的,那么它們已經有了自己的測試。我們不需要測試它們的內部結構。例如,我們不需要測試 Axios 是否按照應有的方式工作。Axios 團隊為我們做到了。
如果我們不必要地擔心這些事情,它們就會讓我們陷入困境。如果你覺得你不能信任你正在使用的已經經過良好測試的庫,也許這是您可能想避免使用它的一個跡象。
### 小結
在本課中,在編寫有效的單元測試之前,我們邁出了重要的第一步:確定在組件中應該和不應該測試什么。使用這種方法,我們可以明智地將時間集中在測試需要測試的部分上。在下一課中,我們將利用這些知識編寫我們的第一個單元測試。
# 用 Jest 編寫單元測試
在本課中,我們將使用 Jest 和 Vue Test Utils 編寫第一個單元測試。您可以從這個頁面的課程資源中可用的起始代碼開始,或者您可以跟隨并使用 Vue CLI 從頭創建項目。
## 創建我們的項目
使用 Vue CLI 創建一個新項目:
```shell
npx @vue/cli create unit-testing-vue
```
我們將選擇 “Manually select features” ,然后單擊 **enter 鍵**,這樣我們就可以指定希望在新項目中包含哪些庫。因為我們將學習如何使用 Vue 路由器和 Vuex 進行測試,選擇這兩個,當然還需要選擇單元測試。
在前一步中,**Linter/Formatter** 是默認選擇的,下一步允許我們定制該特性。對于這些,我選擇了 **ESLint + Prettier** 和 保存時 lint 。這個配置完全取決于您的這個項目。
因為我們選擇了**單元測試**作為項目中的一個特性,所以下一步將詢問我們希望使用哪個庫進行單元測試。我們將使用 **Jest**。
我們將把我們所有的配置放在它們自己的專用文件(dedicated config files)中,這樣就可以在這里保留默認設置并按 **enter 鍵**。
你可以保存為一個預設置,如果不,輸入 **n**,然后按 **enter 鍵**。然后,項目建造好了。
## 熟悉項目結構
首先查看 package.json,在這里我們將看到為我們安裝了 Jest 和 vue-test-utils。
```
"devDependencies": {
"@vue/cli-plugin-unit-jest": "^3.11.0",
"@vue/test-utils": "1.0.0-beta.29"
}
```
這些庫又是做什么的?提醒一下:
[Jest](https://jestjs.io/) 是一個 JavaScript 測試框架,致力于簡化單元測試。Jest 將為我們運行單元測試,并在測試通過或失敗時向我們報告。雖然 Jest 是一個非常大的框架 (有很多關于這個主題的書) ,但是您只需要理解幾個片段就可以編寫一些基本的測試。
[Vue Test Utils](https://vue-test-utils.vuejs.org/) 是 Vue.js 的官方單元測試工具庫。它使我們能夠在測試中渲染組件,然后在這些渲染的組件上執行各種操作。這對于確定組件行為的實際結果至關重要。
我們已經安裝了適當的測試工具。如何讓它們發揮作用呢?注意 **package.json** 中的腳本命令:
**package.json**
```
"scripts": {
...
"test:unit": "vue-cli-service test:unit"
},
```
這個命令實際上查看名為 `tests/unit` 的目錄,并運行我們在 `ComponentName.spec.js` 文件中設置的測試。
如果我們查看`tests/unit`目錄,就會發現已經創建了 `Example.spec.js` 文件。這是用于測試`src/components`目錄中的 `HelloWorld.vue`組件的虛擬測試文件。現在,忽略`Example.spec.js`文件中寫的內容,直接進入終端并輸入`npm run test: unit`。
然后,將看到 `Example.spec.js` 中的測試正在運行,并且通過了。

我們將創建一個新組件,為其設置一些測試,并使用 `test:unit` 命令運行這些測試。
## 新的組件和測試文件
在編寫任何測試之前,我們需要一個組件來進行測試。因此,我們將刪除 `src/components`中的 `HelloWorld.vue` 組件,然后創建一個名為 `AppHeader.vue` 的新文件,如下所示:
```
<template>
<div>
<button v-show="loggedIn">Logout</button>
</div>
</template>
<script>
export default {
data() {
return {
loggedIn: false
}
}
}
</script>
```
這個組件是一個簡單的 App Header,當用戶是 `loggedIn` 時,它會顯示一個注銷按鈕。
現在有了要測試的組件,進入 `test/unit` 目錄,刪除示例測試文件并創建一個名為 `AppHeader.spec.js` 的新文件。正如你在 測試命名原則 里看到的,我們正在使用組件名稱來測試 **AppHeader + spec.js**。Spec 代表 specification(規范),因為在這個文件中,我們實際上是*指定* AppHeader 組件應該如何運行,并測試該行為。
請注意,這些文件名**必須**包含`spec.js` ー 如果沒有它,那么當我們使用 `npm run test: unit` 腳本時,它們將不會運行。
## 確定要測試的內容
在為 `AppHeader.vue` 組件編寫測試之前,需要先確定它的輸入和輸出。幸運的是,我們在上一課中已經講過了。
**輸入**
* 數據(Data):`loggedIn` - 此數據屬性確定按鈕是否顯示
**輸出**
* 渲染輸出:`<button>` - 根據`loggedIn`輸入,判斷我們的按鈕是否顯示在 DOM 中。
我們知道,當`loggetin`等于 false(默認值)時,注銷按鈕不會顯示在 DOM 中。當`loggetin`等于 true 時,將顯示注銷按鈕。
所以我們對這個部分的測試是:
1. 如果用戶沒有登錄,請不要顯示退出按鈕
2. 如果用戶登錄,顯示退出按鈕
## 構建我們的第一個單元測試
現在我們已經知道要測試什么了,可以進入 AppHeader.spec.js 文件并開始編寫測試了。首先,我們需要導入正在測試的組件。
AppHeader.spec.js:
~~~
import AppHeader from '@/components/AppHeader'
~~~
現在,我們可以使用 Jest `describe()` 函數創建第一個測試塊。
AppHeader.spec.js:
~~~
describe('AppHeader', () => {
})
~~~
`describe`塊允許我們對相關的測試進行分組。當我們運行測試時,我們將看到控制臺中輸出的`describe`塊的名稱。
`describe() `接受一個字符串作為組件的名稱,并接受一個函數作為測試的參數。其實,如果我們只有一個測試,我們不需要將它包裝在一個`describe`塊中。但是當我們有多個測試時,用這種方式組織它們是有幫助的。
現在已經有了測試的分組,可以開始編寫這些單獨的測試(individual tests)了。我們使用 Jest 的`test()`方法來實現這一點。
[`test()`](https://jestjs.io/docs/en/api#testname-fn-timeout)方法采用一個字符串來定義測試,并采用一個函數來定義實際的測試邏輯。
AppHeader.spec.js:
~~~
test('a simple string that defines your test', () => {
// testing logic
}
~~~
> 提示:您可能還會看到使用`it()`的測試塊,同樣可以運行,因為它是`test()`的別名。
> it <=> individual test
所以我們的兩個測試開始時是這樣的:
AppHeader.spec.js:
~~~
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
})
~~~
目前我們已經建立了測試,但它們還沒有執行任何邏輯。還需要在它們的主體中添加一些邏輯,以確定實際結果是否與預期的結果相匹配。
### 斷言的期望
在 Jest 中,我們使用斷言來確定我們期望測試返回的內容是否與實際返回的內容相匹配。具體來說,我們使用 Jest 的 `expect()` 方法來實現這一點,該方法使我們能夠訪問許多 “匹配器” ,幫助我們將實際結果與預期結果進行匹配。
斷言的語法基本上是這樣的:
```
expect(theResult).toBe(true)
```
在`expect()`方法內部,我們將要測試的結果本身放入。然后,我們使用**匹配器(matcher)**來確定結果是否是我們預期的那樣。因此,在這里,我們使用通用的 Jest 匹配器`toBe()` 來說明:我們期望結果為真。
在編寫測試時,首先編寫一個您知道肯定會通過(或肯定會失敗)的測試是有幫助的。例如,如果我們說: `expect(true).toBe(true)` 我們知道這一定會通過。傳遞給`expect()`的結果是`true`,我們說我們期望這個結果是`toBe` `true` 。所以如果我們運行這些測試,我們知道它們一定會通過,因為 `true` == `true`。
AppHeader.spec.js:
```
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)
})
})
```
如果這些測試沒有通過,那么我們就知道在代碼中的設置有錯誤。因此,編寫類似這種的測試對其實是一種完備性測試,從而避免了原本可以通過的測試沒有通過,反而浪費時間來調試測試代碼。
理解如何編寫測試,其實就是需要理解哪些匹配器是符合自己需要的,所以花一些時間來理解 [Jest 匹配器 API](https://jestjs.io/docs/en/expect)。
## Vue Test Utils 的強大
現在我們已經完成了完備性測試,兩個測試都通過了,接下來執行真正邏輯測試:
1. 如果用戶沒有登錄,請不要顯示退出(退出)按鈕
2. 如果用戶登錄,顯示退出按鈕
為此,我們需要掛載 `AppHeader` 組件(以檢查按鈕在 DOM 中是否可見)。單獨執行所有這些操作將是一個相當復雜的過程,但幸運的是,在 Vue Test Utils 的幫助下,這個庫非常簡單,因為該庫與`mount`打包在一起。
將`mount`導入到我們的測試文件中,看看:
AppHeader.spec.js:
```
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)
})
})
```
上面,在我們的每個測試中,`mount(AppHeader)` 方法創建了一個`wrapper` 常量,在其中。之所以將其稱為包裝器,是因為除了掛載組件外,此方法還創建了一個[包裝器](https://vue-test-utils.vuejs.org/api/wrapper/),其中包含測試組件的方法。當然,了解包裝器上的不同屬性和方法是有幫助的,所以還是需要花一些時間研究[文檔](https://vue-test-utils.vuejs.org/api/wrapper/)。
> **旁注:**在 Vue Test Utils 中,還有`shallowMount()`方法。如果您的組件具有子組件,則`shallowMount()`將返回該組件的簡單實現,而不是完全呈現的版本。這很重要,因為單元測試的重點是隔離的組件,而不是該組件的子組件。
現在仍然沒有執行實際的測試,現在有掛載了 AppHeader 組件的包裝器,我們可以用它來寫出完整的測試。
### 測試按鈕的可見性
在我們的第一個測試用例中,我們知道默認情況下用戶沒有登錄(我們的輸入是`loggedIn: false`),所以我們想檢查并確保退出按鈕不可見。
要對退出按鈕的狀態做出斷言,我們需要獲得對模板中定義的按鈕元素的引用。為了實現這一點,我們將依賴于新**wrapper** 上提供的兩種方法: :`find()`和`isVisible()`。`find()`方法將在我們的模板中搜索匹配選擇器,以便找到我們的按鈕,而 `isVisible()`將返回一個布爾值,告訴我們該按鈕在組件中是否可見。
所以第一個測試看起來像這樣:
AppHeader.spec.js:
```
test('if user is not logged in, do not show logout button', () => {
const wrapper = mount(AppHeader)
expect(wrapper.find('button').isVisible()).toBe(false)
})
```
對于第二個測試,以同樣的方式找到按鈕,但是這次希望它是可見的,因此將斷言: `toBe (true)`。
*AppHeader.spec.js*:
~~~
test("if logged in, show logout button", () => {
const wrapper = mount(AppHeader)
expect(wrapper.find('button').isVisible()).toBe(true)
})
~~~
因為有用戶登陸時測試組件的行為 (當 `loggedIn` 為`true`時) ,所以需要更新這個值,否則這個測試就會失敗。我們該怎么做?我們的測試使用的救援!
```
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)
})
```
在這里,我們使用`wrapper`的內置 `setData()` 方法來設置數據,以適應我們正在測試的正確場景。現在,使用 `npm run test:unit` 運行測試時,它們應該都通過了!(Xee:哭了,我反正通不過,提示:`TypeError: wrapper.setData is not a function`,可能現在和 vue3 還有問題)
## 小結
剛剛介紹了很多步驟,這里重述一下我們剛剛做的:

顯然,測試的每個組件都會不一樣,因此這些步驟可能會有所不同,特別是圖中的步驟4。例如,我們可能需要設置 Props 或模擬用戶交互,而不是設置數據(setting the data)。在以后的課程中,我們還會介紹更多的測試案例。
# 測試 Props 和用戶交互
在上一課中,我們學習了如何編寫和運行我們的第一個單元測試。在本課中,我們將繼續為需要用戶交互并接受一些props 的組件編寫簡單的測試。
## 隨機數組件
首先確定了一個組件的輸入和輸出,該組件在其`min`和`max` props 的范圍內生成一個隨機數。
下面是該組件的代碼:
*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>
```
### 我們應該編寫哪些測試?
就這個組件的輸入而言,很明顯 props 是輸入,因為 props 實際上是輸入到組件中的。另一個輸入是用戶的交互,不管用戶是否單擊了按鈕,它都會運行生成隨機數的方法。輸出是顯示`randomNumber` 的渲染 HTML。
**輸入**
Props:
* `min`&`max`
用戶交互:
* 點擊生成隨機數按鈕
**輸出**
渲染輸出(DOM)
* 屏幕上顯示的數字介于`min`和`max`之間嗎?
我們可以利用這些知識來決定在這個組件中測試什么:
1. 默認情況下,`randomNumber`數據值應為`0`
2. I如果我們點擊生成按鈕,`randomNumber` 應該介于`1`(min)及`10`(max)之間
3. 如果我們改變`min`和`max` props為`200`及`300` 然后點擊按鈕,`randomNumber` 應該介于`200`(min)及`300`(max)之間
## 隨機數測試
為了測試這個組件,我們將創建一個新的測試文件:`/tests/unit/RandomNumber. spec.js`
現在,我們將簡單地構建測試并編寫我們知道將會失敗的缺省斷言。在上一課中,我們使用一個我們知道會通過的斷言來構建測試。通過使用一個我們知道肯定會失敗的斷言,這可以達到一個類似的目的,即確保我們的組件一開始的工作正如我們所期望的那樣。然后,我們將努力使測試通過。
*/tests/unit/RandomNumber. spec.js:*
```
import { mount } from '@vue/test-util'
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);
})
})
```
運行 `npm run test:unit`,將看到這個測試有 3 個失敗。
## 檢查默認隨機數
看看這個組件,我們知道`randomNumber` 的默認值是 0,那么為什么還要測試它呢?如果我們團隊中的其他人改變了默認的`randomNumber`呢?測試它可以使我們放心,在首次加載組件時將始終顯示 0。
測試這的第一步是`mount`我們正在測試的組件(RandomNumber.vue),它提供了一個包裝器,允許我們深入到組件中并測試需要的內容。這個測試看起來是這樣的:
*/tests/unit/RandomNumber. spec.js*
~~~
test('By default, randomNumber data value should be 0', () => {
const wrapper = mount(RandomNumber)
expect(wrapper.html()).toContain('<span>0</span>')
})
~~~
在這里,我們使用包裝器來獲取 `RandomNumber` 組件的 `HTML`,并斷言我們期望 HTML 的內部 HTML `toContain`一個 0 的 `span`。
如果我們通過在終端中輸入 `npm run test:unit` 來運行這個測試,我們將看到現在只有兩個測試失敗,并且我們已經通過了第一個測試。現在可以進行下一個測試,這需要一些用戶交互。
## 模擬用戶交互
我們需要驗證,當我們點擊生成隨機數按鈕,我們在`min`和`max` props 之間獲得了一個隨機數。它們默認值分別為 1 和 10,因此隨機數應該在這個范圍內。
正如我們以前所做的,我們需要`mount` `RandomNumber`組件。這里的新概念是,我們需要觸發點擊生成隨機數按鈕 (使用`min`和`max` props 生成新的隨機數的方法)。
我們將使用`find()`方法獲得對 button 元素的引用,然后使用[`trigger()`](https://vue-test-utils.vuejs.org/api/wrapper/#trigger)方法觸發 wrapper DOM 節點上的事件。`trigger`方法的第一個參數是一個字符串,用于指定要觸發的事件類型。
在這種情況下,我們希望觸發按鈕上的 “單擊” 事件。
*/tests/unit/RandomNumber. spec.js*:
```
test('If button is clicked, randomNumber should be between 1 and 10', () => {
const wrapper = mount(RandomNumber)
wrapper.find('button').trigger('click')
})
```
現在這個按鈕被點擊了,它會產生了一個隨機數。需要在渲染的 html 中訪問這個數字,我們可以寫:
~~~
const randomNumber = parseInt(wrapper.find('span').element.textContent)
~~~
這樣可以有效地找到 span,并訪問該元素的文本內容。但是因為我們需要內容是整數,所以我們使用了`parseInt`。
最后,可以使用其他的 Jest 斷言來確保該隨機數是落在 min prop 1 和 max prop 10 之間。
*/tests/unit/RandomNumber. spec.js*:
~~~
test('If button is clicked, randomNumber should be between 1 and 10', () => {
const wrapper = mount(RandomNumber)
wrapper.find('button').trigger('click')
const randomNumber = parseInt(wrapper.find('span').element.textContent)
expect(randomNumber).toBeGreaterThanOrEqual(1)
expect(randomNumber).toBeLessThanOrEqual(10)
})
~~~
現在運行測試,可以看到 2 通過。現在我們可以進行最終的測試。
## 設置不同的 prop 值
因為這個組件可以通過最小值和最大值來改變最小值和最大值的范圍,我們需要對此進行測試。為此,我們將使用 `mount()` 方法,它可以傳入可選的第二個參數,包括`propsData`。在這個例子中,可以用來重新設置`min`和`max`值,分別為 200 和 300。
*/tests/unit/RandomNumber. spec.js*:
```
test('If button is clicked, randomNumber should be between 1 and 10', () => {
const wrapper = mount(RandomNumber, {
propsData: {
min: 200,
max: 300
}
})
})
```
有了新的 `min` 和 `max`,這個測試看起來和我們上面寫的非常相似。
*/tests/unit/RandomNumber. spec.js*:
```
test('If button is clicked, randomNumber should be between 200 and 300', () => {
const wrapper = mount(RandomNumber, {
propsData: {
min: 200,
max: 300
}
})
wrapper.find('button').trigger('click')
const randomNumber = parseInt(wrapper.find('span').element.textContent)
expect(randomNumber).toBeGreaterThanOrEqual(200)
expect(randomNumber).toBeLessThanOrEqual(300)
})
```
現在運行該測試了,所有的測試都應該通過了!
## 小結
并不是所有的組件都是一樣的,所以測試它們通常意味著我們必須考慮模擬按鈕點擊和測試 props 。我們將繼續學習有關測試 Vue 組件的常見方面的知識。
# 測試拋出的事件(Emitted Events)
在前面,我們考慮了測試一個組件,該組件包含一些 props,還可以單擊按鈕生成一個數字。這需要我們在測試中模擬(`trigger`)按鈕的單擊。這個 “ click” 屬于原生 DOM 事件的范疇,但是在 Vue 組件中,我們通常需要我們的組件拋出它們自己的自定義事件,接下來,來測試這些事件。
## 什么是自定義事件?
簡而言之:有時候一個子組件需要讓應用程序中的另一個組件知道內部發生了什么事情。它可以通過發出一個自定義事件(例如`formSubmitted`)來廣播發生的事件,以讓其父組件知道事件發生了。父級可以等待,監聽事件的發生,然后在事件發生時做出相應的響應。
## 啟動代碼
幸運的是,Vue Test Utils 為我們提供了一個[emitted API](https://vue-test-utils.vuejs.org/api/wrapper/emitted.html),可以使用它在組件中測試這些類型的自定義事件。稍后將探討如何使用這個 API,首先看一下將要測試的組件。
*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>
```
正如你所看到的,我們有一個非常簡單的登錄表單。注意我們如何使用 `onSubmit()`方法,它用于 `$emit`發出一個名為 `formSubmitted` 的自定義事件,該事件發送一個包含 name 數據的有效負載,該有效負載綁定到`input`元素。我們想要測試的是,當表單提交時,它確實會發出一個包含`name`的有效負載的事件。
## 搭建測試文件
在編寫測試時,我們以模仿實際終端用戶與組件交互的方式來編寫測試,會很有幫助。那么用戶將如何使用這個組件呢?嗯,他們會找到文本輸入字段,然后他們會添加自己的名字,然后提交表單。因此,我們將在測試中盡可能地復制這一過程,如下所示:
*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
})
})
```
上面,你可以看到我們已經導入了 LoginFrom 組件和 Vue Test Utils 的 `mount`,我們需要完成以下步驟:
1. 查找文本輸入
2. 設置文本輸入的值
3. 模擬表單提交
4. 已發出 Assert 事件
5. 斷言有效負載是正確的
讓我們實現這些步驟。
## 設置文本輸入值
首先,就像終端用戶一樣,找到文本輸入并設置它的值。
*LoginForm.spec.js*
```
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('Adam Jahr') // Set value for text input
// 3. Simulate form submission
// 4. Assert event has been emitted
// 5. Assert payload is correct
})
})
```
### 關于定位輸入的注意事項
這對我們的特定需求非常有用,但是在這里值得一提的是,在生產測試中,您可以考慮在元素上使用特定于測試的屬性,如下所示:
~~~
<input data-testid="name-input" type="text" v-model="name" />
~~~
然后,在您的測試文件中,您將找到使用該屬性的`input`。
~~~
const input = wrapper.find('[data-testid="name-input"]')
~~~
這是有好處的,有幾個原因。首先,如果我們有多個輸入,我們可以使用這些 id 專門針對它們,也許更重要的是,這樣可以將 DOM 從測試中分離出來。例如,如果您最終將原生輸入替換為來自組件庫的輸入,那么測試仍然遵循相同的接口,并且不需要更改。它還解決了設計器更改元素的類或 id 名稱導致測試失敗的問題。特定于測試的屬性(Test-specific attributes)是使測試具有未來可靠性的一種方法。
## 模擬表單提交
一旦我們的終端用戶填寫好了我們的表單,下一步將提交表單。前面,我們討論了如何使用 `trigger` 方法來模擬 DOM 元素上的事件。
雖然您可能想在表單的按鈕上使用 `trigger` 來模擬表單提交,但是這樣做可能會有一個問題。如果我們最終從這個組件中刪除按鈕,而是依賴輸入的 `keyup.enter` 事件來提交表單,會怎樣?我們將不得不重構我們的測試。換句話說:在這種情況下,我們的測試會與組件表單的實現細節緊密耦合(tightly coupled)。因此,更具前瞻性(future-proofed)的解決方案將是即使在表單本身上也可以強制提交,而無需依靠按鈕作為中間人。
*LoginForm.spec.js*:
~~~
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('Adam Jahr') // Set value for text input
wrapper.trigger('submit') // Simulate form submission
// 4. Assert event has been emitted
// 5. Assert payload is correct
})
})
~~~
現在,通過使用 `wrapper.trigger('submit')` ,實現了一個更具可伸縮性、解耦的解決方案,以模擬用戶提交表單的過程。
## 測試我們的期望
現在輸入字段的值已經設置好了,表單也已經提交了,我們可以繼續測試期望發生的事情是否真的發生了:
* 事件已經發出
* 有效載荷是正確的
為了測試這個事件是否已經發出,我們將寫入:
*LoginForm.spec.js*:
```
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('Adam Jahr') // 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)
})
})
```
在這里,我們使用 Vue Test Utils 的 [emit API](https://vue-test-utils.vuejs.org/api/wrapper/emitted.html) 將 `formSubmitted` 事件的任何調用存儲在一個常量中,并斷言我們`expect`該數組的長度為`1`。換句話說:檢查事件是否確實發出了。
現在,我們只需要確認發出的事件具有適當的有效負載(組件的`name`數據值)。我們將為此再次使用已發出的 API。
如果我們打開打印`wrapper.emitted('formSubmitted')`,我們會看到這樣的結果:
~~~
[[], [{ 'name': 'Adam Jahr' }]]
~~~
因此,為了瞄準有效負載本身,語法如下:
~~~
wrapper.emitted('formSubmitted')[0][0])
~~~
然后將其與預期的有效負載進行匹配,出于組織的目的,我們將其存儲在`const expectedPayload = { name: 'Adam Jahr' }`
現在我們可以檢查 `expectedPayload` 對象是否與 `formSubmitted` 事件一起發射的有效負載相匹配。
~~~
const expectedPayload = { name: 'Adam Jahr' }
expect(wrapper.emitted('formSubmitted')[0][0]).toMatchObject(expectedPayload)
~~~
> **旁注:** 我們也可以將預期的有效負載硬編碼到匹配器:`.toEqual({ name: 'Adam Jahr' })`中。但是將它存儲在一個常量中可以讓我們更清楚地知道是什么對應什么。
我們的完整測試文件現在是這樣的:
*LoginForm.spec.js*:
```
import LoginForm from '@/components/LoginForm.vue'
import { mount } from '@vue/test-utils'
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('Adam Jahr') // 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: 'Adam Jahr' }
expect(wrapper.emitted('formSubmitted')[0][0]).toMatchObject(expectedPayload)
})
})
```
終端命令:`npm run test:unit`,我們將看到新測試通過!
## 小結
我們已經學習了在演示用戶如何與組件交互的同時編寫測試,以便測試自定義事件是否以正確的有效負載發出。
# 測試 API 調用
除非您使用的是一個簡單的靜態網站,否則您的 Vue 應用很可能會從某些組件中進行 API 調用。我們將看看如何測試這些類型的數據獲取組件。
關于測試進行 API 調用的組件,首先要了解的是,我們不希望對后端進行真正的調用。這樣做將把我們的單元測試與后端結合起來。當我們希望在[持續集成](https://en.wikipedia.org/wiki/Continuous_integration)中執行單元測試時,這就成了一個問題。真正的后端也可能是不可靠的,我們需要我們的測試表現可預測的。
我們希望我們的測試是快速和可靠的,并且我們可以通過模擬我們的 API 調用來實現這一點,并且只關注我們正在測試的組件的輸入和輸出。在本課中,我們將使用 [axios](https://github.com/axios/axios) (流行的基于 promise 的 HTTP 客戶端) 來進行調用。這意味著我們需要模仿 axios 的行為。但是首先讓我們看一下起始代碼。
## 啟動代碼
為了簡單起見,我們沒有插入完整的后端,而是使用 [json-server](https://github.com/typicode/json-server),它提供了一個假的 REST API。你需要知道的是:`db.json` 文件是我們的數據庫,json-server 可以從中獲取數據。
我們的簡單 db 有一個端點(endpoint): “消息” ,這就是我們要獲取的數據。
*db.json*:
```
{
"message": { "text": "Hello from the db!" }
}
```
在我們的項目中,我還添加了一個 API 服務層,它將處理實際的 API 調用。
*services/axios.js*:
~~~
import axios from 'axios'
export function getMessage() {
return axios.get('http://localhost:3000/message').then(response => {
return response.data
})
}
~~~
正如您所看到的,我們已經導入了 axios,并導出了`getMessage()`函數,該函數向我們的端點發出 `get` 請求: `http://localhost:3000/message`,然后我們從響應返回數據。
查看觸發這個 API 調用的組件,并顯示返回的數據。
*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>
```
先從 axios.js 文件中導入了 getMessage 函數,在創建組件時,它使用 `async/await` 調用 `getMessage`,因為 **axios** 是異步的,我們需要等待它返回的承諾來解析。解析時,我們將組件的本地`message`數據設置為已解析值,該值將顯示在模板中。
我們還將 `getMessage` 調用包裝為 `try...catch`,來捕捉可能發生的錯誤,如果確實發生了錯誤,將相應地顯示該錯誤。
## 輸入與輸出
查看 **MessageDisplay.vue** 組件,在編寫測試時需要考慮哪些輸入和輸出?
我們知道來自 `getMessage` 調用的響應是我們的輸入,我們有兩個可能的輸出:
1. 調用成功進行,并顯示消息
2. 調用失敗,并顯示錯誤
所以在我們的測試文件中,我們需要:
1. 模仿一個成功的`getMessage`調用,檢查`message`顯示
2. 模擬一個失敗的`getMessage`調用,,檢查`error`顯示
## 模擬 Axios
讓我們構建測試塊,導入我們正在測試的組件,掛載它,并使用注釋來分離測試需要執行的操作。
*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
})
})
```
讓我們一個一個地填寫這些測試。查看其中 “調用 `getMessage` 并顯示消息” 的測試,我們的第一步是模擬 axios。同樣,在測試進行 API 調用的組件時,我們不希望對數據庫進行實際調用。我們可以使用 Jest 的 [mock](https://jestjs.io/docs/en/mock-functions.html) 函數簡單地通過模擬該行為來調用。
為了模仿我們的 API 調用,我們首先從 **axios.js** 文件中導入 `getMessage` 函數。然后,我們可以通過向`jest.mock()`函數傳遞該請求函數所在文件位置的路徑,來將該函數提供給它。
*MessageDisplay.spec.js*:
~~~
import MessageDisplay from '@/components/MessageDisplay'
import { mount } from '@vue/test-utils'
import { getMessage } from '@/services/axios'
jest.mock('@/services/axios')
...
~~~
您可以將 `jest.mock` 想象為:“我將獲取您的 `getMessage` 函數,作為回報,我將給您一個模擬的 `getMessage` 函數。” ,當我們在測試中調用 `getMessage` 時,實際上我們調用的是這個函數的模擬版本,而不是實際版本。
讓我們在測試中調用模擬的 `getMessage` 函數。
*MessageDisplay.spec.js*:
```
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
})
})
```
通過使用 jest 的 [`mockResolvedValueOnce()`]( https://jestjs.io/docs/en/mock-function-API.html#mockfnmockresolvedvalueoncevalue ) 方法,我們所做的正是按照方法名稱的意思進行操作:模擬的調用 API 并返回一個模擬的值,以便調用用于解析。作為它的參數,這個方法接受我們希望這個模擬的函數用來 resolve 的值。換句話說,這就是放置模擬的請求應該返回的假數據的地方。因此,我們將傳入`{ text: mockMessage }`來復制服務器將響應的內容。
正如您可以看到的,我們正在使用`async`,就像我們在以前的測試中一樣,因為 axios(和我們的模擬 axios 調用)是異步的。因為在我們編寫任何斷言之前,我們需要確保我們的調用返回的 Promise 得到解決。否則,測試將在 Promise 解析之前運行,然后導致失敗。
## 等待 Promises
在確定在測試中`await`的位置時,需要想想在我們測試的組件中是如何調用 `getMessage`的。記住,它是在組件`created`生命周期掛鉤上調用的嗎?
*MessageDisplay.vue*:
```
async created() {
try {
this.message = await getMessage()
} catch (err) {
this.error = 'Oops! Something went wrong.'
}
}
```
由于 vue-test-utils 無法訪問由`created`生命周期掛鉤 請求的 pomise 的內部,因此我們實際上不能挖掘任何可以`await`這個 pomise 的內容。因此,這里的解決方案是使用一個名為 [flush-promises](https://www.npmjs.com/package/flush-promises) 的第三方庫,它允許我們很好地兌現(flush)pomise,確保它們在運行我們的斷言之前都得到了解決。
用 `npm i flush-promises -- save-dev` 安裝了這個庫,將它導入到我們的測試文件中,等待pomise的兌現(flush)。
*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
})
})
~~~
既然我們已經確保在運行斷言之前解決了 promises,那么可以編寫這些斷言了。
## 我們的斷言
首先,確保 API 調用只發生一次。
*MessageDisplay.spec.js*:
```
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()`方法,并傳遞我們希望`getMessage`被調用的次數是:`1`。現在我們已經確保我們不會意外地多次訪問我們的服務器。
接下來,檢查組件是否顯示了從 `getMessage` 請求接收到的消息。在 `MessageDisplay` 組件的模板中,顯示消息的`p`標記有一個用于測試的 id:`data-testid="message"`
*MessageDisplay.vue*:
~~~
<template>
<p v-if="error" data-testid="message-error">{{ error }}</p>
<p v-else data-testid="message">{{ message }}</p>
</template>
~~~
我們在前面已經學習了這些測試 id。我們將使用該 id 來`find`元素,然后斷言其文本內容應等于我們的模擬`getMessage`請求所解析的值:`mockMessage`
*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)
})
```
如果在終端中運行`npm run test:unit`,將看到新編寫的測試通過!可以繼續進行第二個測試,我們將模擬失敗的 getMessage 請求,并檢查組件是否顯示錯誤。
## 模擬一個失敗的請求
第一步,模仿失敗的 API 調用,非常類似于我們的第一個測試。
*MessageDisplay.spec.js*:
~~~
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
})
~~~
請注意我們是如何使用 `mockRejectedValueOnce` 來模擬失敗的獲取請求的,并且我們將 `mockError` 傳遞給它來解決它。
在等待 promises 刷新(flushing)之后,我們可以檢查調用是否只發生一次,并驗證組件的模板是否顯示預期的 `mockError`。
*MessageDisplay.spec.js*:
```
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)
})
```
就像我們的第一個測試,我們使用`.toHaveBeenCalledTimes(1)` 來確保 API 調用沒有超出我們應該的范圍,我們正在尋找顯示錯誤消息的元素,并檢查其文本內容與返回的模擬失敗請求返回的 `mockError` 對比。
現在運行這些測試,會發生什么?測試失敗了:
**Expected number of calls: 1 Received number of calls: 2**
為什么呢?因為在第一個測試中,已經調用了 `getMessage`,在第二個測試中又調用了它。在運行第二個測試之前,沒有清除模擬的 `getMessage` 函數。解決辦法很簡單。
## 清除所有模擬
在創建 jest `mock` 的下面,可以添加解決方案,清除所有模擬。
*MessageDisplay.spec.js*:
~~~
jest.mock('@/services/axios')
beforeEach(() => {
jest.clearAllMocks()
})
~~~
在運行每個測試之前,將確保清除了 `getMessage` 模擬,這把它被調回的次數重置為 0。
現在,運行測試時,它們都會通過了!
## 完整代碼
*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')
beforeEach(() => {
jest.clearAllMocks()
})
describe('MessageDisplay', () => {
it('Calls getMessage 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)
})
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)
})
})
```
## 小結
我們已經學習來,在測試 API 調用時,同樣的基本規則也適用:關注組件的輸入(請求的響應)和輸出(顯示的消息或錯誤),同時注意避免測試和組件的實現細節之間的緊密耦合 (例如,通過測試 id 查找元素,通過元素類型查找元素)。還學習了如何使用 jest 來模擬我們的調用,以及第三方庫 flush-promises 來等待我們的生命周期鉤子中的異步行為。
接著,我們將學習什么是 stub ,以及它如何幫助我們測試父組件。
# stub 子組件
我們已經研究了模擬 axios 模塊的過程,以測試我們的組件已設置為進行 API 調用并使用返回的數據,而不必碰到實際的服務器或不必要地將測試耦合到后端。
在我們的單元測試中,*模擬*某些東西的概念比模擬模塊更廣泛,不管它們是 axios 還是其他外部依賴。我們將深入探討這個主題,并研究組件測試中的另一種偽造形式,稱為 stubbing,以及為什么和什么時候這種方法可能有用。
## Children with Baggage
為了探索這個概念,我想向您介紹 `MessageContainer`,它是 `MessageDisplay` (我們在前面中測試的組件) 的父組件。
*MessageContainer.vue*:
```
<template>
<MessageDisplay />
</template>
<script>
import MessageDisplay from '@/components/MessageDisplay'
export default {
components: {
MessageDisplay
}
}
</script>
```
正如您所看到的,`MessageContainer` 只是導入和包裝 `MessageDisplay`。這意味著當 MessageContainer 被呈現時,`MessageDisplay` 也被呈現。所以我們遇到了上一課中的同樣問題。我們并不希望真正觸發 `MessageDisplay` 在`created`時發生的 axios `get` 請求。
*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` 而不觸發它的子 axios 請求?或者說得更籠統一些:當子組件具有模塊依賴性,而我們又不想在測試中使用真正的版本時,我們該怎么辦?
問題的答案也許并不令人滿意。因為答案是:這要看情況(it depends)。它取決于子模塊的復雜性和數量。對于這個例子來說,東西是相當輕量級的。我們只有一個模塊,因此我們可以簡單地在 `MessageContainer` 的測試中模仿 axios,就像我們在 `MessageDisplay.spec.js `中所做的那樣。但是如果我們的子組件有多個模塊依賴項會怎樣呢?在更復雜的情況下,更簡單的方法通常是跳過模擬子組件的模塊包袱(baggage),而是模擬子組件本身。換句話說:我們可以使用子組件的 **存根(stub)** 或假占位符版本。
完成了所有這些智能樣板文件之后,繼續這個例子,了解如何在`MessageContainer`的測試中對`MessageDisplay`進行存根處理。
## MessageContainer 測試
我們從一個腳手架(scaffold)開始:
*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)
})
})
```
在這個測試中,我們在哪里以及如何給我們的孩子存根?請記住,正是 `MessageContainer` 的掛載將創建并掛載它的子元素,從而觸發子元素的 API 調用。因此,在掛載其父組件時對子組件進行存根是很有意義的。為此,我們將在 `mount` 方法中添加一個 `stubs` 屬性作為第二個參數。
*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, {
stubs: {
MessageDisplay: '<p data-testid="message">Hello from the db!</p>'
}
})
})
})
~~~
請注意,我們是如何將 `MessageDisplay` 組件添加到`stubs`屬性中的,并且它的值是我們希望在實際掛載子組件時呈現的HTML。同樣,這個存根是一個占位符,當我們掛載父類時它會被掛載。這是一種固定的反應;代替真實子組件的替代品。
現在,為了確保 `MessageContainer` 執行了包裝 `MessageDisplay` 組件的 ****工作,我們需要查看掛載的內容,并查看是否能夠找到來自`MessageDisplay`(我們的存根版本)的正確消息。
*MessageContainer.spec.js*:
```
describe('MessageContainer', () => {
it('Wraps MessageDisplay component', () => {
const wrapper = mount(MessageContainer, {
stubs: {
MessageDisplay: '<p data-testid="message">Hello from the db!</p>'
}
})
const message = wrapper.find('[data-testid="message"]').element.textContent
expect(message).toEqual('Hello from the db!')
})
})
```
我們將創建一個常量來存儲我們期望被呈現的 `stubMessage`,在斷言中,我們把它與掛載的消息(來自存根)進行比較。
在命令行中運行 `npm run test:unit`,確實可以看到我們的測試正在通過,并且我們已經確認 `MessageContainer` 正在做它的工作:包裝 (被存根的) `MessageDisplay` 組件,該組件顯示真正的子組件所具有的內容。太好了。
## 存根的缺點
雖然存根可以幫助我們簡化包含負擔沉重的子組件的測試,但我們需要花一點時間考慮存根的缺點。
由于存根實際上只是子組件的占位符,如果實際組件的行為發生了變化,我們可能需要相應地更新存根。隨著應用程序的發展,這可能會導致維護存根的**維護成本(maintenance costs)**。一般來說,存根可以在測試和組件的實現細節之間創建耦合。
此外,由于存根并不是*實際*的完全呈現的件,這樣就減少了實際組件代碼庫的測試覆蓋率,從而降低了測試給您的應用程序提供真實反饋的信心。
我提出這些觀點不是為了阻止你使用存根,而是為了鼓勵你明智而謹慎地使用它們,記住,像我們在其他章節中看到的那樣,關注模擬模塊和服務層通常是更好的實踐。
## 那么 ShallowMount 的呢?
您可能已經看到過在其他人的測試代碼中使用淺掛載(`shallowMount`)。你可能已經聽說過,這是一種只掛載頂級父層而不掛載其子層的簡便方法(因此:*淺*,不深入子層)。那么,我們為什么不使用它呢?為什么我們要手動對子組件進行存根(stubbing)?
首先,`shallowMount`受到存根同樣的缺點 (如果不是更多的話) 的影響:信心降低,耦合和維護增加。其次,如果你使用其他的測試庫,比如 [Vue Testing Library](https://testing-library.com/docs/vue-testing-library/intro),你會發現它不支持`shallowMount`。這就是為什么沒有教授它的原因。有關這方面的更多信息,可以參考 testinglibrary 的維護人員 Kent c. Dodds 撰寫的[這篇文章](https://kentcdodds.com/blog/why-i-never-use-shallow-rendering)。
## 總結
終于結束了這篇。希望您在這篇關于 Vue 應用單元測試的介紹中學到了很多。還有很多測試主題和生產級實踐要講,即將到來的單元測試生產課程會學到更多,該課程將在未來幾個月內發布。敬請期待!
更多可以參考:[Vue 測試指南](https://lmiller1990.github.io/vue-testing-handbook/zh-CN/)
# 參考
[組件單元測試的指導原則](https://zhuanlan.zhihu.com/p/140919158)
[Testing logic inside a Vue.js watcher](https://vuedose.tips/testing-logic-inside-a-vue-js-watcher/)
[vue-test-utils](https://vue-test-utils.vuejs.org/api/wrapper/)
[VueMastery - Unit testing](https://coursehunters.online/t/vuemastery-unit-testing/2938)
- Introduction
- Introduction to Vue
- Vue First App
- DevTools
- Configuring VS Code for Vue Development
- Components
- Single File Components
- Templates
- Styling components using CSS
- Directives
- Events
- Methods vs Watchers vs Computed Properties
- Props
- Slots
- Vue CLI
- 兼容IE
- Vue Router
- Vuex
- 組件設計
- 組件之間的通信
- 預渲染技術
- Vue 中的動畫
- FLIP
- lottie
- Unit test
- Vue3 新特性
- Composition API
- Reactivity
- 使用 typescript
- 知識點
- 附錄
- 問題
- 源碼解析
- 資源