在上一篇文章中牍戚,講解了 Jest 的一些基本用法伦仍,理所當(dāng)然的娄蔼,我們需要應(yīng)用到實(shí)際項(xiàng)目中怖喻,這里以 Vue 舉例底哗,介紹一些 Jest 在 Vue 開發(fā)中的基本用法。
TDD vs BDD
在開始之前锚沸,我們需要先了解兩種編寫測試用例的方式跋选,以便在實(shí)際開發(fā)中選取合適的方式。
Test Driven Development (TDD) 測試驅(qū)動開發(fā)
TDD 的原理就是在編寫代碼之前先編寫測試用例哗蜈,由測試來決定我們的代碼前标,而且 TDD 更多的需要編寫?yīng)毩⒌臏y試用例,比如只測試一個組件的某個功能點(diǎn)距潘,某個工具函數(shù)等炼列。它是白盒測試。
開發(fā)流程大致是:編寫測試用例音比、運(yùn)行測試俭尖、編寫代碼使測試通過、優(yōu)化代碼洞翩。
TDD 的優(yōu)勢:從長期來看稽犁,可以有效減少回歸測試的 Bug;因?yàn)橄染帉憸y試骚亿,所以可能出現(xiàn)的問題都被提前發(fā)現(xiàn)了已亥;測試覆蓋率高,因?yàn)楹缶帉懘a循未,因此測試用例基本都能照顧到陷猫;保證代碼質(zhì)量。
TDD 的劣勢:因?yàn)閭?cè)重點(diǎn)在于代碼的妖,更多是保證某個測試單元沒問題绣檬,因此無法保證業(yè)務(wù)流程沒有問題;而且需求經(jīng)常變更嫂粟,在修改某個功能點(diǎn)之前要先修改測試用例娇未,因此在復(fù)雜的項(xiàng)目中工作量很大;測試代碼和實(shí)際代碼可能會出現(xiàn)耦合星虹,經(jīng)常需要修改零抬。
BDD (Behavior Driven Development) 行為驅(qū)動開發(fā)
BDD 是從產(chǎn)品角度出發(fā),它鼓勵開發(fā)人員和非開發(fā)人員之間的協(xié)作宽涌,是一種黑盒測試平夜。
開發(fā)流程大致是:獲悉需求并編寫代碼,然后再從用戶角度編寫集成測試卸亮。
BDD 的優(yōu)勢:它的測試重點(diǎn)更多是站在項(xiàng)目角度忽妒,在 UI 和 DOM 的角度進(jìn)行測試,直接地測試業(yè)務(wù)流程是否沒問題,測試代碼和實(shí)際代碼解耦段直。
BDD 的劣勢:因?yàn)槭羌蓽y試吃溅,因此不是那么關(guān)注每個函數(shù)功能,測試覆蓋率比較低鸯檬,沒有 TDD 那么嚴(yán)格的保證代碼質(zhì)量决侈。
Vue 中配置 Jest
在這里,直接借助了 Vue CLI 工具來初始化項(xiàng)目喧务,在初始化時會詢問是否使用單元測試赖歌,我們只需要按照步驟選擇,并選擇 Jest 即可蹂楣。
通過這種方式 Vue 會內(nèi)置 Vue Test Utils 幫助我們進(jìn)行測試
我們可以打開package.json
文件修改"test:unit": "vue-cli-service test:unit"
在后面加上--watch
這樣就只測試發(fā)生變動的文件
安裝后俏站,項(xiàng)目目錄下會有一個jest.config.js
文件,里面放的是 jest 相關(guān)的配置痊土,我們可以根據(jù)自己的需要修改之
module.exports = {
// 依次找 js肄扎、jsx、json赁酝、vue 后綴的文件
moduleFileExtensions: [
'js',
'jsx',
'json',
'vue'
],
// 使用 vue-jest 幫助測試 .vue 文件
// 遇到 css 等轉(zhuǎn)為字符串 不作測試
// 遇到 js jsx 等轉(zhuǎn)成 es5
transform: {
'^.+\\.vue$': 'vue-jest',
'.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub',
'^.+\\.jsx?$': 'babel-jest'
},
// 哪些文件下的內(nèi)容不需要被轉(zhuǎn)換
transformIgnorePatterns: [
'/node_modules/'
],
// 模塊的映射 @ 開頭到根目錄下尋找
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
// snapshot 怎么去存儲
snapshotSerializers: [
'jest-serializer-vue'
],
// npm run test:unit 時到哪些目錄下去找 測試 文件
testMatch: [
'**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
],
// 模擬的瀏覽器的地址是什么
testURL: 'http://localhost/',
// 兩個幫助使用 jest 的插件 過濾 測試名/文件名 來過濾測試用例
watchPlugins: [
'jest-watch-typeahead/filename',
'jest-watch-typeahead/testname'
]
}
為了方便犯祠,修改了testMatch
的配置:
// 這條是自己新增的,測試 .vue 文件的測試覆蓋率
// vue-cli-service test:unit --coverage 可以生成測試覆蓋率文件
// 可以在 package.json 作如下配置
// "test:coverage": "vue-cli-service test:unit --coverage"
collectCoverageFrom: ['**/*.{vue}', '!**/node_modules/**'],
testMatch: [
// 進(jìn)行測試時匹配 __tests__ 目錄下的 js/jsx/ts/tsx 文件
'**/__tests__/**/*.(js|jsx|ts|tsx)'
],
// .eslintrc.js 不需要進(jìn)行測試
testPathIgnorePatterns: [
'.eslintrc.js'
],
同時我們要明確開發(fā)的目錄結(jié)構(gòu)酌呆,好的目錄組織可以幫助開發(fā)者更好的理解代碼衡载,以下是一種參考,當(dāng)然可以有別的方式隙袁,只要組織得容易理解就好
├── App.vue
├── assets
├── components # 基礎(chǔ)組件
├── views # 項(xiàng)目主頁面
│ └── Article # Article 頁面
│ ├── ArticleHome.vue # Article 頁面組件
│ ├── __mocks__
│ │ └── axios.js # 需要 mock 的文件
│ ├── __tests__ # 測試目錄
│ │ ├── integration # 集成測試目錄
│ │ │ └── Article.js
│ │ └── unit # 單元測試目錄
│ │ └── store.js
│ └── components # 頁面子組件
│ ├── MyHeader.vue
│ └── MyFooter.vue
├── main.js
├── store.js # Vuex store
└── utils # 工具函數(shù)
下面介紹幾種基礎(chǔ)的用法痰娱,更詳細(xì)的可以看官方文檔
使用 Jest 測試 Vue 項(xiàng)目
判斷 DOM 結(jié)構(gòu)是否發(fā)生改變
Vue-test-utils提供的shallowMount
可以幫助我們掛載組件,但它不掛載子組件菩收,在組件單元測試中特別有用
另外mount
方法則是把子組件也進(jìn)行掛載
返回的wrapper
包含了所測試組件的屬性以及 vnode梨睁,我們可以借助它來測試 Vue
import { shallowMount } from '@vue/test-utils'
import MyHeader from '../../components/MyHeader.vue'
// __test__/unit/MyHeader.js
it('提示 MyHeader 樣式是否有發(fā)生變更', () => {
const wrapper = shallowMount(MyHeader)
expect(wrapper).toMatchSnapshot()
})
input
為了獲取 DOM,通常需要添加data-test=""
屬性來獲取
我們可以編寫一個webpack plugin
在生成production
環(huán)境代碼的時候移除之
<!-- MyHeader.vue -->
<input
data-test="input"
v-model="inputValue"
@keyup.enter="add"
/>
// __test__/unit/MyHeader.js
// 為了讓代碼看起來更清晰娜饵,我們可以使用 describe 包裹起來
describe('MyHeader 組件測試', () => {
it('input 測試', () => {
// 掛載 MyHeader 組件
const wrapper = shallowMount(MyHeader)
// 判斷是否存在 input
// wrapper.findAll('[data-test="input"]').at(0) 取第一個元素
const input = wrapper.find('[data-test="input"]')
expect(input.exists()).toBe(true)
// input 一開始內(nèi)容為空
const inputValue = wrapper.vm.inputValue
expect(inputValue).toBe('')
// 模擬輸入了內(nèi)容
input.setValue('name')
// 模擬觸發(fā)回車事件
input.trigger('keyup.enter')
// 模擬向外發(fā)送了一個 add 事件
expect(wrapper.emitted().add).toBeTruthy()
// 模擬回車之后內(nèi)容為空
expect(wrapper.vm.inputValue).toBe('')
})
})
集成測試
相比于單元測試坡贺,集成測試從業(yè)務(wù)流程角度出發(fā),同時測試多個組件箱舞,保證整個用戶行為是沒有問題的遍坟。
it(`
1. 在 input 輸入框輸入內(nèi)容
2. 點(diǎn)擊回車按鈕
3. 增加用戶輸入內(nèi)容的列表項(xiàng)
`, () => {
const wrapper = mount(TodoList)
const inputElem = wrapper.findAll('[data-test="input"]').at(0)
const content = '今晚去踢波'
inputElem.setValue(content)
inputElem.trigger('change')
inputElem.trigger('keyup.enter')
// 這是從另外一個組件中獲取的
const listItems = wrapper.findAll('[data-test="list-item"]')
expect(listItems.length).toBe(1)
expect(listItems.at(0).text(0)).toContain(content)
})
Vuex
測試 Store
import store from '../../../../store'
it('當(dāng) store commit change 時 value 發(fā)生變化', () => {
const value = 'content'
store.commit('change', value)
expect(store.state.value).toBe(value)
})
組件中
// 在 mount 時把 store 傳入,這樣就能使用 store
const wrapper = mount(TodoList, { store })
異步測試
在組件掛載時晴股,我們經(jīng)常會加載遠(yuǎn)程數(shù)據(jù)來渲染頁面愿伴,這樣,渲染出來后的 DOM 就不是可以立即能獲取的电湘,因此應(yīng)該這樣測試:
// 因?yàn)槭褂玫?nextTick公般,因此需要傳入 done 參數(shù)
it(`
1. 用戶打開頁面時万搔,請求遠(yuǎn)程數(shù)據(jù)
2. 頁面渲染遠(yuǎn)程數(shù)據(jù)
`, (done) => {
const wrapper = mount(TodoList, { store })
// 只需要稍有延遲就能取到數(shù)據(jù)
wrapper.vm.$nextTick(() => {
// 此時可以獲取到渲染后的 DOM
const listItems = wrapper.findAll('[data-test="list-item"]')
expect(listItems.length).toBe(2)
// 當(dāng) done 被執(zhí)行才結(jié)束測試
done()
})
})
mock 和 timer
假如我們使用了 axios,我們可以使用手動 mock 的方式官帘,不用真正地請求數(shù)據(jù),而是重寫獲取數(shù)據(jù)的實(shí)現(xiàn)昧谊,這樣可以省去每次遠(yuǎn)程獲取數(shù)據(jù)的時間刽虹。
// __mocks__/axios.js
const response = {
errorCode: 0,
data: [{ id: 0, name: 'name' }]
}
export default {
get () {
if (url === '/getData') {
return new Promise((resolve, reject) => {
if (this.errorCode === 0) {
resolve(response)
} else {
reject(new Error())
}
})
}
}
}
// TodoList.vue
mounted() {
setTimeout(() => {
axios.get('/getData').then((res) => {
this.data = res.data
}).catch(e => {
})
}, 5000)
}
通過上面的 mock,當(dāng)我們請求/getData
接口時就會先找到 mock 中模擬的請求呢诬,并返回模擬請求的中的數(shù)據(jù)
接下來就可以直接在測試中使用了涌哲,在這里再加了一個 5s 的延時,以演示如何測試有 timer 的情況
// 對 setTimeout 的統(tǒng)計(jì)都清零尚镰,避免測試之間相互影響
// 進(jìn)入導(dǎo)致 toHaveBeenCalledTimes 得不到預(yù)期值
beforeEach(() => {
jest.useFakeTimers()
})
it(`
1. 用戶打開頁面時阀圾,等待 5s,然后請求遠(yuǎn)程數(shù)據(jù)
2. 頁面渲染遠(yuǎn)程數(shù)據(jù)
`, (done) => {
const wrapper = mount(TodoList, { store })
// 希望 setTimeout 被調(diào)用一次
// 也可以使用 jest.advanceTimersByTime(5000) 來前進(jìn)多少秒
expect(setTimeout).toHaveBeenCalledTimes(1)
// 讓 timer 立即執(zhí)行
jest.runAllTimers()
// 獲取遠(yuǎn)程數(shù)據(jù)狗唉,在 nextTick 后把數(shù)據(jù)渲染出來
wrapper.vm.$nextTick(() => {
const listItems = wrapper.findAll('[data-test="list-item"]')
expect(listItems.length).toBe(2)
done()
})
})
如果要測試請求出現(xiàn)失敗的情況初烘,我們可以這樣做:
import axios from '../../__mocks__/axios'
beforeEach(() => {
// 每個測試用例之前,都把請求設(shè)為成功
axios.errorCode = 0
})
it(`測試失敗`, () => {
// 在這個測試用例中分俯,把請求設(shè)為失敗
// 錯誤碼是自己定的
axios.errorCode = 10000
})
小結(jié)
我們需要明確單元測試肾筐、集成測試、TDD缸剪、BDD幾個概念吗铐,針對不同的情況使用不同的測試方式,比如測試工具函數(shù)可以用 TDD 的測試方式杏节,面對復(fù)雜的項(xiàng)目唬渗,我們需要保證用戶的體驗(yàn),就可以使用 BDD 的測試方式奋渔,他們之間不是對立的镊逝,我們可以在項(xiàng)目中靈活地使用它們,把它拓展到團(tuán)隊(duì)中卒稳,讓自動化測試達(dá)到最佳實(shí)踐蹋半。如果測試寫得好,那么測試本身就已經(jīng)是一份文檔了充坑,能保證項(xiàng)目在迭代中的代碼質(zhì)量减江,在多人協(xié)同開發(fā)中特別有用。
官方文檔