前情
- uniapp推薦了測試方案
@dcloudio/uni-automator
,屬于自動化測試,api提供的示例偏重于渲染組件,判斷當(dāng)前渲染的組件是否和預(yù)期一致 - vue推薦的測試方案
vue test utils
,屬于單元測試,可以搭配jest、mocha等單測運行器
我選了方案2???
關(guān)于vue的組件測試,vue官方提到:
你的 Vue 應(yīng)用中大部分內(nèi)容都應(yīng)該由組件測試來覆蓋,我們建議每個 Vue 組件都應(yīng)有自己的組件測試文件。
當(dāng)進(jìn)行測試時,請記住,測試這個組件做了什么,而不是測試它是怎么做到的對于 視圖 的測試:根據(jù)輸入 prop 和插槽斷言渲染輸出是否正確。
對于 交互 的測試:斷言渲染的更新是否正確或觸發(fā)的事件是否正確地響應(yīng)了用戶輸入事件
本身的測試寫起來很簡單,就是挺多東西需要配置的,比較麻煩,記錄在后文
安裝依賴
- @vue/test-utils
vue2項目安裝:npm install --save-dev @vue/test-utils@1
不指定的話默認(rèn)安裝最新,適合vue3項目吧 - jest
- vue-jest:為了處理.vue文件
npm install --save-dev @vue/vue2-jest@29
(最后寫jest版本) - babel-jest
- jest-environment-jsdom
jest版本在27以上,是要安裝jest-environment-jsdom
的
其他版本下如果報錯:
[vue-test-utils]: window is undefined, vue-test-utils needs to be run in a browser environment.
You can run the tests in node using jsdom
可以嘗試:npm install --save-dev jsdom jsdom-global
// 在測試的設(shè)置 / 入口中
require('jsdom-global')()
package.json配置
加一條就好
"scripts": {
"test": "jest"
},
jest配置
可以配在package.json
的jest選項中
也可以新建jest.config.js
,我選了后者
module.exports = {
moduleFileExtensions: [
'js',
'vue'
],
transform: {
'^.+\\.vue$': '<rootDir>/node_modules/@vue/vue2-jest',
'^.+\\.js$': '<rootDir>/node_modules/babel-jest'
},
moduleNameMapper: { // webpack中設(shè)置了別名,@設(shè)置為/src 的別名,就需要配這個
'^@/(.*)$': '<rootDir>/src/$1'
},
testMatch: ['**/__tests__/**/*.spec.js'],
transformIgnorePatterns: ['<rootDir>/node_modules/'],
testEnvironment: "jsdom" // jest v27以上要加
}
??:
官網(wǎng)提到的一個注意點:
如果你使用了 Babel 7 或更高版本,
你需要在你的 devDependencies 里添加 babel-bridge
($ npm install --save-dev babel-core@^7.0.0-bridge.0)。
我在運行時有相關(guān)的報錯提示,所以我也按照這樣安裝了
如果你也有的話,可以參考一下
測試文件目錄
新建__tests__
目錄,放在src目錄下可以,根目錄下也可以
(注意別少打s)
目錄下的測試文件擴(kuò)展名應(yīng)該是.spec.js
或者test.js
,我選了前者
這個也可以改,想改去找jest文檔————
編寫setup.js
通常用于執(zhí)行一些全局的設(shè)置或引入一些測試所需的全局依賴,以確保這些設(shè)置和依賴在所有測試文件中都可用!
jest.config.js
中新加一條:setupFiles: ["./__tests__/setup.js"]
__test__
文件夾下新建setup.js
1.項目中用到的uni或者wx的api是識別不了的,所以放在這里預(yù)先配置一下
2.在Vue.prototype上掛載的比如$toast、$api,$store、直接用this.調(diào)用的時候也是識別不了的,也要在這配置一下
localVue可以理解成創(chuàng)建本地的vue實例,可以使用localVue.prototype
掛載一些東西而不會污染到真正的Vue.prototype
,我在這掛到全局了,實際上可以在每個單獨的測試文件中都create新的
import { createLocalVue } from "@vue/test-utils";
import Vuex from 'vuex'
import axios from 'axios'
const CODE = '用戶登錄憑證';
// 創(chuàng)建的一個 Vue 的本地拷貝
const localVue = createLocalVue()
localVue.use(Vuex)
const store = new Vuex.Store({
state: {},
mutations: {
// 這里如果只是為了代碼里不卡住,直接jest.fn()就可以
login: jest.fn((state, userInfo) => state.userInfo = userInfo),
setFlag: jest.fn((state,param) => state.flag = param.flag),
logout: jest.fn((state) => state.userInfo = {})
}
})
localVue.prototype.$store = store
localVue.prototype.$toast = jest.fn()
// 后面很多場景的使用是const {confirm} = await this.$modal(xxx), 這里直接模擬cofirm為true
localVue.prototype.$modal = jest.fn(() => Promise.resolve({ confirm: true }))
localVue.prototype.$api = {
student: {
studentLogin: jest.spyOn(axios, 'post')
},
}
global.uni = {
showLoading: jest.fn(),
hideLoading: jest.fn(),
navigateTo: jest.fn(),
switchTab: jest.fn(),
getStorageSync: jest.fn(),
setStorageSync: jest.fn(),
login: jest.fn(() => Promise.resolve([,CODE]))
}
global.setValue = (target, value) => {
target.element.value = value
target.trigger('input')
}
global.wx = global.uni
global.localVue = localVue
ps:這里掛了一個全局的方法setValue
,因為官方的那個我使用會報錯顯示沒有setValue(),查看setValue(),不知道是不是因為我的input是小程序的??
編寫第一個測試文件
對組件StudentLogin.vue
,新建studentLogin.spec.js
變更一個響應(yīng)式屬性之后,為了斷言這個變化,測試需要等待 Vue 完成更新,可以
- await vm.nextTick() 2. await 操作,比如trigger
import { shallowMount } from "@vue/test-utils";
import StudentLogin from '@/pages/student-login/student-login'
const TEST_VALUE = '123456'
const TEST_TIP = {
NO_NUMBER: '請?zhí)顚憣W(xué)號!',
NO_PASSWORD: '請?zhí)顚懨艽a!'
}
// describe(name, fn): 表示一組測試,如果沒有describe,那整個測試文件就是一個describe。name是這組測試的名字,fn是這組測試要執(zhí)行的函數(shù)。
describe('StudentLogin.vue', () => {
let wrapper;
beforeEach(() => {
// shallowMount和mount區(qū)別在于不會掛載子組件,比較適合單元測試,子組件的測試邏輯單獨寫
wrapper = shallowMount(StudentLogin, {
localVue
})
})
// formSubmit觸發(fā)時,輸入賬號沒輸入密碼,提示請?zhí)顚懨艽a!
test('if formSubmit triggered with number but no password, show tip', async () => {
setValue(wrapper.find('input[name="number"]'), TEST_VALUE)
await wrapper.vm.$nextTick();
await wrapper.find('.submit-btn').trigger('click')
expect(localVue.prototype.$toast).toBeCalledWith('error', TEST_TIP.NO_PASSWORD)
})
// formSubmit調(diào)用后,應(yīng)該發(fā)起請求
it('if formSubmit done, send request', async () => {
setValue(wrapper.find('input[name="number"]'), TEST_VALUE)
setValue(wrapper.find('input[name="password"]'), TEST_VALUE)
await wrapper.vm.formSubmit()
expect(localVue.prototype.$api.student.studentLogin).toBeCalled();
expect(localVue.prototype.$api.student.studentLogin).toBeCalledWith(TEST_VALUE, TEST_VALUE, CODE)
})
// 銷毀所有被創(chuàng)建的 Wrapper 實例
enableAutoDestroy(afterEach)
})
jest.fn()和jest.spyOn()
承接上文:
輕輕記錄一下jest.fn()
和jest.spyOn()
他們都是用來模擬函數(shù)的行為,都會跟蹤函數(shù)的調(diào)用和傳參
區(qū)別:jest.fn()
是創(chuàng)建一個全新的模擬函數(shù),jest.spyOn()
一般是模擬對象上的現(xiàn)有方法
比如
頁面需要axios發(fā)請求,但是我們測試的時候不需要實際調(diào)用,
就可以利用
localVue.prototype.$api = {
student: {
studentLogin: jest.spyOn(axios, 'post')
},
}
使用場景非常多,后文也會涉及
他們兩返回的其實就是mockFn,在jest官網(wǎng)有非常多對mockFn的操作
指路:mockFn
我常用的一個mockFn.mockResolvedValue(value)
例如:
測試這個函數(shù),是否成功發(fā)送請求,但我們無需發(fā)送真的請求,就可以模擬返回值
async getList() {
const { data } = await this.$api.student.getData()
this.list = data
}
// test.spec.js
test('', async () => {
localVue.prototype.$api.student.getData.mockResolvedValue({
list: [1,2,3]
})
await wrapper.vm.getList()
expect(wrapper.list.length).toBe(3)
})
??提醒一下自己,注意:
比如說我們要斷言,trigger某個操作或者更新了頁面之后,某個函數(shù)應(yīng)該要被調(diào)用
會使用
const spy = jest.spyOn(wrapper.vm, 'someFunction')
expect(spy).toBeCalled()
但要注意這個必須要寫在更新操作之前,如果寫在之后是會斷言錯誤的
?? jest.spyOn
寫在了trigger之后,也就是開始跟蹤的時候已經(jīng)觸發(fā)完了,
那么expect(infoSpy).toBeCalled()
就會失敗
test('if term picker triggered', async () => {
const picker = wrapper.findComponent('picker')
await picker.trigger("change", 1);
const infoSpy = jest.spyOn(wrapper.vm, 'getInfo')
expect(wrapper.vm.termIndex).toBe(1)
expect(infoSpy).toBeCalled()
})
jest 解析scss失敗
比如這個頁面有引入scss:import { THEME_COLOR } from "@/uni.scss";
如果不做配置的話就會報錯
解決方法:
新建一個styleMock.js
// styleMock.js
module.exports = {
process() {
return {
code: `module.exports = {};`,
};
},
};
然后在jest.config.js
中配置transform
:
transform: {
'^.+\\.vue$': '<rootDir>/node_modules/@vue/vue2-jest',
'^.+\\.js$': '<rootDir>/node_modules/babel-jest',
'\\.(css|less|scss|sass)$': '<rootDir>/styleMock.js',
},
然后運行npm run test
,如果還是沒生效,可以試試關(guān)閉編輯器重新啟動
測試vuex
這里不提官網(wǎng)有的部分,有需要可自查
在組件中測試vuex
目前場景是這個組件在計算屬性中使用了mapState
computed: {
... mapState(['flag', 'userInfo'])
}
然后當(dāng)userInfo.level = 1 && flag = 1
時候要渲染某容器,我需要測試這個,那么就需要修改state
中的數(shù)據(jù)
由于前面在setup.js中已經(jīng)在localVue上安裝了vuex,這里就通過localVue
來訪問
localVue.prototype.$store.state.flag = 1
localVue.prototype.$store.state.userInfo = { level: 1 }
如果使用$store.commit()
的話,就要在setup.js中l(wèi)ocalVue.prototype.$store上用jest.fn()具體實現(xiàn)mutation中的方法了,只是簡單的用jest.fn()模擬是不生效的
像這樣??
const store = new Vuex.Store({
state: {},
mutations: {
// 這里如果只是為了代碼里不卡住,直接jest.fn()就可以
login: jest.fn((state, userInfo) => state.userInfo = userInfo),
setFlag: jest.fn((state,param) => state.flag = param.flag),
logout: jest.fn((state) => state.userInfo = {})
}
})
??:更改完數(shù)據(jù)后,要等待頁面更新,記得await nextTick()一下,否則斷言會失敗
$refs
類似于這樣的代碼:
close() {
// 清除校驗結(jié)果
this.$refs.form.clearValidate();
this.$emit('close');
},
this.$refs.form.clearValidate();
會報錯,提示找不到clearValidate這個function
解決方法1: 模擬一個form塞在stubs里
// 這里要寫的是組件的名字,不是ref設(shè)置的名字
const UniForms = {
render: jest.fn(),
methods: {
validate: () => {},
clearValidate:() => {}
}
}
wrapper = shallowMount(ForgetPassword, {
localVue,
stubs: {
UniForms
}
})
(模板上<uni-forms ref="form"></uni-forms>
)
但我這個例子用這個方法不太行,會影響我別的測試(一些元素渲染失敗,wrapper.find時會找不到)
先記錄在這吧
解決方法2:
加一行??wrapper.vm.$refs.form.clearValidate = jest.fn()
如果有要返回的數(shù)據(jù),可以在jest.fn()
中直接模擬
比如說:
我們需要拿到返回的password、email,簡單的jest.fn()
無法滿足需求
const { password, email } = await this.$refs.form.validate();
設(shè)定jest.fn()模擬的函數(shù),返回成功值
wrapper.vm.$refs.form.validate = jest.fn(() => Promise.resolve({
password: 1,
email: 1
}))
后續(xù):
又有一處用到$refs:
mounted() {
this.$refs.form.setRules(this.formRules);
}
這次是在mounted()里使用,方法2就用不了了,因為需要先mount(wrapper),才能拿到wrapper.vm,但這里又是要在mounted中執(zhí)行的,假如我們使用wrapper.vm.$refs.form.setRules = jest.fn()
其實就已經(jīng)晚了,mounted已經(jīng)執(zhí)行完了
這個時候就可以用方法1~
定時器
檢驗有關(guān)定時器的方法
setTime(number) {
this.codeText = `倒計時${number}s`;
if(!number) {
this.codeText = '發(fā)送驗證碼';
this.isSending = false;
this.timer = null;
return;
} else {
number--;
}
this.timer = setTimeout(() => {
this.setTime(number);
}, 1000);
},
使用jest.useFakeTimers()
指定全局使用假的定時器apijest.advanceTimersByTime(1000)
模擬時間快進(jìn)1s
jest.useFakeTimers()
const sendCodeBtn = wrapper.findComponent('.send-code')
test('if setTime triggered 60, change btn content and start countdown', async () => {
const setTimeSpy = jest.spyOn(wrapper.vm, 'setTime')
await wrapper.vm.setTime(60)
expect(sendCodeBtn.text()).toBe('倒計時60s')
// 過一秒
jest.advanceTimersByTime(1000)
expect(setTimeSpy).toBeCalledWith(60 - 1)
})
test('if setTime triggered 0, change btn content and close timer', async () => {
await wrapper.vm.setTime(0)
expect(sendCodeBtn.text()).toBe('發(fā)送驗證碼')
// 過一秒
jest.advanceTimersByTime(1000)
expect(wrapper.vm.timer).toBe(null)
})
測試函數(shù)調(diào)用n次
本來想測
1.titleInput或contentInput無內(nèi)容時 => 提示’請輸入必要內(nèi)容’
2.titleInput和contentInput都有內(nèi)容時 => 不顯示提示
(錯誤寫法??)
test("", async () => {
await form.trigger('submit')
expect(localVue.prototype.$toast).toHaveBeenCalledWith('none', '請輸入必要內(nèi)容')
setValue(titleInput, TEST_VALUE)
await form.trigger('submit')
expect(localVue.prototype.$toast).toHaveBeenCalledWith('none', '請輸入必要內(nèi)容')
setValue(contentInput, TEST_VALUE)
await form.trigger('submit')
expect(localVue.prototype.$toast).not.toHaveBeenCalled()
});
但上面這種寫法是錯的,實際上localVue.prototype.$toast
的調(diào)用是累積的,不是相互隔離的,第三次expect(localVue.prototype.$toast)
的時候?qū)嶋H上已經(jīng)被調(diào)用三次了,那么not.toHaveBeenCalled()
就不可能通過測試
這時候應(yīng)該使用toHaveBeenNthCalledWidth()
,第一個參數(shù)寫n,表示第n次
第三次的時候不應(yīng)該被調(diào)用,就用toHaveBeenCalledTimes()
判斷總調(diào)用次數(shù)
test("", async () => {
await form.trigger('submit')
expect(localVue.prototype.$toast).toHaveBeenNthCalledWith(1, 'none', '請輸入必要內(nèi)容')
setValue(titleInput, TEST_VALUE)
await form.trigger('submit')
expect(localVue.prototype.$toast).toHaveBeenNthCalledWith(2, 'none', '請輸入必要內(nèi)容')
setValue(contentInput, TEST_VALUE)
await form.trigger('submit')
expect(localVue.prototype.$toast).not.toHaveBeenCalledTimes(3);
});
手動調(diào)用生命周期
比如說
(onLoad是小程序里的生命周期)
onLoad({code}) {
this.code = +code;
// 每10min刷新一次
if(!this.code) {
this.getSignInCode();
this.timer = setInterval(() => { this.getSignInCode() }, 600000);
}
}
這里想測試code為0的時候是否調(diào)用了函數(shù)getSignInCode,且過了10min是否再次調(diào)用
我想手動調(diào)用onLoad(),onLoad并不在wrapper.vm
上,不能通過wrapper.vm.onLoad
訪問
可以通過兩種方式找到:(這個組件名叫ShowQRcode
)
ShowQRcode.onLoad({ code: 1 })
wrapper.vm.$options.onLoad({ code: 1 })
但都會報錯:this.getSignInCode is not a function
,因為getSignInCode是在wrapper.vm
上的,所以這里要更改this指向ShowQRcode.onLoad.call(wrapper.vm, {code: 0 })
test('', () => {
const signInSpy = jest.spyOn(wrapper.vm, 'getSignInCode')
ShowQRcode.onLoad.call(wrapper.vm, { code: 0 })
expect(signInSpy).toHaveBeenCalledTimes(1)
jest.advanceTimersByTime(600000)
expect(signInSpy).toHaveBeenCalledTimes(2)
})
處理其他模塊導(dǎo)入的函數(shù)
場景:
import { uploadImg } from '@/util/uploadImg.js';
async selectImg(res) {
// 上傳圖片
const { url } = await uploadImg(res.tempFilePaths, 'files')
this.imgPaths.push(url[0]);
}
如果要測試selectImg()
,當(dāng)執(zhí)行到uploadImg()
就會報錯
我們就可以利用jest.mock
來模擬這個模塊
記錄一下jest.mock的簡單使用:
官網(wǎng)的例子:
// banana.js
export default () => 'banana';
// test.spec.js
// 后續(xù)的測試中,任何導(dǎo)入./banana模塊的代碼將會被自動模擬,而不是實際的banana.js模塊
jest.mock('./banana');
// 這個導(dǎo)入的bannana就被自動模擬了
const banana = require('./banana');
// 不會返回banana,因為被模擬了,默認(rèn)返回undefined
banana(); // will return 'undefined'
還可以接收一個函數(shù),顯式指定模塊導(dǎo)出的內(nèi)容
// 相當(dāng)于
// const mockFn = jest.fn(() => 'bannana'
// export default mockFn
jest.mock('./bannana', () => {
return jest.fn(() => 'bannana');
});
const bannana = require('./bannana');
bannana(); // Will return 'bannana';
所以這里就這樣寫:
// 相當(dāng)于
// export const uploadImg = jest.fn(() => Promse.resolve({ data: TEST_UPLOAD_RESPONSE}))
jest.mock('@/util/uploadImg.js', () => ({
otherFunction: xxx,
uploadImg: jest.fn(() => Promise.resolve({ data: TEST_UPLOAD_RESPONSE }))
}));
測試插槽
項目比較簡單,用插槽的地方很少,甚至沒用到作用域插槽
這里只記錄最簡單的方法
官網(wǎng)是有例子的:測試插槽
就是在shallowMount的時候配置slots
beforeEach(() => {
wrapper = shallowMount(Detail, {
localVue,
slots: {
list: '<view>list</view>',
operation: '<view>operation</view>'
}
})
})
這里slots配置的就是模擬傳入插槽的內(nèi)容
比如list: '<view>list</view>'
,就是該組件內(nèi)有一個插槽出口<slot name="list"></slot>
然后我們模擬傳入這個插槽的內(nèi)容是<view>list</view>
之后打印wrapper.html()
會發(fā)現(xiàn)插槽出口確實都被替換成了我們預(yù)設(shè)的內(nèi)容
只需要斷言expect(wrapper.html()).toContain('<view>list</view>')
即可完成測試
這里還出現(xiàn)一個問題,我有一個插槽出口長這樣??
<slot name="top">
<view class="top__button"
v-if="flag === 'xxx'">
<text>{{ xxx }}</text>
</view>
</slot>
在插槽中指定了默認(rèn)內(nèi)容,且默認(rèn)內(nèi)容要通過v-if控制顯示隱藏
并且這個地方我也寫了一個測試,是測試top__button的顯隱
如果我一開始預(yù)設(shè)的時候,預(yù)設(shè)了插槽top的內(nèi)容,就會導(dǎo)致這個測試失敗,因為找不到top__button了,直接被替換成了我預(yù)設(shè)的內(nèi)容
其實失敗的原因是我兩個測試共用了一個wrapper的配置(習(xí)慣寫在beforeEach里)
解決的方法就是在這個測試中,單獨的再重新創(chuàng)建一個wrapper,不要預(yù)設(shè)slots就好
補充:
測試作用域插槽文章來源:http://www.zghlxwxcb.cn/news/detail-742044.html
參考:
https://juejin.cn/post/7119314584371986468?searchId=2023092122585499D5137C15C4283D9452
https://blog.csdn.net/pk142536/article/details/122255192
https://zhuanlan.zhihu.com/p/457648810文章來源地址http://www.zghlxwxcb.cn/news/detail-742044.html
到了這里,關(guān)于記錄使用vue-test-utils + jest 在uniapp中進(jìn)行單元測試的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!