Vue 项目如何做单元测试?

时间:2022-10-18 18:40:24

Vue 项目如何做单元测试?

一直对单测很感兴趣,但对单测覆盖率、测试报告等关键词懵懵懂懂,最近几个月一直在摸索如何在Vue业务系统中落地单元测试,看到慢慢增长的覆盖率,慢慢清晰的模块,对单元测试的理解也比以前更加深入,也有一些心得和收获。

一. 定义

单元测试定义:

单元测试是指对软件中的最小可测试单元进行检查和验证。单元在质量保证中是非常重要的环节,根据测试金字塔原理,越往上层的测试,所需的测试投入比例越大,效果也越差,而单元测试的成本要小的多,也更容易发现问题。

也有不同的测试分层策略(冰淇淋模型、冠军模型)。

二. 安装与使用

1. vue项目添加 @vue/unit-jest 文档 

  1. $ vue add @vue/unit-jest 

安装完成后,在package.json中会多出test:unit脚本选项,并生成jest.config.js文件。

  1. // package.json  
  2.  
  3.   "name": "avatar",  
  4.   "scripts": {  
  5.     "test:unit": "vue-cli-service test:unit", // 新增的脚本  
  6.   },  
  7.   "dependencies": {  
  8.     ...  
  9.   },  
  10.   "devDependencies": {  
  11.     ...  
  12.   },  

生成测试报告的脚本:增加--coverage自定义参数

  1. // package.json  
  2.  
  3.   "name": "avatar",  
  4.   "scripts": {  
  5.     "test:unit": "vue-cli-service test:unit",  
  6.     "test:unitc": "vue-cli-service test:unit  --coverage", // 测试并生成测试报告  
  7.   },  
  8.   "dependencies": {  
  9.     ...  
  10.   },  
  11.   "devDependencies": {  
  12.     ...  
  13.   },  

2. VScode vscode-jest-runner 插件配置

作用:VS Code打开测试文件后,可直接运行用例。

Vue 项目如何做单元测试?

运行效果:

Vue 项目如何做单元测试?

不通过效果:

安装插件:https://marketplace.visualstudio.com/items?itemName=firsttris.vscode-jest-runner配置项:设置 => 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 配置

作用:在提交时执行所有测试用例,有测试用例不通过或覆盖率不达标时取消提交。

Vue 项目如何做单元测试?

安装:

  1. $ npm install husky --save-dev 

配置:

  1. // package.json  
  2.  
  3.   "name": "avatar",  
  4.   "scripts": {  
  5.     "test:unit": "vue-cli-service test:unit",  
  6.     "test:unitc": "vue-cli-service test:unit  --coverage", // 测试并生成测试报告  
  7.   },  
  8.   "husky": {  
  9.     "hooks": {  
  10.       "pre-commit": "npm run test:unitc" // commit时执行参单元测试 并生成测试报告  
  11.     }  
  12.   },  

设置牵引指标:jest.config.js,可全局设置、对文件夹设置、对单个文件设置。

  1. module.exports = {  
  2.   preset: '@vue/cli-plugin-unit-jest',  
  3.   timers: 'fake',  
  4.   coverageThreshold: {  
  5.    global: { // 全局  
  6.       branches: 10, 
  7.       functions: 10,  
  8.       lines: 10,  
  9.       statements: 10  
  10.     },  
  11.     './src/common/**/*.js': { // 文件夹  
  12.       branches: 0,  
  13.       statements: 0  
  14.     },  
  15.     './src/common/agoraClientUtils.js': { // 单个文件  
  16.       branches: 80,  
  17.       functions: 80,  
  18.       lines: 80,  
  19.       statements: 80  
  20.     }  
  21.   }  

4. 测试报告

生成的测试报告在跟目录下的coverage文件夹下,主要是4个指标。

  •   语句覆盖率(statement coverage)每个语句是否都执行
  •   分支覆盖率(branch coverage)每个if代码块是否都执行
  •   函数覆盖率(function coverage)每个函数是否都调用
  •   行覆盖率(line coverage) 每一行是否都执行了

根目录截图文件夹目录截图:三种颜色代表三种状态:红色、黄色、绿色。单个文件截图:红色行为未覆盖,绿色行为运行次数。

Vue 项目如何做单元测试?

三. 常用API

抛砖引玉,只展示简单的用法,具体可参见文档。

Jest常用方法:文档

  1. // 例子  
  2. describe('versionToNum 版本号转数字', () => {  
  3.   it('10.2.3 => 10.2', () => {  
  4.     expect(versionToNum('10.2.3')).toBe(10.2)  
  5.   })  
  6.   it('11.2.3 => 11.2', () => {  
  7.     expect(versionToNum('11.2.3')).toBe(11.2)  
  8.   })  
  9. }) 
  10. /*------------------------------------------------*/  
  11. // 值对比  
  12. expect(2 + 2).toBe(4);   
  13. expect(operationServe.operationPower).toBe(true)  
  14. // 对象对比  
  15. expect(data).toEqual({one: 1, two: 2});   
  16. // JSON 对比  
  17. expect(data).toStrictEqual(afterJson) 
  18. // 每次执行前  
  19. beforeEach(() => {  
  20.  // do  some thing....  
  21.   // DOM 设置  
  22.   document.body.innerHTML = `<div id="pc" class="live-umcamera-video" style="position: relative;">  
  23.         <div style="width:200px; height:300px; position:absolute; top:20px; left:500px;">  
  24.             <video style="width:300px; height:400px;"  
  25.                 autoplay="" muted="" playsinline=""></video>  
  26.         </div>  
  27.     </div> 
  28. })  
  29. // Mock  
  30. const getCondition =  jest.fn().mockImplementation(() => Promise.resolve({ ret: 0, content: [{ parameterName: 'hulala' }] }))  
  31. // Promise 方法  
  32. it('获取预置埋点 - pages', () => {  
  33.   return getCondition('hz', 'pages').then(() => {  
  34.     // logType不包含presetEvent、不等于 pages,获取预置埋点  
  35.     expect($api.analysis.findPresetList).toBeCalled()  
  36. })  
  37. // 定时器方法    
  38. it('定时器 新建 执行', () => {  
  39.   const timer = new IntervalStore()  
  40.   const callback = jest.fn()  
  41.   timer.start('oneset', callback, 2000)  
  42.   expect(callback).not.toBeCalled()  
  43.   jest.runTimersToTime(2000) // 等待2秒  
  44.   expect(callback).toBeCalled()  
  45. })    

@vue/test-utils常用方法:文档

  1. // 例子  
  2. import { mount } from '@vue/test-utils'  
  3. import Counter from './counter'  
  4. describe('Counter', () => {  
  5.   // 现在挂载组件,你便得到了这个包裹器  
  6.   const wrapper = mount(Counter)  
  7.   it('renders the correct markup', () => {  
  8.     expect(wrapper.html()).toContain('<span class="count">0</span>')  
  9.   })  
  10.   // 也便于检查已存在的元素  
  11.   it('has a button', () => {  
  12.     expect(wrapper.contains('button')).toBe(true)  
  13.   })  
  14. })  
  15. /*------------------------------------------------*/  
  16. import { shallowMount, mount, render, renderToString, createLocalVue } from '@vue/test-utils'  
  17. import Component from '../HelloWorld.vue'  
  18. // router模拟  
  19. import VueRouter from 'vue-router'  
  20. const localVue = createLocalVue()  
  21. localVue.use(VueRouter)  
  22. shallowMount(Component, { localVue })  
  23. // 伪造  
  24. const $route = {  
  25.   path: '/some/path'  
  26.  
  27. const wrapper = shallowMount(Component, {  
  28.   mocks: {  
  29.     $route  
  30.   }  
  31. })  
  32. // store 模拟  
  33. const store = new Vuex.Store({  
  34.       state: {},  
  35.       actions  
  36.  })  
  37. shallowMount(Component, { localVue, store })  
  38. it('错误信息展示', async () => {  
  39.     // shallowMount  入参模拟  
  40.     const wrapper = shallowMount(cloudPhone, {  
  41.       propsData: {  
  42.         mosaicStatus: false,  
  43.         customerOnLine: true,  
  44.         cloudPhoneState: false,  
  45.         cloudPhoneError: true,  
  46.         cloudPhoneTip: '发生错误',  
  47.         delay: ''  
  48.       } 
  49.     })   
  50.      // 子组件是否展示  
  51.     expect(wrapper.getComponent(Tip).exists()).toBe(true)  
  52.     // html判断  
  53.     expect(wrapper.html().includes('发生错误')).toBe(true)  
  54.     // DOM 元素判断  
  55.     expect(wrapper.get('.mosaicStatus').isVisible()).toBe(true)  
  56.     // 执行点击事件  
  57.     await wrapper.find('button').trigger('click')  
  58.    // class  
  59.     expect(wrapper.classes()).toContain('bar')  
  60.     expect(wrapper.classes('bar')).toBe(true)  
  61.   // 子组件查找     
  62.    wrapper.findComponent(Bar) 
  63.     // 销毁  
  64.     wrapper.destroy()  
  65.     //   
  66.     wrapper.setData({ foo: 'bar' })   
  67.     // axios模拟  
  68.     jest.mock('axios', () => ({  
  69.       get: Promise.resolve('value')  
  70.     })) 
  71.    }) 

四. 落地单元测试

❌ 直接对一个较大的业务组件添加单元测试,需要模拟一系列的全局函数,无法直接运行。

问题:

  1.  逻辑多:业务逻辑不清楚,1000+ 行
  2.  依赖多:$dayjs、$api、$validate、$route、$echarts、mixins、$store...
  3.  路径不一致:有@、./、../

单元测试是用来对一个模块、一个函数或者一个类来进行正确性检验的测试工作。-- 廖雪峰的官方网站

落地:

✅ 对业务逻辑关键点,抽出纯函数、类方法、组件,并单独增加测试代码。

例子:获取分组参数,由7个接口聚合。

Vue 项目如何做单元测试?

Vue 项目如何做单元测试?

Vue 项目如何做单元测试?

原有逻辑:系统参数存全局变量,自定义参数存全局变量

  •  无法看出多少种类型与接口数量
  •  无法在多个位置直接复用 
  1. 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('获取事件属性失败,请联系后台管理员')              }            })          } 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 => {                  itemitem.description = item.name                  this.$set(this.extraParamsList.parameterList[fIndex], item.name, item)                })                resolve()              }            })          } else {            resolve('无需拉取')          }        } catch (e) {          reject(e)        }      })    },           getGlobalCondition (funnelId) { // 获取 全局 基础选项      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('获取基础选项失败,请联系后台管理员')            reject('获取基础选项失败,请联系后台管理员')          }        })      })    },           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个子类构成,并聚合子类接口。

Vue 项目如何做单元测试?

这是其中一个子类,获取私有参数的单元测试:

  1. import GetParamsServer, { GetPrivateParamsServer } from '@/views/analysis/components/getParamsServer.js'  
  2. describe('GetPrivateParamsServer 私有参数获取', () => {  
  3.   let $api  
  4.     beforeEach(() => {  
  5.       $api = {  
  6.         analysis: {  
  7.           findPresetList: jest.fn().mockImplementation(() => Promise.resolve({  
  8.             code: 0, data: [{ name: 'hulala', description: '234234', data_type: 'event' }]  
  9.           })), // 预置埋点  
  10.           serverParameterList: jest.fn().mockImplementation(() => Promise.resolve({  
  11.             ret: 0, content: [{ parameterName: 'hulala' }]  
  12.           })), // 服务端埋点  
  13.           autoParameterList: jest.fn().mockImplementation(() => Promise.resolve({  
  14.             ret: 0, content: [{ parameter_name: 'hulala' }]  
  15.           })), // H5全埋点  
  16.           customH5ParameterList: jest.fn().mockImplementation(() => Promise.resolve({  
  17.             ret: 0, content: [{ parameterName: 'hulala' }]  
  18.           })), // H5自定义  
  19.           customWeappParameterList: jest.fn().mockImplementation(() => Promise.resolve({  
  20.             ret: 0, content: [{ parameter_name: 'hulala', description: '234234', data_type: 'event' }]  
  21.           })), // Weapp自定义  
  22.           customAppParameterList: jest.fn().mockImplementation(() => Promise.resolve({  
  23.             ret: 0, content: [{ parameterName: 'hulala', description: 'asdfafd', data_type: 'event' }] 
  24.           })) // App自定义  
  25.         }  
  26.       }  
  27.     })  
  28.   describe('GetPrivateParamsServer 不同类型获取', () => {  
  29.     it('获取预置埋点 - pages', () => {  
  30.       const paramsServer = new GetPrivateParamsServer()  
  31.       paramsServer.initApi($api)  
  32.       return paramsServer.getCondition('hz', 'pages').then(() => {  
  33.         // logType不包含presetEvent、不等于 pages,获取预置埋点  
  34.         expect($api.analysis.findPresetList).toBeCalled()  
  35.       })  
  36.     })  
  37.     it('获取预置埋点 - presetEvent ', () => {  
  38.       const paramsServer = new GetPrivateParamsServer()  
  39.       paramsServer.initApi($api)  
  40.       return paramsServer.getCondition('hz', 'presetEvent').then(() => {  
  41.         // logType不包含presetEvent、不等于 pages,获取预置埋点  
  42.         expect($api.analysis.findPresetList).toBeCalled()  
  43.       })  
  44.     })  
  45.     it('获取非预置埋点 - 其他', () => {  
  46.       const paramsServer = new GetPrivateParamsServer()  
  47.       paramsServer.initApi($api)  
  48.       return paramsServer.getCondition('hz', '12312').then(() => {  
  49.         expect($api.analysis.findPresetList).not.toBeCalled()  
  50.       })  
  51.     })  
  52.     it('获取非预置埋点 - server', () => {  
  53.       const paramsServer = new GetPrivateParamsServer()  
  54.       paramsServer.initApi($api)  
  55.       return paramsServer.getCondition('hz', 'server').then(() => {  
  56.         expect($api.analysis.serverParameterList).toBeCalled()  
  57.       })  
  58.     })  
  59.     it('获取非预置埋点 - auto', () => {  
  60.       const paramsServer = new GetPrivateParamsServer()  
  61.       paramsServer.initApi($api)  
  62.       return paramsServer.getCondition('hz', 'auto').then(() => {  
  63.         expect($api.analysis.autoParameterList).toBeCalled()  
  64.       })  
  65.     })  
  66.     it('获取非预置埋点 - customH5', () => {  
  67.       const paramsServer = new GetPrivateParamsServer()  
  68.       paramsServer.initApi($api)  
  69.       return paramsServer.getCondition('hz', 'customH5').then(() => {  
  70.         expect($api.analysis.customH5ParameterList).toBeCalled()  
  71.       })  
  72.     })  
  73.     it('获取非预置埋点 - customWeapp', () => {  
  74.       const paramsServer = new GetPrivateParamsServer()  
  75.       paramsServer.initApi($api)  
  76.       return paramsServer.getCondition('hz', 'customWeapp').then(() => {  
  77.         expect($api.analysis.customWeappParameterList).toBeCalled()  
  78.       })  
  79.     })  
  80.     it('获取非预置埋点 - customApp', () => {  
  81.       const paramsServer = new GetPrivateParamsServer()  
  82.       paramsServer.initApi($api)  
  83.       return paramsServer.getCondition('hz', 'customApp').then(() => {  
  84.         expect($api.analysis.customAppParameterList).toBeCalled()  
  85.       })  
  86.     })  
  87.     it('获取非预置埋点 - 不存在类型', () => {  
  88.       const paramsServer = new GetPrivateParamsServer()  
  89.       paramsServer.initApi($api)  
  90.       return paramsServer.getCondition('hz', '哈哈哈哈').then(res => {  
  91.         expect(res.length).toBe(0)  
  92.       })  
  93.     })  
  94.   })  
  95.   describe('GetPrivateParamsServer 结果转换为label', () => {  
  96.     it('获取预置埋点 - pages', () => {  
  97.       const paramsServer = new GetPrivateParamsServer()  
  98.       paramsServer.initApi($api)  
  99.       return paramsServer.getConditionLabel('hz', 'pages').then((res) => {  
  100.         expect(res.length).toBe(1)  
  101.         expect(!!res[0].value).toBeTruthy()  
  102.         expect(!!res[0].label).toBeTruthy()  
  103.         expect(res[0].types).toBe('custom')  
  104.         expect(res[0].dataType).toBe('event')  
  105.       })  
  106.     })  
  107.     it('获取非预置埋点 - customWeapp', () => {  
  108.       const paramsServer = new GetPrivateParamsServer()  
  109.       paramsServer.initApi($api)  
  110.       return paramsServer.getConditionLabel('hz', 'customWeapp').then((res) => {  
  111.         expect(res.length).toBe(1)  
  112.         expect(!!res[0].value).toBeTruthy()  
  113.         expect(!!res[0].label).toBeTruthy()  
  114.         expect(res[0].types).toBe('custom')  
  115.         expect(res[0].dataType).toBe('event')  
  116.       })  
  117.     })  
  118.     it('获取非预置埋点 - customApp', () => {  
  119.       const paramsServer = new GetPrivateParamsServer()  
  120.       paramsServer.initApi($api)  
  121.       return paramsServer.getConditionLabel('hz', 'customApp').then((res) => {  
  122.         expect(res.length).toBe(1)  
  123.         expect(!!res[0].value).toBeTruthy()  
  124.         expect(!!res[0].label).toBeTruthy()  
  125.         expect(res[0].types).toBe('custom')  
  126.         expect(res[0].dataType).toBe('event')  
  127.       })  
  128.     })  
  129.   })  
  130. }) 

Vue 项目如何做单元测试?

从测试用例看到的代码逻辑:

  •  6个接口
  •  6种事件类型
  •  类型与接口的对应关系
  •  接口格式有三种

作用:

  •  复用:将复杂的业务逻辑封闭在黑盒里,更方便复用。
  •  质量:模块的功能通过测试用例得到保障。
  •  维护:测试即文档,方便了解业务逻辑。

实践:在添加单测的过程中,抽象模块,重构部分功能,并对单一职责的模块增加单测。

五. 演进:构建可测试单元模块

将业务代码代码演变为可测试代码,重点在:

  •  设计:将业务逻辑拆分为单元模块(UI组件、功能模块)。
  •  时间:可行的重构目标与重构方法,要有长期重构的心理预期。

为单一职责的模块设计测试用例,才会对功能覆盖的更全面,所以设计这一步尤为重要。

如果挽救一个系统的办法是重新设计一个新的系统,那么,我们有什么理由认为从头开始,结果会更好呢? --《架构整洁之道》

原来模块也是有设计,我们如何保证重构后真的比之前更好吗?还是要根据设计原则客观的来判断。

设计原则 SOLID:

  •  SRP-单一职责
  •  OCP-开闭:易与扩展,抗拒修改。
  •  LSP-里氏替换:子类接口统一,可相互替换。
  •  ISP-接口隔离:不依赖不需要的东西。
  •  DIP-依赖反转:构建稳定的抽象层,单向依赖(例:A => B => C, 反例:A  => B => C => A)。

在应接不暇的需求面前,还要拆模块、重构、加单测,无疑是增加工作量,显得不切实际,《重构》这本书给了我很多指导。

重构方法:

  •  预备性重构
  •  帮助理解的重构
  •  捡垃圾式重构(营地法则:遇到一个重构一个,像见垃圾一样,让你离开时的代码比来时更干净、健康)
  •  有计划的重构与见机行事的重构
  •  长期重构

业务系统1的模块与UI梳理:

Vue 项目如何做单元测试?

业务系统2的模块与UI梳理:

Vue 项目如何做单元测试?

六. 可维护的单元模块

避免重构后再次写出坏味道的代码,提取执行成本更低的规范。

代码坏味道:

  •  神秘命名-无法取出好名字,背后可能潜藏着更深的设计问题。
  •  重复代码
  •  过长函数-小函数、纯函数。
  •  过长参数
  •  全局数据-数量越多处理难度会指数上升。
  •  可变数据-不知道在哪个节点修改了数据。
  •  发散式变化-只关注当前修改,不用关注其他关联。
  •  霰弹式修改-修改代码散布四处。
  •  依恋情结-与外部模块交流数据胜过内部数据。
  •  数据泥团-相同的参数在多个函数间传递。
  •  基本类型偏执
  •  重复的switch
  •  循环语句
  •  冗赘的元素
  •  夸夸其谈通用性
  •  临时字段
  •  过长的消息链
  •  中间人
  •  内幕交易
  •  过大的类
  •  异曲同工的类
  •  纯数据类
  •  被拒绝的遗赠-继承父类无用的属性或方法
  •  注释-当你感觉需要撰写注释时,请先尝试重构,试着让所有注释都变得多余。

规范:

  •  全局变量数量:20 ±
  •  方法方法行数:15 ±
  •  代码行数:300-500
  •  内部方法、内联方法:下划线开头

技巧:

  •  使用class语法:将紧密关联的方法和变量封装在一起。
  •  使用Eventemitter 工具库:实现简单发布订阅。
  •  使用vue  provide语法:传递实例。
  •  使用koroFileHeader插件:统一注释规范。
  •  使用Git-commit-plugin插件:统一commit规范。
  •  使用eslint + stylelint(未使用变量、误改变量名、debugger,自动优化的css)。

示例代码:

  1. /*  
  2.  * @name: 轻量级message提示插件  
  3.  * @Description: 模仿iview的$message方法,api与样式保持一致。  
  4.  */ 
  5. class Message {  
  6.     constructor() {  
  7.         this._prefixCls = 'i-message-' 
  8.         this._default = {  
  9.             top: 16,  
  10.             duration: 2  
  11.         }  
  12.     }  
  13.     info(options) {  
  14.         return this._message('info', options);  
  15.     }  
  16.     success(options) {  
  17.         return this._message('success', options);  
  18.     }  
  19.     warning(options) {  
  20.         return this._message('warning', options);  
  21.     }  
  22.     error(options) {  
  23.         return this._message('error', options);  
  24.     }  
  25.     loading(options) {  
  26.         return this._message('loading', options);  
  27.     } 
  28.     config({ top = this._default.top, duration = this._default.duration }) {  
  29.         this._default = {  
  30.             top,  
  31.             duration  
  32.         }  
  33.         this._setContentBoxTop()  
  34.     }  
  35.     destroy() {  
  36.         const boxId = 'messageBox'  
  37.         const contentBox = document.querySelector('#' + boxId)  
  38.         if (contentBox) {  
  39.             document.body.removeChild(contentBox)  
  40.         }  
  41.         this._resetDefault()  
  42.     } 
  43.     /**  
  44.      * @description: 渲染消息  
  45.      * @param {String} type 类型  
  46.      * @param {Object | String} options 详细格式  
  47.      */  
  48.     _message(type, options) {  
  49.         if (typeof options === 'string') {  
  50.             options = {  
  51.                 content: options  
  52.             };  
  53.         }  
  54.         return this._render(options.content, options.duration, type, options.onClose, options.closable);  
  55.     } 
  56.     /**  
  57.      * @description: 渲染消息  
  58.      * @param {String} content 消息内容  
  59.      * @param {Number} duration 持续时间  
  60.      * @param {String} type 消息类型  
  61.      */  
  62.     _render(content = ''duration = this._default.duration, type = 'info' 
  63.         onClose = () => { }, closable = false  
  64.     ) {  
  65.         // 获取节点信息  
  66.         const messageDOM = this._getMsgHtml(type, content, closable)  
  67.         // 插入父容器  
  68.         const contentBox = this._getContentBox() 
  69.         contentBox.appendChild(messageDOM);  
  70.         // 删除方法  
  71.         const remove = () => this._removeMsg(contentBox, messageDOM, onClose)  
  72.         let removeTimer  
  73.         if(duration !== 0){  
  74.             removeTimer = setTimeout(remove, duration * 1000);  
  75.         }  
  76.         // 关闭按钮  
  77.         closable && this._addClosBtn(messageDOM, remove, removeTimer)  
  78.     }  
  79.     /**  
  80.      * @description: 删除消息  
  81.      * @param {Element} contentBox 父节点  
  82.      * @param {Element} messageDOM 消息节点  
  83.      * @param {Number} duration 持续时间  
  84.      */  
  85.     _removeMsg(contentBox, messageDOM, onClose) {  
  86.         messageDOM.className = `${this._prefixCls}box animate__animated animate__fadeOutUp`  
  87.         messageDOM.style.height = 0  
  88.         setTimeout(() => {  
  89.             contentBox.removeChild(messageDOM)  
  90.             onClose()  
  91.         }, 400);  
  92.     }  
  93.     /**  
  94.      * @description: 获取图标  
  95.      * @param {String} type  
  96.      * @return {String} DOM HTML 字符串  
  97.      */  
  98.     _getIcon(type = 'info') {  
  99.         const map = {  
  100.             info: `<svg style="color:#2db7f5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">  
  101.            <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" />  
  102.          </svg>`,  
  103.             success: `<svg style="color:#19be6b"  xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">  
  104.           <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" /> 
  105.           </svg>`,  
  106.            warning: `<svg style="color:#ff9900" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 
  107.             <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" />  
  108.          </svg>`,  
  109.             error: `<svg style="color:#ed4014" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">  
  110.            <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" /> 
  111.           </svg>`,  
  112.             loading: `<svg style="color:#2db7f5" xmlns="http://www.w3.org/2000/svg" class="loading" viewBox="0 0 20 20" fill="currentColor"> 
  113.             <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" /> 
  114.           </svg> 
  115.         }  
  116.         return map[type]  
  117.     }  
  118.     /**  
  119.      * @description: 获取消息节点  
  120.      * @param {String} type 类型  
  121.      * @param {String} content 消息内容  
  122.      * @return {Element} 节点DOM对象  
  123.      */  
  124.     _getMsgHtml(type, content) {  
  125.         const messageDOM = document.createElement("div")  
  126.         messageDOM.className = `${this._prefixCls}box animate__animated animate__fadeInDown`  
  127.         messageDOM.style.height = 36 + 'px'  
  128.         messageDOM.innerHTML = `  
  129.                 <div class="${this._prefixCls}message" >  
  130.                     ${this._getIcon(type)}  
  131.                     <div class="${this._prefixCls}content-text">${content}</div>  
  132.                 </div>  
  133.         `  
  134.         return messageDOM  
  135.     }  
  136.     /**  
  137.      * @description: 添加关闭按钮  
  138.      * @param {Element} messageDOM 消息节点DOM  
  139.      */  
  140.     _addClosBtn(messageDOM, remove, removeTimer) {  
  141.         const svgStr = `<svg class="${this._prefixCls}btn" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">  
  142.             <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />  
  143.         </svg> 
  144.         const closBtn = new DOMParser().parseFromString(svgStr, 'text/html').body.childNodes[0];  
  145.         closBtn.onclick = () => {  
  146.             removeTimer && clearTimeout(removeTimer)  
  147.             remove()  
  148.         }  
  149.         messageDOM.querySelector(`.${this._prefixCls}message`).appendChild(closBtn)  
  150.     }  
  151.     /**  
  152.      * @description: 获取父节点容器  
  153.      * @return {Element} 节点DOM对象  
  154.      */  
  155.     _getContentBox() {  
  156.         const boxId = 'messageBox'  
  157.         if (document.querySelector('#' + boxId)) {  
  158.             return document.querySelector('#' + boxId)  
  159.         } else {  
  160.             const contentBox = document.createElement("div")  
  161.             contentBox.id = boxId  
  162.             contentBox.style.top = this._default.top + 'px'  
  163.             document.body.appendChild(contentBox)  
  164.             return contentBox 
  165.          }  
  166.     } 
  167.     /**  
  168.      * @description: 重新设置父节点高度  
  169.      */  
  170.     _setContentBoxTop() {  
  171.         const boxId = 'messageBox'  
  172.         const contentBox = document.querySelector('#' + boxId)  
  173.         if (contentBox) {  
  174.             contentBox.style.top = this._default.top + 'px'  
  175.         }  
  176.     }  
  177.     /**  
  178.      * @description: 恢复默认值  
  179.      */  
  180.     _resetDefault() {  
  181.         this._default = {  
  182.             top: 16,  
  183.             duration: 2  
  184.         }  
  185.     }  
  186.  
  187. if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {  
  188.     module.exports = new Message();  
  189. } else {  
  190.     window.$message = new Message();  

Vue 项目如何做单元测试?

七. 回顾

  •  定义
  •  安装与使用(安装、调试、git拦截、测试报告)
  •  常用API(jest、vue组件)
  •  落地单元测试(拆分关键模块加单测)
  •  演进:构建可测试单元模块(设计原则、重构)
  •  可维护的单元模块(代码规范)

落地线路:

① 安装使用 => ② API学习 => ③ 落地:拆分关键模块加单测 =>  ④ 演进:架构设计与重构 =>  ⑤ 代码规范

未来:

⑥ 文档先行(待探索)

在较为复杂的业务系统开发过程中,从第一版代码到逐步划分模块、增加单测,还是走了一段弯路。如果能够养成文档先行的习惯,先设计模块、测试用例,再编写代码,会更高效。

理解:

  •  单元测试有长期价值,也有执行成本。
  •  好的架构设计是单测的土壤,为单一职责的模块设计单测、增加单元测试更加顺畅。
  •  每个项目的业务形态与阶段不一样,不一定都适合,找到适合项目的平衡点。 

原文地址:https://mp.weixin.qq.com/s/q7JSWGoqRMyoAR0Q-MH0Vg