Vue 業(yè)務(wù)系統(tǒng)如何落地單元測試
點擊上方 程序員成長指北,關(guān)注公眾號
回復1,加入高級Node交流群
今日文章由作者@愚坤(秦少衛(wèi))Node.js技術(shù)棧@五月君 授權(quán)分享, 曾就職優(yōu)信二手車,現(xiàn)就職水滴籌大數(shù)據(jù)前端團隊,掘金優(yōu)秀作者。閱讀原文關(guān)注作者!
一直對單測很感興趣,但對單測覆蓋率、測試報告等關(guān)鍵詞懵懵懂懂,最近幾個月一直在摸索如何在Vue業(yè)務(wù)系統(tǒng)中落地單元測試,看到慢慢增長的覆蓋率,慢慢清晰的模塊,對單元測試的理解也比以前更加深入,也有一些心得和收獲。
今天把自己的筆記分享出來,和大家一起交流我在2個較為復雜的Vue業(yè)務(wù)系統(tǒng)中落地單測的一些思路和方法,算是入門實踐類的筆記,資深大佬還請?zhí)^。
大綱
-
定義 -
安裝與使用 -
常用API -
落地單元測試 -
演進:構(gòu)建可測試的單元模塊 -
可維護的單元模塊 -
回顧 -
討論 && Thank
1. 定義
單元測試定義:
單元測試是指對軟件中的最小可測試單元進行檢查和驗證。單元在質(zhì)量保證中是非常重要的環(huán)節(jié),根據(jù)測試金字塔原理,越往上層的測試,所需的測試投入比例越大,效果也越差,而單元測試的成本要小的多,也更容易發(fā)現(xiàn)問題。
也有不同的測試分層策略(冰淇淋模型、冠軍模型)。

2. 安裝與使用
1. vue項目添加 @vue/unit-jest 文檔
$ vue add @vue/unit-jest
安裝完成后,在package.json中會多出test:unit腳本選項,并生成jest.config.js文件。
// package.json
{
"name": "avatar",
"scripts": {
"test:unit": "vue-cli-service test:unit", // 新增的腳本
},
"dependencies": {
...
},
"devDependencies": {
...
},
}
生成測試報告的腳本:增加--coverage自定義參數(shù)
// package.json
{
"name": "avatar",
"scripts": {
"test:unit": "vue-cli-service test:unit",
"test:unitc": "vue-cli-service test:unit --coverage", // 測試并生成測試報告
},
"dependencies": {
...
},
"devDependencies": {
...
},
}
2. VScode vscode-jest-runner 插件配置
作用:VS Code打開測試文件后,可直接運行用例。
運行效果:
不通過效果:
安裝插件:https://marketplace.visualstudio.com/items?itemName=firsttris.vscode-jest-runner配置項:設(shè)置 => jest-Runner Config
-
Code Lens Selector:匹配的文件,**/*.{test,spec}.{js,jsx,ts,tsx} -
Jest Command:定義Jest命令,默認為Jest 全局命令。
將Jest Command替換為 test:unit,使用vue腳手架提供的 test:unit 進行單元測試。
3. githook 配置
作用:在提交時執(zhí)行所有測試用例,有測試用例不通過或覆蓋率不達標時取消提交。

安裝:
$ npm install husky --save-dev
配置:
// package.json
{
"name": "avatar",
"scripts": {
"test:unit": "vue-cli-service test:unit",
"test:unitc": "vue-cli-service test:unit --coverage", // 測試并生成測試報告
},
"husky": {
"hooks": {
"pre-commit": "npm run test:unitc" // commit時執(zhí)行參單元測試 并生成測試報告
}
},
}
設(shè)置牽引指標:jest.config.js,可全局設(shè)置、對文件夾設(shè)置、對單個文件設(shè)置。
module.exports = {
preset: '@vue/cli-plugin-unit-jest',
timers: 'fake',
coverageThreshold: {
global: { // 全局
branches: 10,
functions: 10,
lines: 10,
statements: 10
},
'./src/common/**/*.js': { // 文件夾
branches: 0,
statements: 0
},
'./src/common/agoraClientUtils.js': { // 單個文件
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
}
4. 測試報告
生成的測試報告在跟目錄下的coverage文件夾下,主要是4個指標。
語句覆蓋率(statement coverage)每個語句是否都執(zhí)行 分支覆蓋率(branch coverage)每個if代碼塊是否都執(zhí)行 函數(shù)覆蓋率(function coverage)每個函數(shù)是否都調(diào)用 行覆蓋率(line coverage) 每一行是否都執(zhí)行了
根目錄截圖
文件夾目錄截圖:三種顏色代表三種狀態(tài):紅色、黃色、綠色。
單個文件截圖:紅色行為未覆蓋,綠色行為運行次數(shù)。
3. 常用API
拋磚引玉,只展示簡單的用法,具體可參見文檔。
Jest常用方法:文檔
// 例子
describe('versionToNum 版本號轉(zhuǎn)數(shù)字', () => {
it('10.2.3 => 10.2', () => {
expect(versionToNum('10.2.3')).toBe(10.2)
})
it('11.2.3 => 11.2', () => {
expect(versionToNum('11.2.3')).toBe(11.2)
})
})
/*------------------------------------------------*/
// 值對比
expect(2 + 2).toBe(4);
expect(operationServe.operationPower).toBe(true)
// 對象對比
expect(data).toEqual({one: 1, two: 2});
// JSON 對比
expect(data).toStrictEqual(afterJson)
// 每次執(zhí)行前
beforeEach(() => {
// do some thing....
// DOM 設(shè)置
document.body.innerHTML = `<div id="pc" class="live-umcamera-video" style="position: relative;">
<div style="width:200px; height:300px; position:absolute; top:20px; left:500px;">
<video style="width:300px; height:400px;"
autoplay="" muted="" playsinline=""></video>
</div>
</div>`
})
// Mock
const getCondition = jest.fn().mockImplementation(() => Promise.resolve({ ret: 0, content: [{ parameterName: 'hulala' }] }))
// Promise 方法
it('獲取預置埋點 - pages', () => {
return getCondition('hz', 'pages').then(() => {
// logType不包含presetEvent、不等于 pages,獲取預置埋點
expect($api.analysis.findPresetList).toBeCalled()
})
// 定時器方法
it('定時器 新建 執(zhí)行', () => {
const timer = new IntervalStore()
const callback = jest.fn()
timer.start('oneset', callback, 2000)
expect(callback).not.toBeCalled()
jest.runTimersToTime(2000) // 等待2秒
expect(callback).toBeCalled()
})
@vue/test-utils常用方法:文檔
// 例子
import { mount } from '@vue/test-utils'
import Counter from './counter'
describe('Counter', () => {
// 現(xiàn)在掛載組件,你便得到了這個包裹器
const wrapper = mount(Counter)
it('renders the correct markup', () => {
expect(wrapper.html()).toContain('<span class="count">0</span>')
})
// 也便于檢查已存在的元素
it('has a button', () => {
expect(wrapper.contains('button')).toBe(true)
})
})
/*------------------------------------------------*/
import { shallowMount, mount, render, renderToString, createLocalVue } from '@vue/test-utils'
import Component from '../HelloWorld.vue'
// router模擬
import VueRouter from 'vue-router'
const localVue = createLocalVue()
localVue.use(VueRouter)
shallowMount(Component, { localVue })
// 偽造
const $route = {
path: '/some/path'
}
const wrapper = shallowMount(Component, {
mocks: {
$route
}
})
// store 模擬
const store = new Vuex.Store({
state: {},
actions
})
shallowMount(Component, { localVue, store })
it('錯誤信息展示', async () => {
// shallowMount 入?yún)⒛M
const wrapper = shallowMount(cloudPhone, {
propsData: {
mosaicStatus: false,
customerOnLine: true,
cloudPhoneState: false,
cloudPhoneError: true,
cloudPhoneTip: '發(fā)生錯誤',
delay: ''
}
})
// 子組件是否展示
expect(wrapper.getComponent(Tip).exists()).toBe(true)
// html判斷
expect(wrapper.html().includes('發(fā)生錯誤')).toBe(true)
// DOM 元素判斷
expect(wrapper.get('.mosaicStatus').isVisible()).toBe(true)
// 執(zhí)行點擊事件
await wrapper.find('button').trigger('click')
// class
expect(wrapper.classes()).toContain('bar')
expect(wrapper.classes('bar')).toBe(true)
// 子組件查找
wrapper.findComponent(Bar)
// 銷毀
wrapper.destroy()
//
wrapper.setData({ foo: 'bar' })
// axios模擬
jest.mock('axios', () => ({
get: Promise.resolve('value')
}))
})
4. 落地單元測試
? 直接對一個較大的業(yè)務(wù)組件添加單元測試,需要模擬一系列的全局函數(shù),無法直接運行。
問題:
-
邏輯多:業(yè)務(wù)邏輯不清楚,1000+ 行 -
依賴多: $dayjs、$api、$validate、$route、$echarts、mixins、$store... -
路徑不一致:有 @、./、../
單元測試是用來對一個模塊、一個函數(shù)或者一個類來進行正確性檢驗的測試工作。-- 廖雪峰的官方網(wǎng)站
落地:
? 對業(yè)務(wù)邏輯關(guān)鍵點,抽出純函數(shù)、類方法、組件,并單獨增加測試代碼。
例子:獲取分組參數(shù),由7個接口聚合。
原有邏輯:系統(tǒng)參數(shù)存全局變量,自定義參數(shù)存全局變量
-
無法看出多少種類型與接口數(shù)量 -
無法在多個位置直接復用
getCondition (fIndex, oneFunnel) { // 添加限制條件,如果該事件沒有先拉取
const {biz, logType, event, feCreateType} = oneFunnel
return new Promise((resolve, reject) => {
// 私有限制條件為空,且不是預置事件 或 頁面組,就拉取私有限制條件
try {
this.$set(this.extraParamsList.parameterList, fIndex, {})
if (logType !== 'pages' && logType.indexOf('presetEvent') === -1) {
this.$api.analysis[`${logType}ParameterList`]({
biz: logType === 'server' && feCreateType === 0 ? '' : biz,
event: event,
terminal: this.customType[logType],
platform: logType === 'server' && feCreateType === 0 ? 'common' : '',
pageNum: -1
}).then(res => {
if (res.ret === 0) {
res.content.forEach(element => {
this.$set(this.extraParamsList.parameterList[fIndex], element.parameterName || element.parameter_name, element)
})
resolve()
} else {
reject('獲取事件屬性失敗,請聯(lián)系后臺管理員')
}
})
} else if ((logType === 'presetEvents' || logType === 'presetEventsApp')) {
this.$api.analysis.findPresetList({
biz,
appTerminal: logType,
operation: event
}).then(res => {
if (res.code === 0) {
res.data.forEach(item => {
item.description = item.name
this.$set(this.extraParamsList.parameterList[fIndex], item.name, item)
})
resolve()
}
})
} else {
resolve('無需拉取')
}
} catch (e) {
reject(e)
}
})
},
getGlobalCondition (funnelId) { // 獲取 全局 基礎(chǔ)選項
return new Promise((resolve, reject) => {
this.$api.analysis.getGlobalCondition({
funnelId: funnelId,
type: this.conditionMode
}).then(res => {
if (res.code === 0) {
const {bizList, expressions, expressionsNumber, comBizList} = res.data
this.bizList = Object.assign(...bizList)
this.comBizList = Object.assign(...comBizList)
this.comBizKeyList = Object.keys(this.comBizList)
this.operatorList = expressions
this.numberOperatorList = expressionsNumber
this.comBizKey = Object.keys(this.comBizList)
this.getComBizEvent()
resolve(res)
} else {
this.$message.error('獲取基礎(chǔ)選項失敗,請聯(lián)系后臺管理員')
reject('獲取基礎(chǔ)選項失敗,請聯(lián)系后臺管理員')
}
})
})
},
setCommonPropertiesList (data) { // 初始化 公共限制條件列表 commonPropertiesList
const commonPropertiesList = {
auto: data.h5AutoCommonProperties,
pages: data.h5PagesCommonProperties,
presetEvents: data.h5PresetCommonProperties, // h5 預置事件 公共屬性
customH5: data.h5CustomCommonProperties,
customApp: data.appCustomCommonProperties,
presetEventsApp: data.appPresetCommonProperties, // App 預置事件 公共屬性
server: data.serverCommonProperties,
customWeapp: data.weappCustomCommonProperties,
presetEventsWeapp: data.weappPresetCommonProperties, // Weapp 預置事件 公共屬性
presetEventsServer: data.serverPresetCommonProperties || [], // Server 預置事件 公共屬性
presetEventsAd: data.adPresetCommonProperties
}
for (let type in commonPropertiesList) { // 將parameter_name的值作為key,item作為value,組合為k-v形式
let properties = {}
if (!commonPropertiesList[type]) continue
commonPropertiesList[type].forEach(item => {
properties[item.parameter_name] = item
})
commonPropertiesList[type] = properties
}
this.commonPropertiesList = commonPropertiesList
},
拆分模塊后:建立GetParamsServer主類,該類由2個子類構(gòu)成,并聚合子類接口。
這是其中一個子類,獲取私有參數(shù)的單元測試:
import GetParamsServer, { GetPrivateParamsServer } from '@/views/analysis/components/getParamsServer.js'
describe('GetPrivateParamsServer 私有參數(shù)獲取', () => {
let $api
beforeEach(() => {
$api = {
analysis: {
findPresetList: jest.fn().mockImplementation(() => Promise.resolve({
code: 0, data: [{ name: 'hulala', description: '234234', data_type: 'event' }]
})), // 預置埋點
serverParameterList: jest.fn().mockImplementation(() => Promise.resolve({
ret: 0, content: [{ parameterName: 'hulala' }]
})), // 服務(wù)端埋點
autoParameterList: jest.fn().mockImplementation(() => Promise.resolve({
ret: 0, content: [{ parameter_name: 'hulala' }]
})), // H5全埋點
customH5ParameterList: jest.fn().mockImplementation(() => Promise.resolve({
ret: 0, content: [{ parameterName: 'hulala' }]
})), // H5自定義
customWeappParameterList: jest.fn().mockImplementation(() => Promise.resolve({
ret: 0, content: [{ parameter_name: 'hulala', description: '234234', data_type: 'event' }]
})), // Weapp自定義
customAppParameterList: jest.fn().mockImplementation(() => Promise.resolve({
ret: 0, content: [{ parameterName: 'hulala', description: 'asdfafd', data_type: 'event' }]
})) // App自定義
}
}
})
describe('GetPrivateParamsServer 不同類型獲取', () => {
it('獲取預置埋點 - pages', () => {
const paramsServer = new GetPrivateParamsServer()
paramsServer.initApi($api)
return paramsServer.getCondition('hz', 'pages').then(() => {
// logType不包含presetEvent、不等于 pages,獲取預置埋點
expect($api.analysis.findPresetList).toBeCalled()
})
})
it('獲取預置埋點 - presetEvent ', () => {
const paramsServer = new GetPrivateParamsServer()
paramsServer.initApi($api)
return paramsServer.getCondition('hz', 'presetEvent').then(() => {
// logType不包含presetEvent、不等于 pages,獲取預置埋點
expect($api.analysis.findPresetList).toBeCalled()
})
})
it('獲取非預置埋點 - 其他', () => {
const paramsServer = new GetPrivateParamsServer()
paramsServer.initApi($api)
return paramsServer.getCondition('hz', '12312').then(() => {
expect($api.analysis.findPresetList).not.toBeCalled()
})
})
it('獲取非預置埋點 - server', () => {
const paramsServer = new GetPrivateParamsServer()
paramsServer.initApi($api)
return paramsServer.getCondition('hz', 'server').then(() => {
expect($api.analysis.serverParameterList).toBeCalled()
})
})
it('獲取非預置埋點 - auto', () => {
const paramsServer = new GetPrivateParamsServer()
paramsServer.initApi($api)
return paramsServer.getCondition('hz', 'auto').then(() => {
expect($api.analysis.autoParameterList).toBeCalled()
})
})
it('獲取非預置埋點 - customH5', () => {
const paramsServer = new GetPrivateParamsServer()
paramsServer.initApi($api)
return paramsServer.getCondition('hz', 'customH5').then(() => {
expect($api.analysis.customH5ParameterList).toBeCalled()
})
})
it('獲取非預置埋點 - customWeapp', () => {
const paramsServer = new GetPrivateParamsServer()
paramsServer.initApi($api)
return paramsServer.getCondition('hz', 'customWeapp').then(() => {
expect($api.analysis.customWeappParameterList).toBeCalled()
})
})
it('獲取非預置埋點 - customApp', () => {
const paramsServer = new GetPrivateParamsServer()
paramsServer.initApi($api)
return paramsServer.getCondition('hz', 'customApp').then(() => {
expect($api.analysis.customAppParameterList).toBeCalled()
})
})
it('獲取非預置埋點 - 不存在類型', () => {
const paramsServer = new GetPrivateParamsServer()
paramsServer.initApi($api)
return paramsServer.getCondition('hz', '哈哈哈哈').then(res => {
expect(res.length).toBe(0)
})
})
})
describe('GetPrivateParamsServer 結(jié)果轉(zhuǎn)換為label', () => {
it('獲取預置埋點 - pages', () => {
const paramsServer = new GetPrivateParamsServer()
paramsServer.initApi($api)
return paramsServer.getConditionLabel('hz', 'pages').then((res) => {
expect(res.length).toBe(1)
expect(!!res[0].value).toBeTruthy()
expect(!!res[0].label).toBeTruthy()
expect(res[0].types).toBe('custom')
expect(res[0].dataType).toBe('event')
})
})
it('獲取非預置埋點 - customWeapp', () => {
const paramsServer = new GetPrivateParamsServer()
paramsServer.initApi($api)
return paramsServer.getConditionLabel('hz', 'customWeapp').then((res) => {
expect(res.length).toBe(1)
expect(!!res[0].value).toBeTruthy()
expect(!!res[0].label).toBeTruthy()
expect(res[0].types).toBe('custom')
expect(res[0].dataType).toBe('event')
})
})
it('獲取非預置埋點 - customApp', () => {
const paramsServer = new GetPrivateParamsServer()
paramsServer.initApi($api)
return paramsServer.getConditionLabel('hz', 'customApp').then((res) => {
expect(res.length).toBe(1)
expect(!!res[0].value).toBeTruthy()
expect(!!res[0].label).toBeTruthy()
expect(res[0].types).toBe('custom')
expect(res[0].dataType).toBe('event')
})
})
})
})
從測試用例看到的代碼邏輯:
-
6個接口 -
6種事件類型 -
類型與接口的對應關(guān)系 -
接口格式有三種
作用:
-
復用:將復雜的業(yè)務(wù)邏輯封閉在黑盒里,更方便復用。 -
質(zhì)量:模塊的功能通過測試用例得到保障。 -
維護:測試即文檔,方便了解業(yè)務(wù)邏輯。
實踐:在添加單測的過程中,抽象模塊,重構(gòu)部分功能,并對單一職責的模塊增加單測。
5. 演進:構(gòu)建可測試單元模塊
將業(yè)務(wù)代碼代碼演變?yōu)榭蓽y試代碼,重點在:
-
設(shè)計:將業(yè)務(wù)邏輯拆分為單元模塊(UI組件、功能模塊)。 -
時間:可行的重構(gòu)目標與重構(gòu)方法,要有長期重構(gòu)的心理預期。
為單一職責的模塊設(shè)計測試用例,才會對功能覆蓋的更全面,所以設(shè)計這一步尤為重要。
如果挽救一個系統(tǒng)的辦法是重新設(shè)計一個新的系統(tǒng),那么,我們有什么理由認為從頭開始,結(jié)果會更好呢? --《架構(gòu)整潔之道》
原來模塊也是有設(shè)計,我們?nèi)绾伪WC重構(gòu)后真的比之前更好嗎?還是要根據(jù)設(shè)計原則客觀的來判斷。
設(shè)計原則 SOLID:
-
SRP-單一職責 -
OCP-開閉:易與擴展,抗拒修改。 -
LSP-里氏替換:子類接口統(tǒng)一,可相互替換。 -
ISP-接口隔離:不依賴不需要的東西。 -
DIP-依賴反轉(zhuǎn):構(gòu)建穩(wěn)定的抽象層,單向依賴(例:A => B => C, 反例:A => B => C => A)。
在應接不暇的需求面前,還要拆模塊、重構(gòu)、加單測,無疑是增加工作量,顯得不切實際,《重構(gòu)》這本書給了我很多指導。
重構(gòu)方法:
-
預備性重構(gòu) -
幫助理解的重構(gòu) -
撿垃圾式重構(gòu)(營地法則:遇到一個重構(gòu)一個,像見垃圾一樣,讓你離開時的代碼比來時更干凈、健康) -
有計劃的重構(gòu)與見機行事的重構(gòu) -
長期重構(gòu)
業(yè)務(wù)系統(tǒng)1的模塊與UI梳理:
業(yè)務(wù)系統(tǒng)2的模塊與UI梳理:
6. 可維護的單元模塊
避免重構(gòu)后再次寫出壞味道的代碼,提取執(zhí)行成本更低的規(guī)范。
代碼壞味道:
-
神秘命名-無法取出好名字,背后可能潛藏著更深的設(shè)計問題。 -
重復代碼 -
過長函數(shù)-小函數(shù)、純函數(shù)。 -
過長參數(shù) -
全局數(shù)據(jù)-數(shù)量越多處理難度會指數(shù)上升。 -
可變數(shù)據(jù)-不知道在哪個節(jié)點修改了數(shù)據(jù)。 -
發(fā)散式變化-只關(guān)注當前修改,不用關(guān)注其他關(guān)聯(lián)。 -
霰彈式修改-修改代碼散布四處。 -
依戀情結(jié)-與外部模塊交流數(shù)據(jù)勝過內(nèi)部數(shù)據(jù)。 -
數(shù)據(jù)泥團-相同的參數(shù)在多個函數(shù)間傳遞。 -
基本類型偏執(zhí) -
重復的switch -
循環(huán)語句 -
冗贅的元素 -
夸夸其談通用性 -
臨時字段 -
過長的消息鏈 -
中間人 -
內(nèi)幕交易 -
過大的類 -
異曲同工的類 -
純數(shù)據(jù)類 -
被拒絕的遺贈-繼承父類無用的屬性或方法 -
注釋-當你感覺需要撰寫注釋時,請先嘗試重構(gòu),試著讓所有注釋都變得多余。
規(guī)范:
-
全局變量數(shù)量:20 ± -
方法方法行數(shù):15 ± -
代碼行數(shù):300-500 -
內(nèi)部方法、內(nèi)聯(lián)方法:下劃線開頭
技巧:
-
使用class語法:將緊密關(guān)聯(lián)的方法和變量封裝在一起。 -
使用Eventemitter 工具庫:實現(xiàn)簡單發(fā)布訂閱。 -
使用vue provide語法:傳遞實例。 -
使用koroFileHeader插件:統(tǒng)一注釋規(guī)范。 -
使用Git-commit-plugin插件:統(tǒng)一commit規(guī)范。 -
使用eslint + stylelint(未使用變量、誤改變量名、debugger,自動優(yōu)化的css)。
示例代碼:
/*
* @name: 輕量級message提示插件
* @Description: 模仿iview的$message方法,api與樣式保持一致。
*/
class Message {
constructor() {
this._prefixCls = 'i-message-';
this._default = {
top: 16,
duration: 2
}
}
info(options) {
return this._message('info', options);
}
success(options) {
return this._message('success', options);
}
warning(options) {
return this._message('warning', options);
}
error(options) {
return this._message('error', options);
}
loading(options) {
return this._message('loading', options);
}
config({ top = this._default.top, duration = this._default.duration }) {
this._default = {
top,
duration
}
this._setContentBoxTop()
}
destroy() {
const boxId = 'messageBox'
const contentBox = document.querySelector('#' + boxId)
if (contentBox) {
document.body.removeChild(contentBox)
}
this._resetDefault()
}
/**
* @description: 渲染消息
* @param {String} type 類型
* @param {Object | String} options 詳細格式
*/
_message(type, options) {
if (typeof options === 'string') {
options = {
content: options
};
}
return this._render(options.content, options.duration, type, options.onClose, options.closable);
}
/**
* @description: 渲染消息
* @param {String} content 消息內(nèi)容
* @param {Number} duration 持續(xù)時間
* @param {String} type 消息類型
*/
_render(content = '', duration = this._default.duration, type = 'info',
onClose = () => { }, closable = false
) {
// 獲取節(jié)點信息
const messageDOM = this._getMsgHtml(type, content, closable)
// 插入父容器
const contentBox = this._getContentBox()
contentBox.appendChild(messageDOM);
// 刪除方法
const remove = () => this._removeMsg(contentBox, messageDOM, onClose)
let removeTimer
if(duration !== 0){
removeTimer = setTimeout(remove, duration * 1000);
}
// 關(guān)閉按鈕
closable && this._addClosBtn(messageDOM, remove, removeTimer)
}
/**
* @description: 刪除消息
* @param {Element} contentBox 父節(jié)點
* @param {Element} messageDOM 消息節(jié)點
* @param {Number} duration 持續(xù)時間
*/
_removeMsg(contentBox, messageDOM, onClose) {
messageDOM.className = `${this._prefixCls}box animate__animated animate__fadeOutUp`
messageDOM.style.height = 0
setTimeout(() => {
contentBox.removeChild(messageDOM)
onClose()
}, 400);
}
/**
* @description: 獲取圖標
* @param {String} type
* @return {String} DOM HTML 字符串
*/
_getIcon(type = 'info') {
const map = {
info: `<svg style="color:#2db7f5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>`,
success: `<svg style="color:#19be6b" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>`,
warning: `<svg style="color:#ff9900" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>`,
error: `<svg style="color:#ed4014" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>`,
loading: `<svg style="color:#2db7f5" xmlns="http://www.w3.org/2000/svg" class="loading" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9.504 1.132a1 1 0 01.992 0l1.75 1a1 1 0 11-.992 1.736L10 3.152l-1.254.716a1 1 0 11-.992-1.736l1.75-1zM5.618 4.504a1 1 0 01-.372 1.364L5.016 6l.23.132a1 1 0 11-.992 1.736L4 7.723V8a1 1 0 01-2 0V6a.996.996 0 01.52-.878l1.734-.99a1 1 0 011.364.372zm8.764 0a1 1 0 011.364-.372l1.733.99A1.002 1.002 0 0118 6v2a1 1 0 11-2 0v-.277l-.254.145a1 1 0 11-.992-1.736l.23-.132-.23-.132a1 1 0 01-.372-1.364zm-7 4a1 1 0 011.364-.372L10 8.848l1.254-.716a1 1 0 11.992 1.736L11 10.58V12a1 1 0 11-2 0v-1.42l-1.246-.712a1 1 0 01-.372-1.364zM3 11a1 1 0 011 1v1.42l1.246.712a1 1 0 11-.992 1.736l-1.75-1A1 1 0 012 14v-2a1 1 0 011-1zm14 0a1 1 0 011 1v2a1 1 0 01-.504.868l-1.75 1a1 1 0 11-.992-1.736L16 13.42V12a1 1 0 011-1zm-9.618 5.504a1 1 0 011.364-.372l.254.145V16a1 1 0 112 0v.277l.254-.145a1 1 0 11.992 1.736l-1.735.992a.995.995 0 01-1.022 0l-1.735-.992a1 1 0 01-.372-1.364z" clip-rule="evenodd" />
</svg>`
}
return map[type]
}
/**
* @description: 獲取消息節(jié)點
* @param {String} type 類型
* @param {String} content 消息內(nèi)容
* @return {Element} 節(jié)點DOM對象
*/
_getMsgHtml(type, content) {
const messageDOM = document.createElement("div")
messageDOM.className = `${this._prefixCls}box animate__animated animate__fadeInDown`
messageDOM.style.height = 36 + 'px'
messageDOM.innerHTML = `
<div class="${this._prefixCls}message" >
${this._getIcon(type)}
<div class="${this._prefixCls}content-text">${content}</div>
</div>
`
return messageDOM
}
/**
* @description: 添加關(guān)閉按鈕
* @param {Element} messageDOM 消息節(jié)點DOM
*/
_addClosBtn(messageDOM, remove, removeTimer) {
const svgStr = `<svg class="${this._prefixCls}btn" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>`
const closBtn = new DOMParser().parseFromString(svgStr, 'text/html').body.childNodes[0];
closBtn.onclick = () => {
removeTimer && clearTimeout(removeTimer)
remove()
}
messageDOM.querySelector(`.${this._prefixCls}message`).appendChild(closBtn)
}
/**
* @description: 獲取父節(jié)點容器
* @return {Element} 節(jié)點DOM對象
*/
_getContentBox() {
const boxId = 'messageBox'
if (document.querySelector('#' + boxId)) {
return document.querySelector('#' + boxId)
} else {
const contentBox = document.createElement("div")
contentBox.id = boxId
contentBox.style.top = this._default.top + 'px'
document.body.appendChild(contentBox)
return contentBox
}
}
/**
* @description: 重新設(shè)置父節(jié)點高度
*/
_setContentBoxTop() {
const boxId = 'messageBox'
const contentBox = document.querySelector('#' + boxId)
if (contentBox) {
contentBox.style.top = this._default.top + 'px'
}
}
/**
* @description: 恢復默認值
*/
_resetDefault() {
this._default = {
top: 16,
duration: 2
}
}
}
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
module.exports = new Message();
} else {
window.$message = new Message();
}
6. 回顧
-
定義 -
安裝與使用(安裝、調(diào)試、git攔截、測試報告) -
常用API(jest、vue組件) -
落地單元測試(拆分關(guān)鍵模塊加單測) -
演進:構(gòu)建可測試單元模塊(設(shè)計原則、重構(gòu)) -
可維護的單元模塊(代碼規(guī)范)
落地線路:
① 安裝使用 => ② API學習 => ③ 落地:拆分關(guān)鍵模塊加單測 => ④ 演進:架構(gòu)設(shè)計與重構(gòu) => ⑤ 代碼規(guī)范
未來:
⑥ 文檔先行(待探索)
在較為復雜的業(yè)務(wù)系統(tǒng)開發(fā)過程中,從第一版代碼到逐步劃分模塊、增加單測,還是走了一段彎路。如果能夠養(yǎng)成文檔先行的習慣,先設(shè)計模塊、測試用例,再編寫代碼,會更高效。
理解:
-
單元測試有長期價值,也有執(zhí)行成本。 -
好的架構(gòu)設(shè)計是單測的土壤,為單一職責的模塊設(shè)計單測、增加單元測試更加順暢。 -
每個項目的業(yè)務(wù)形態(tài)與階段不一樣,不一定都適合,找到適合項目的平衡點。
7. 討論 && Thank
感謝各位能夠看到最后,前半部偏干,后半部分偏水,為內(nèi)部分享筆記,部分代碼和圖片經(jīng)過處理,重在分享和大家一起交流,懇請斧正,有收獲還請點贊收藏。
關(guān)于本文
-
作者:@愚坤(秦少衛(wèi)) -
https://juejin.cn/post/6978831511164289055 -
https://github.com/nihaojob

“分享、點贊、在看” 支持一波 
