通過生動詳實的例子帶你排遍vue單元測試過程中的所有疑惑與難題。
技術棧:jest、vue-test-utils。
共四個部分:運行時、Mock、Stub、Configuring和CLI。
運行時
在跑測試用例時,大家的第一個絆腳石肯定是各種undifned報錯。
解決這些報錯的血淚史還歷歷在目,現在總結來看,大都是缺少運行時變量抑或異步造成的。
這里咱們只說運行時,基本就這兩類:
1.缺少window等環境變量
一般通過引入global-jsdom解決,這也是官方推薦的。當然我們也可以自己在測試代碼中直接聲明定義。
比如我們在業務代碼中使用了sessionStorage。
// procudtpay.vue
<script>
const sessionParams = window.sessionStorage.getItem('sessionParams')
export default {data () { }
}
</script>
然后在測試代碼中直接重定義,這樣在運行時,實際取到的值就是我們在這里定義的。
// procudtpay.spec.js
window.sessionStorage = {getItem: () => {return { name:'name', type:'type' }}
}
import procudtpay from '../views/procudtpay.vue'
這里關于執行順序做一點額外說明:
示例中sessionParams的賦值是在import引入.vue模塊就執行了的,所以對sessionStorage的定義賦值需要在引入之前。
如果你的sessionStorage取值是在vue實例化后,比如created中,那么則沒有該問題。
2.缺少在main.js中定義/注冊的全局屬性和方法
這些就需要在測試代碼中引入同款,以及通過mount的配置項mocks和stubs,分別對其進行mock或者存根了。
// main.js
import Vue from 'vue'
import Mint from 'mint-ui'
import '../filter'
import axios from 'axios'
Vue.use(Mint)
Vue.prototype.$post = (url, params) => {return axios.post(url, params).then(res => res.data)
}
Vue.filter('filterxxx', function (value) {// bala bala ba…
})// xxx.spec.js
import Vue from 'vue'
import '../../filter/filter' // 引入注冊同款過濾器
Vue.filter('filterxxx', function (value) {// bala bala ba…
})
import { $post } from './http.js'
it('快照測試', () => {const wrapper = shallowMount(ProductPay, {mocks: {$post // 用自己定義的mock數據取代真實http請求},stubs:['mt-header'] // 存根組件})// ...
})
通常其他測試文件也會依賴這些全局變量,我們可以通過配置jest的setupFiles實現復用。
Mock
我翻開代碼一看,這代碼沒有注釋,歪歪斜斜的每一行都寫著‘斷言正確’四個字。我橫豎睡不著,仔細看了半夜,才從字縫里看出字來,滿屏都寫著兩個字:“造假”!
正應了那一句:人(ce)生(shi)如戲,全靠演技(mock)。總之,mock老重要了。
1.mock簡單函數
我們從最簡單的mock一個函數開始。
比如我們現在想要測試:當用戶購買成功,期望頁面能跳轉到結果頁。
// productpay.vue
<script>
export default {...methods:{commmit () {this.$post('xxx', params).then(data => {this.$router.push(`/payresult`)})}}
}
</script>
那么,我們可以通過mock掉$router的push方法,然后斷言它有被調用且參數正確,達成測試目的。
// productpay.spec.js
it('當用戶購買成功后,頁面應該跳轉至結果頁', async () => {const mockFunc = jest.fn()const wrapper = shallowMount(ProductPay, {mocks: {$post,$router: {push: mockFunc}}})wrapper.vm.commmit() // 提交購買expect(mockFunc).toHaveBeenCalledWith('/payresult')
})
2.mockHttp請求,指定返回結果
http請求和上面例子中的$router的區別是,它需要返回值。jest有多種方式指定返回值,這里用的是mockImplementation。
// test/**.spec.js
it('當用戶xxxx,應該xxxx', async () => {const respSuccess = { data: [...], code:0 }const respError = { data: [...], code:888 }// 定義mock函數const mockPost = jest.fn() const wrapper = shallowMount(index, { mocks: {$post:mockPost // 應用該mock函數}})// 指定異步返回數據mockPost.mockImplementation(() => Promise.resolve(respError))// 可以對調用情況進行斷言expect(mockPost).toHaveBeenCalled() mockPost.mockImplementation(() => Promise.resolve(respSuccess))//也可以等待異步結束,對結果進行斷言await flushPromises()expect(wrapper.vm.list).toEqual(respSuccess.data)
})
實際上我們項目中調用的接口會很多,且不乏返回大量數據的情況。如果這些都定義在測試代碼里就會很臃腫。這時候,我們可以對該功能做個簡單的模塊化。
// 常見的業務代碼
// main.js中把axios掛載到了vue實例
Vue.prototype.$post = (url, params) => {return axios.post(url, params).then(res => res.data)
}
// Index.vue中的請求
getProductList () {this.$post('/ProductListQry', {}).then(data => {this.ProductList = data.List})
}
// 1. 在單獨js中存放模擬數據 data/ProductListQry.js
export default {data:[{ id:1,name:'name',...},...],code:0
}// 2. 定義post方法,并做個數據匹配 test/http.js
import ProductListQry from '@/data/ProductListQry.js'
const mockData = {ProductListQry,... //可以用同樣的方式引入更多mock數據
}
const $post = (url = '') => {return new Promise((resolve, reject) => {const jsName = String(url).split('/')[1]resolve(mockData[jsName])})
}
export { $post }// 3. 引入并使用 test/index.spec.js
import Index from '@/views/Index.vue'
import { $post } from './http.js'
it('...',()=>{const wrapper = shallowMount(Index, {mocks: {$post}})wrapper.vm.getProductList() //觸發請求await flushPromises() //等待異步請求結束//可以看到wrapper中就有了我們指定的模擬數據console.log(wrapper.vm.ProductList)
})
同理,如果要測試請求失敗的情形,可以再定義一個返回錯誤數據的方法,比如就叫$postError。
// test/**.spec.js
import { $postError } from './http.js'
it('...',()=>{const wrapper = shallowMount(Index, {mocks: {$post:$postError}})wrapper.vm.getProductList() //觸發請求await flushPromises() //等待異步請求結束// 我們就可以就獲取到錯誤數據的場景進行測試了console.log(wrapper.vm.ProductList)
})
3.mock整個模塊
當業務代碼中直接使用了引入的組件/方法時,我們對其測試可能就需要mock整個模塊。下面是一個用彈窗做表單驗證的場景:
// productpay.vue
<script>
import { MessageBox } from '../Component'
export default {methods:{makeSurebuy () {let payAmount = delcommafy(this.payAmount)if (!payAmount) {MessageBox({message: '請先輸入購買金額'})return}if (payAmount < this.resData.BaseAmt) {MessageBox({message: '購買金額不能小于起存金額'})return}if (payAmount > this.Balance) {MessageBox({message: '購買金額不能大于可用余額'})return}// 校驗通過,發起交易...}}
}
<script>
//productpay.spce.js
import Component from '../Component'
jest.mock('../../../components/ZyComponent')it('當用戶點擊購買按鈕,如果輸入非法金額,應該有相應的錯誤提示', async () => {wrapper.findAll('.btn-commit').at(0).trigger('click')expect(Component.MessageBox.mock.calls[0][0]).toEqual({ message: '請先輸入購買金額' })wrapper.setData({payAmount: '100'})wrapper.findAll('.btn-commit').at(0).trigger('click')expect(Component.MessageBox.mock.calls[1][0]).toEqual({ message: '購買金額不能小于起存金額' })wrapper.setData({payAmount: '100000000000000000'})wrapper.findAll('.btn-commit').at(0).trigger('click')expect(Component.MessageBox.mock.calls[2][0]).toEqual({ message: '購買金額不能大于可用余額' })
})
我們通過jest.mock()mock整個模塊,當該模塊的方法被調用后它就會有一個mock屬性,可以通過ZyComponent.ZyMessageBox.mock進行訪問,其中ZyComponent.ZyMessageBox.mock.calls會返回被調用情況的數組,我們可以根據這個數據對函數被調用次數、入參情況進行斷言測試。
Stub存根組件
進行單元測試,理論上我們不用、也不應該在它的測試用例中測試子組件,不然就叫集成測試了。vue-test-utils是通過配置stubs實現對組件mock的。
const wrapper = shallowMount(index, {stubs: ['mt-header', 'mt-loadmore']
}
但是業務中難免會有調用子組件方法的時候,比如說mint-ui的loadmore。
// procuctlist.vue
<script>
export default {...methods:{getProductList () {this.$post('xxx', params).then(data => {...this.ProductList = this.ProductList.concat(data.List)this.$refs.loadmore.onBottomLoaded()})}}
}
</script>
這時候我們是可以改用mount方法使頁面渲染子組件,這樣通過$refs就能正常的獲取到子組件實例。但更合適的做法應該是自定義存根組件的內部實現,以滿足測試需求。
// procuctlist.spec.js
it('當用戶上拉產品列表,應該能看到的更多的產品', () => {const mockOnBottomLoaded = jest.fn()const mtLoadMore = {render: () => { },methods: {onBottomLoaded: mockOnBottomLoaded}}const mtHeader = {render: () => { }}const wrapper = shallowMount(Index, {stubs: { 'mt-loadmore': mtLoadMore, 'mt-header': mtHeader },mocks: {$post}})const currentPage = wrapper.vm.currentPagewrapper.vm.loadMoreProduction()expect(wrapper.vm.currentPage).toEqual(currentPage + 1)expect(mockOnBottomLoaded).toHaveBeenCalled()
})
最后提一嘴,存根組件后,業務代碼中子組件還是會被引入的,只是沒有被實例化和渲染。
Configuring和CLI
1.統計代碼覆蓋率忽略某些文件
使用coveragePathIgnorePatterns配置即可,把這個列出來是應為我遇到兩個項目相同配置,有一個死活不生效的問題。最后才從官方文檔中得知是babel插件istanbul問題。目前還未解決,只是粗暴的在.balelrc中把istanbul去掉了。有真正解決方案的大佬,留言教下……跪謝。
// jest.config.js
{coveragePathIgnorePatterns: ['<rootDir>/src/assets/']
}
2.通過t模式,可以僅執行指定的測試用例
當測試用例寫的多了,每次執行跑一堆用例,效率很低,如果代碼里有很多console,那就更難受了,找個報錯都能找半天。當時就想如果能僅測試當前用例就好了。
然后就找到了t模式,jest命令帶–watch參數進入監聽模式,然后輸入t,再輸入匹配規則即可。世界一下子就清凈了,舒服……
// package.json
{"scripts":{"tets":"jest --watch"}
}
3.vue-awesome-swiper測試運行時報錯
如果組件中引入了swiper,那么在執行測試用例時,vue-awesome-swiper中的js會報錯,引用即報錯,且是第三方代碼。
最后通過把swiper組件由局部注冊改為全局注冊得以解決。
行動吧,在路上總比一直觀望的要好,未來的你肯定會感 謝現在拼搏的自己!如果想學習提升找不到資料,沒人答疑解惑時,請及時加入扣群: 320231853,里面有各種軟件測試+開發資料和技術可以一起交流學習哦。
最后感謝每一個認真閱讀我文章的人,禮尚往來總是要有的,雖然不是什么很值錢的東西,如果你用得到的話可以直接拿走:
?
這些資料,對于【軟件測試】的朋友來說應該是最全面最完整的備戰倉庫,這個倉庫也陪伴上萬個測試工程師們走過最艱難的路程,希望也能幫助到你!