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

1. 定义

单元测试定义:

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

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

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自定义参数

// 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配置项:设置 => 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 配置

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

安装:

$ 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时执行参单元测试 并生成测试报告
    }
  },
}

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

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)每个语句是否都执行

  • 分支覆盖率(branch coverage)每个if代码块是否都执行

  • 函数覆盖率(function coverage)每个函数是否都调用

  • 行覆盖率(line coverage) 每一行是否都执行了

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

3. 常用API

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

Jest常用方法:文档

// 例子
describe('versionToNum 版本号转数字', () => {
  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)


// 每次执行前
beforeEach(() => {
 // do  some thing....
  // DOM 设置
  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('定时器 新建 执行', () => {
  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', () => {
  // 现在挂载组件,你便得到了这个包裹器
  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  入参模拟
    const wrapper = shallowMount(cloudPhone, {
      propsData: {
        mosaicStatus: false,
        customerOnLine: true,
        cloudPhoneState: false,
        cloudPhoneError: true,
        cloudPhoneTip: '发生错误',
        delay: ''
      }
    })
    
    
    // 子组件是否展示
    expect(wrapper.getComponent(Tip).exists()).toBe(true)
    // html判断
    expect(wrapper.html().includes('发生错误')).toBe(true)
    // DOM 元素判断
    expect(wrapper.get('.mosaicStatus').isVisible()).toBe(true)
    // 执行点击事件
    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. 落地单元测试

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

问题:

  1. 逻辑多:业务逻辑不清楚,1000+ 行

  2. 依赖多:$dayjs、$api、$validate、$route、$echarts、mixins、$store...

  3. 路径不一致:有@./../

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

落地:

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

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

图片

图片

图片

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

  • 无法看出多少种类型与接口数量

  • 无法在多个位置直接复用

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 => {                  item.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个子类构成,并聚合子类接口。

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

import GetParamsServer, { GetPrivateParamsServer } from '@/views/analysis/components/getParamsServer.js'

describe('GetPrivateParamsServer 私有参数获取', () => {
  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' }]
          })), // 服务端埋点
          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 结果转换为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种事件类型

  • 类型与接口的对应关系

  • 接口格式有三种

作用:

  1. 复用:将复杂的业务逻辑封闭在黑盒里,更方便复用。

  2. 质量:模块的功能通过测试用例得到保障。

  3. 维护:测试即文档,方便了解业务逻辑。

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

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

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

  1. 设计:将业务逻辑拆分为单元模块(UI组件、功能模块)。

  2. 时间:可行的重构目标与重构方法,要有长期重构的心理预期。

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

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

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

设计原则 SOLID:

  • SRP-单一职责

  • OCP-开闭:易与扩展,抗拒修改。

  • LSP-里氏替换:子类接口统一,可相互替换。

  • ISP-接口隔离:不依赖不需要的东西。

  • DIP-依赖反转:构建稳定的抽象层,单向依赖(例:A => B => C, 反例:A  => B => C => A)。

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

重构方法:

  • 预备性重构

  • 帮助理解的重构

  • 捡垃圾式重构(营地法则:遇到一个重构一个,像见垃圾一样,让你离开时的代码比来时更干净、健康)

  • 有计划的重构与见机行事的重构

  • 长期重构

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

图片

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

6. 可维护的单元模块

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

代码坏味道:

  • 神秘命名-无法取出好名字,背后可能潜藏着更深的设计问题。

  • 重复代码

  • 过长函数-小函数、纯函数

  • 过长参数

  • 全局数据-数量越多处理难度会指数上升。

  • 可变数据-不知道在哪个节点修改了数据。

  • 发散式变化-只关注当前修改,不用关注其他关联。

  • 霰弹式修改-修改代码散布四处

  • 依恋情结-与外部模块交流数据胜过内部数据。

  • 数据泥团-相同的参数在多个函数间传递。

  • 基本类型偏执

  • 重复的switch

  • 循环语句

  • 冗赘的元素

  • 夸夸其谈通用性

  • 临时字段

  • 过长的消息链

  • 中间人

  • 内幕交易

  • 过大的类

  • 异曲同工的类

  • 纯数据类

  • 被拒绝的遗赠-继承父类无用的属性或方法

  • 注释-当你感觉需要撰写注释时,请先尝试重构,试着让所有注释都变得多余。

规范:

  • 全局变量数量:20 ±

  • 方法方法行数:15 ±

  • 代码行数:300-500

  • 内部方法、内联方法:下划线开头

技巧:

  • 使用class语法:将紧密关联的方法和变量封装在一起。

  • 使用Eventemitter 工具库:实现简单发布订阅。

  • 使用vue  provide语法:传递实例。

  • 使用koroFileHeader插件:统一注释规范。

  • 使用Git-commit-plugin插件:统一commit规范。

  • 使用eslint + stylelint(未使用变量、误改变量名、debugger,自动优化的css)。

示例代码:

图片

6. 回顾

  • 定义

  • 安装与使用(安装、调试、git拦截、测试报告)

  • 常用API(jest、vue组件)

  • 落地单元测试(拆分关键模块加单测)

  • 演进:构建可测试单元模块(设计原则、重构)

  • 可维护的单元模块(代码规范)

落地线路:

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

未来:

⑥ 文档先行(待探索)

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

理解:

  • 单元测试有长期价值,也有执行成本。

  • 好的架构设计是单测的土壤,为单一职责的模块设计单测、增加单元测试更加顺畅

  • 每个项目的业务形态与阶段不一样,不一定都适合,找到适合项目的平衡点

Logo

前往低代码交流专区

更多推荐