vue单元测试最佳实践

1,106 阅读8分钟

一、为什么要进行单元测试

  • 单元测试可以作为描述组件行为的文档
  • 降低人工测试的时间
  • 减少迭代或者优化产生的bug
  • 改进设计&促进重构

二、如何使用Vue Test Utils对Vue组件进行单元测试

Vue官方推荐使用​​Vue Test Utils​​对单文件组件单元测试。 Vue Test Utils 会将单文件组件隔离挂载,然后模拟必要的输入 (prop、注入和用户事件) 和对输出 (渲染结果、触发的自定义事件) 的断言来测试 Vue 组件。 被挂载的组件会返回到一个包裹器内,而包裹器会暴露很多封装、遍历和查询其内部的 Vue 组件实例的便捷的方法 单文件组件单元测试流程图如下:

24a0b6b2-d2ab-11ed-a336-8a0c6bfd6c3c.png

下面将介绍如何使用官方推荐方案搭建单元测试的环境

1 、选择测试运行器

测试运行器是执行测试集的程序。主流的JavaScript测试运行器比较多,且Vue Test Utils都支持。Vue Test Utils是与测试运行器无关的。 那我们该如何从众多测试运行器选择呢?需要关注以下几点:

  • 测试运行器给我们提供的功能集合是否足够强大
  • 性能
  • 对单文件组价预编译的支持

Vue Test Utils推荐以下两个测试运行器

  • Jest是功能最全的测试运行器。它需要的配置是最少的,默认安装了JSDOM,内置断言命令行的用户体验非常好。 不过需要一个将Vue单文件组件预处理器,这样Jest才可以处理。Vue Test Utils为我们提供了vue-jest预处理器来处理最常见的单文件组件特性,注意不是100%的vue-loader.
  • mocha-webpack是一个webpack + Mocha的包裹器。同时包含了更顺畅的接口和侦听模式。这些设置的好处是我们可以通过webpack+vue-loader得到完整的单文件组件支持,但是需要很多配置。

2、浏览器环境支持

Vue Test Utils依赖浏览器环境。技术上讲你可以将其运行在一个真实的浏览器环境,但不推荐,因为在不同的平台都启动真实的浏览器是很复杂的。 推荐是用JSDOM在Node虚拟浏览器环境运行测试。 Jest测试运行器内置了JSDOM。对于其他的测试运行器,你可以在你测试的入口使用jsdom-global手动设置JSDOM。 npm install --save-dev jsdom jsdom-global

// 在测试的入口中设置 
require('jsdom-global')()

3、单文件组件单元测试环境配置

Vue的单文件组件在他们运行于Node或者浏览器之前需要预编译。有两种方式来做预编译,通过vue-jest预编译器或者通过webpack vue-jest预处理器支持基本的单文件组件功能,但是目前不支持处理样式块和自定义块,这些都需要vue-loader+webpack支持。

  • Jest&@vue/test-utils 对Vue2项目推荐以下版本,可以直接运行起来

npm i -D @vue/test-utils@1.3.0 jest@24.9.0

配置文件

{
  "scripts": {
    "test": "jest"
  }
}
  • vue-jest vue-jest是用来处理单文件组件的

npm i -D vue-jest@4.0.1

配置文件

{
  // ...
  "jest": {
    "moduleFileExtensions": [
      "js",
      "json",
      // 告诉 Jest 处理 `*.vue` 文件
      "vue"
    ],
    "transform": {
      //`vue-jest` 处理 `*.vue` 文件
      ".*\\.(vue)$": "vue-jest"
    }
  }
}
  • 如果你的Babel版本高于7还需安装 babel-bridge

npm install --save-dev babel-core@^7.0.0-bridge.0

对于比较老的项目

npm install --save-dev babel-core@^7.0.0-bridge.0 @babel/plugin-transform-runtime

.babelrc配置如下

{
  "presets": [
    ["@babel/preset-env", {
      "useBuiltIns": false,
    }],
  ],
  "plugins": ["@babel/plugin-transform-runtime"]
}
  • 处理 webpack 别名

如果你在 webpack 中配置了别名解析,比如把 @ 设置为 /src 的别名,那么你也需要用 moduleNameMapper 选项为 Jest 增加一个匹配配置:

{
  // ...
  "jest": {
    // ...
    // 支持源代码中相同的 `@` -> `src` 别名
    "moduleNameMapper": {
      "^@/(.*)$": "<rootDir>/src/$1"
    }
  }
}
  • 为 Jest 配置 Babel 尽管最新版本的 Node 已经支持绝大多数的 ES2015 特性,你可能仍然想要在你的测试中使用 ES modules 语法和 stage-x 的特性。为此我们需要安装 babel-jest:

npm i -D babel-jest@24.9.0

配置如下:

{
transform: { 
    "^.+\\.js$": "<rootDir>/node_modules/babel-jest"
  }
}

最后完整的jest.conf.js的配置如下:

// ./test/unit/jest.conf.js
const path = require('path');
module.exports = {
  rootDir: path.resolve(__dirname, '../../'), // 类似 webpack.context
  "collectCoverage": true,
  moduleFileExtensions: [ // 类似 webpack.resolve.extensions
    'js',
    'json',
    'vue',
  ],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1', // 类似 webpack.resolve.alias
  },
  transform: { // 类似 webpack.module.rules
    '^.+\\.js$': '<rootDir>/node_modules/babel-jest',
    // '.*\\.(vue)$': '<rootDir>/node_modules/vue-jest',
    ".*\\.(vue)$": '<rootDir>/node_modules/vue-jest',
  },
  setupFiles: ['<rootDir>/test/unit/setup'], // 类似 webpack.entry
  coverageDirectory: '<rootDir>/test/unit/coverage', // 类似 webpack.output
  collectCoverageFrom: [ // 类似 webpack 的 rule.include
    'src/components/__test__/*.{js}',
    '!src/main.js',
    '!src/router/index.js',
    '!**/node_modules/**',
  ],
  transformIgnorePatterns: ['/node_modules/']
};

目录结构如下:

45cba5b8-d2ab-11ed-84d0-66115fc9569f.png

  • setup.js入口文件
import Vue from 'vue'
Vue.config.productionTip = false;

这样单元测试的配置文件就完成了~

三、开始单元测试

1、整合的官方demo

//src\components\hCounter\counter.vue
<template>
  <div>
    <span class="desc">{{ desc }}</span>
    <span class="count">{{ count }}</span>
    <button @click="increment">Increment</button>
  </div>
</template>
<script>
export default {
    data() {
      return {
        count: 0
      }
    },
    props: {
      desc: {
        type: String, 
        default: "Counter"
      }
    },
    methods: {
      increment() {
        this.count++
      }
    }
  }
</script>

2、编写单元测试用例

//src\components\__test__\counter.spec.js
import { mount } from '@vue/test-utils'
import Counter from '@/components/hCounter/counter';
describe('Counter', () => {
  // 现在挂载组件,你便得到了这个包裹器
  const wrapper = mount(Counter, { 
    propsData: { 
      desc: 'I am a counter'
    }
  })

  console.log('wrapper.vm.desc' , wrapper.vm.desc)
  console.log('wrapper.vm.count' , wrapper.vm.count)
  console.log('options.attachedToDocument' , wrapper.options.attachedToDocument)
  // console.log('wrapper.element' , wrapper.element)

  it('renders the desc', () => {
    expect(wrapper.html()).toContain('I am a counter')
  });

  it('renders the correct markup', () => {
    expect(wrapper.html()).toContain('<span class="count">0</span>')
  })

  // 也便于检查已存在的元素
  it('has a button', () => {
    expect(wrapper.find('button').exists()).toBe(true)
    // console.log(expect(wrapper.find('button')))
    // expect(wrapper.contains('button')).toBe(true)
  })

  //模拟用户交互
  //用户点击按钮则计数器增加递增,首先通过wrapper.find()定位改按钮,改方法返回一个该按钮元素的包裹器,然后对按钮包裹器调用.trigger模拟操作
  it('button click should increment the count', () => {
    expect(wrapper.vm.count).toBe(0)
    const button = wrapper.find('button')
    button.trigger('click')
    expect(wrapper.vm.count).toBe(1)
  })


  it('set count to 100', async () => {
    await wrapper.setData({'count': 100});
    expect(wrapper.vm.count).toBe(100)
  })

  //为了测试计数器中的文本是否更新,需要使用异步nextTick await

  //任何导致操作DOM的改变都应该在断言之前await

  it('button click should increment the count text', async () => {
    expect(wrapper.text()).toContain('1')
    const button = wrapper.find('button')
    await button.trigger('click')
    expect(wrapper.text()).toContain('2')
  })
})

3、对UI单元测试的建议

对于 UI 组件来说,我们不推荐一味追求行级覆盖率,因为它会导致我们过分关注组件的内部实现细节,从而导致琐碎的测试。 取而代之的是,我们推荐把测试撰写为断言你的组件的公共接口,并在一个黑盒内部处理它。一个简单的测试用例将会断言一些输入 (用户的交互或 prop 的改变) 提供给某组件之后是否导致预期结果 (渲染结果或触发自定义事件)。 比如,对于每次点击按钮都会将计数加一的 Counter 组件来说,其测试用例将会模拟点击并断言渲染结果会加 1。该测试并没有关注 Counter 如何递增数值,而只关注其输入和输出。 该提议的好处在于,即便该组件的内部实现已经随时间发生了改变,只要你的组件的公共接口始终保持一致,测试就可以通过。

4、某项目首页tab的单元测试

大致的逻辑上是,未登录的时候有4个tab分别是主页、产品介绍、案例展示、联系我们。

登陆之前

78a86854-d2ab-11ed-bc88-02e9d7f50d20.png

登陆之后从home页返回之后会把主页去掉,新的tab是返回系统、产品介绍、业务指南、培训视频、案例展示、联系我们,且点击返回系统会根据角色跳转到不同的页面

640b6a40-d2ab-11ed-b460-faef2340b518.png

完整的单元测试用例如下~

import { mount, createLocalVue } from '@vue/test-utils'
import loginHeader from '@/components/loginHeader'
import VueRouter from 'vue-router'

const localVue = createLocalVue()
localVue.use(VueRouter)
const router = new VueRouter()

describe('loginHeader',  () => {
    const wrapperNotLogin = mount(loginHeader, {
        localVue,
        router
    });
    it('未登录之前的tab展示', () => {
        const loginTabWrap = wrapperNotLogin.find('.login-tab-wrap');
        const tabsArr = ['主页', '产品介绍', '案例展示', '联系我们'];
        for(let i = 1; i < 5; i++) {
            const findDom = loginTabWrap.findAll('div').at(i);
            expect(findDom.html()).toContain(tabsArr[i-1])
        }
    });


    //模拟登陆之后的情况
    sessionStorage.setItem('objUser', 'yes')
    sessionStorage.setItem('system.route.path.type', 'PLAN_MANAGER')

    const wrapperLogin = mount(loginHeader, {
        mocks: {
            $route: {//伪造路由
                path: '/home'
            },
            $router: {}
        }
    });
    it('登陆成功且是计划管理人身份', () => {
        const loginTabWrap = wrapperLogin.find('.login-tab-wrap');
        const tabsArr = ['返回系统', '产品介绍', '业务指南', '培训视频', '案例展示', '联系我们'];
        for(let i = 1; i < 7; i++) {
            const findDom = loginTabWrap.findAll('div').at(i);
            expect(findDom.html()).toContain(tabsArr[i-1])
        }
    });


    it('when system.route.path.typ == PLAN_MANAGER tabsArr[0].toRouter == /absProductManage/index?active=1', () => {
        expect(wrapperLogin.vm.tabArr[0].toRouter).toBe('/absProductManage/index?active=1')
    });
  
})

npm run test之后的结果如下,通过了单元测试

91f74e56-d2ab-11ed-9f9f-4244f76b6170.png

四、更复杂的场景单元测试

以某项目资产与发票的管理组件为场景,该组件是业务场景比较复杂的案例,交互如下:

a7d63d72-d2ab-11ed-a468-5a96ab6eee32.png

为了完成组件的单元测试,牵涉到llsweb组件注册、过滤器注册、指令注册、原型改造、表格组件改造以及如何使用接口mock等。

  • llsweb组件改造 由于llsweb的llsBuryingPoint(“base”)是引入就执行的,里面有document.domain的逻辑,会报错,我暂时先把它注释了,后面可以改造成引入之后主动调用。

c330f134-d2ab-11ed-9899-5e449f9e4901.png

然后本地build,本地引入

d973d826-d2ab-11ed-9dbd-0e2a61a4e588.png

  • 过滤器改造 原来的过滤器是默认使用Vue注册的,因此注册之后的过滤器会挂载到全局的Vue原型上

eec024e6-d2ab-11ed-b736-4e98b78859e5.png

而单元测试的代码是通过一个模拟器来执行的,会生成一个临时的localVue,因此应该把过滤器挂载到localVue上

import { shallowMount, mount,  createLocalVue } from '@vue/test-utils';
const localVue = createLocalVue()
import filterV2 from '../../common/filterV2.js'
filterV2(localVue);

改造之后的过滤器如下:

177507b2-d2ac-11ed-9852-962d1dec5f64.png

  • 全局指令与原型的改造 改造方式如过滤器,改造之后如下
import { shallowMount, mount,  createLocalVue } from '@vue/test-utils';
const localVue = createLocalVue()
localVue.use(VueRouter)
const router = new VueRouter();
import VueRouter from 'vue-router'

import llsweb from './lib/llsweb.min.js'
//自定义公共过滤器,指令,方法和插件
import filterV2 from '../../common/filterV2.js'
import directiveV2 from  '../../common/directiveV2.js'
import prototypeV2 from  '../../common/prototypeV2.js'

directiveV2(localVue);
filterV2(localVue);
prototypeV2(localVue);
localVue.use(llsweb)

import tableV2 from '@/components/lls-tableV2'
import columnV2 from'@/components/lls-table-columnV2'

tableV2(localVue);
columnV2(localVue);
  • props设置
wrapper = mount(usedAssetList, {
            propsData: {
                isEdit: true,
                detailData: {

                },
                accountsForm: {

                },
                assetNo: '',
                assetNoArr: ['1'],
                traceTableList: [],
                appNo: ''
            },
  • http请求的mock

'vue-resource’的http请求mock方式如下,

wrapper = mount(usedAssetList, {
            mocks: {
                $http: {
                    get: function (url) {
                        if (!url) {
                            return Promise.resolve({ data: 'Mock Welcome!' });
                        }
                    },
                    post: function (url) {
                        if (url == '/common/debt/listDebtPage') {
                            return Promise.resolve({body:{"code":"200","message":"成功","url":"/spyPc-web/common/debt/listDebtPage","traceId":"1534069248765997058","data":{"records":[{"id":"1457","orgId":"0000000001","loanStatus":"{\"dictParam\":\"WAIT\",\"displayName\":\"待提交\",\"dictKey\":\"wait\"}","billType":"{\"dictParam\":\"INVOICE\",\"displayName\":\"发票\",\"dictKey\":\"invoice\"}","billNo":"44444444","billCheckSixCode":"443434","billAmount":4.00,"nonTaxAmount":4.00,"billDate":"2022-05-05","transferAmount":4.00,"checkStatus":"{\"dictParam\":\"WAIT_CHECK\",\"displayName\":\"待验真\",\"dictKey\":\"IVCS0101\"}","screenShotStatus":"{\"dictParam\":\"WAITING_CHECK\",\"displayName\":\"待查验\",\"dictKey\":\"ICSE100\"}","sellerId":"1483260622452768770","sellerName":"118供应商","buyerId":"1483276406675685377","buyerName":"118核心企业","billCode":"4444343443","enable":"{\"dictParam\":\"Y\",\"displayName\":\"启用\",\"dictKey\":1}","createBy":"460","createTime":"2022-05-26 13:35:22","updateBy":"0","updateTime":"2022-05-26 13:35:21","invoiceType":"{\"dictParam\":\"NORMAL\",\"displayName\":\"普票\",\"dictKey\":\"normal\"}","verifyCode":"443434","currency":"CNY","unpayAmount":0.00,"transferredAmount":4.00,"billValidAmount":4.00,"assetNo":"FC-2022-05-1620","baseContNo":"no89787899","baseContName":"合同034","onceTransferAmount":4.00,"onlyOnceRelationFlag":true}],"total":"1","size":"10","current":"1","queryCondition":{"onlyOnceRelationFlag":true,"assetNos":["FC-2022-05-1620","FC-2022-05-1621"]},"pages":"1","searchCount":true,"hitCount":false}}});
                        }
                    }
                }
            },

如果使用了axio的话

jest.mock("axios", () => ({
  get: (url) => {
    if(url == 'http://jsonplaceholder.typicode.com/posts') {
      return Promise.resolve({ data: [
        {
          "userId": 1,
          "id": 1,
          "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
          "body": "abc"
        },
        {
          "userId": 1,
          "id": 2,
          "title": "qui est esse",
          "body": "xyz"
        }
      ] })
    }
    
  }

}));

官方提供的单元测试方案,对复杂的场景可能需要改造组件,过程对组件的侵入性很强,也相对复杂,因此很难应于实际业务中,那有没有简单通用的方案吗?

五、单元测试最佳实践

在某项目上实践过Vue官方推荐的单元测试方案一段时间之后,Vue-test-utils在使用过程中问题很多,对于复杂组件的测试更加困难,甚至没法测试。

当把基于Vue-Test-Utils的单测方案从某应用到低代码业务组件库时,遇到了一个问题很难解决,在google上也没找到解决方法,浪费了大量时间和精力。最后决定放弃这种方案。

那有没有更好的方案呢?

(1)、基于Karma+mocha+chain的解决方案

考虑到Element-ui这个应用广泛的库,肯定也有单元测试,于是决定借鉴他们的方案,读了Element-ui的源码中单元测试方案,他们使用的是基于Karma+mocha+chain的解决方案,基本思想是注册所有组件、用Vue创建实例,通过操作DOM的方法来实现的. 注册所有组件、用Vue创建实例部分代码如下:

import Vue from 'vue';
import Element from 'main/index.js';

Vue.use(Element);

let id = 0;

const createElm = function() {
  const elm = document.createElement('div');

  elm.id = 'app' + ++id;
  document.body.appendChild(elm);

  return elm;
};

/**
 * 回收 vm
 * @param  {Object} vm
 */
export const destroyVM = function(vm) {
  vm.$destroy &amp;&amp; vm.$destroy();
  vm.$el &amp;&amp;
  vm.$el.parentNode &amp;&amp;
  vm.$el.parentNode.removeChild(vm.$el);
};

/**
 * 创建一个 Vue 的实例对象
 * @param  {Object|String}  Compo   组件配置,可直接传 template
 * @param  {Boolean=false} mounted 是否添加到 DOM 上
 * @return {Object} vm
 */
export const createVue = function(Compo, mounted = false) {
  if (Object.prototype.toString.call(Compo) === '[object String]') {
    Compo = { template: Compo };
  }
  return new Vue(Compo).$mount(mounted === false ? null : createElm());
};

/**
 * 创建一个测试组件实例
 * @link http://vuejs.org/guide/unit-testing.html#Writing-Testable-Components
 * @param  {Object}  Compo          - 组件对象
 * @param  {Object}  propsData      - props 数据
 * @param  {Boolean=false} mounted  - 是否添加到 DOM 上
 * @return {Object} vm
 */
export const createTest = function(Compo, propsData = {}, mounted = false) {
  if (propsData === true || propsData === false) {
    mounted = propsData;
    propsData = {};
  }
  const elm = createElm();
  const Ctor = Vue.extend(Compo);
  return new Ctor({ propsData }).$mount(mounted === false ? null : elm);
};

/**
 * 触发一个事件
 * mouseenter, mouseleave, mouseover, keyup, change, click 等
 * @param  {Element} elm
 * @param  {String} name
 * @param  {*} opts
 */
export const triggerEvent = function(elm, name, ...opts) {
  let eventName;

  if (/^mouse|click/.test(name)) {
    eventName = 'MouseEvents';
  } else if (/^key/.test(name)) {
    eventName = 'KeyboardEvent';
  } else {
    eventName = 'HTMLEvents';
  }
  const evt = document.createEvent(eventName);

  evt.initEvent(name, ...opts);
  elm.dispatchEvent
    ? elm.dispatchEvent(evt)
    : elm.fireEvent('on' + name, evt);

  return elm;
};

/**
 * 触发 “mouseup” 和 “mousedown” 事件
 * @param {Element} elm
 * @param {*} opts
 */
export const triggerClick = function(elm, ...opts) {
  triggerEvent(elm, 'mousedown', ...opts);
  triggerEvent(elm, 'mouseup', ...opts);

  return elm;
};

/**
 * 触发 keydown 事件
 * @param {Element} elm
 * @param {keyCode} int
 */
export const triggerKeyDown = function(el, keyCode) {
  const evt = document.createEvent('Events');
  evt.initEvent('keydown', true, true);
  evt.keyCode = keyCode;
  el.dispatchEvent(evt);
};

/**
 * 等待 ms 毫秒,返回 Promise
 * @param {Number} ms
 */
export const wait = function(ms = 50) {
  return new Promise(resolve => setTimeout(() => resolve(), ms));
};

/**
 * 等待一个 Tick,代替 Vue.nextTick,返回 Promise
 */
export const waitImmediate = () => wait(0);

这种方案的好处是方案成熟,与非MVVM的前端单测方案区别不大,但是缺点是写测试用例复杂,需要熟悉原生JS方法、且代码量比较大、需要自己股管理回收Vue实例、实现事件等。

(2)、基于Karma+mocha+Vue-test-utils+chain的解决方案

其实​​Vue-test-utils​​​给我们提供了操作Vue组件的方法,借助它可以减少手动操作原生DOM的场景、不用自己去管理Vue实例和事件、且天然支持组件props通信、支持​​浅渲染​​、更优雅的实现异步以及Mock、Vue-router等。 因此基于Karma+mocha+chain的解决方案加上Vue-test-utils就形成了现有的单元测试方案--基于Karma+mocha+Vue-test-utils+chain的解决方案,并且支持两种写法。

示例如下:

import Vue from 'vue'
import taskTodoChart from '@/components/taskTodoChart/index.vue'
import { createTest, createVue, destroyVM } from '../util';

//引入vue-test-utils
import { mount } from 'vue-test-utils'

describe('vue-test-utils测试taskTodoChart组件', () => {
    it('待领取的数量应该等于1,待办理数量应该等于23', async () => {
        const wrapper =  mount(taskTodoChart, {
            propsData: {
              parent: {
                unAssignedData:1,
                assignedData:23,
                router:"/#/ewewewew"
              }
            }
        });
        await Vue.nextTick()
        const unAssignedData =  wrapper.find('.unAssignedData');
        const assignedData =  wrapper.find('.assignedData');
        expect(unAssignedData.text()).to.equal('待领取:1')
        expect(assignedData.text()).to.equal('待办理:23')

    })
})

describe('Mocha测试taskTodoChart组件', () => {
    it('待领取的数量应该等于1,待办理数量应该等于23', async () => {
        const vm = createTest(taskTodoChart, {
            parent: {
                unAssignedData:1,
                assignedData:23,
                router:"/#/ewewewew"
              }
        }, true);
        await Vue.nextTick()
        expect((vm.$el.querySelector('.unAssignedData').textContent)).to.equal('待领取:1');
        expect((vm.$el.querySelector('.assignedData').textContent)).to.equal('待办理:23');
    })
})

六、总结

基于Karma+mocha+Vue-test-utils+chain的解决方案是在Karma+mocha+chain的方案之上引入了Vue-test-utils工具库并保留了Element-ui的原有单元测试方案,它更适用于Vue的单元测试且能用于任何非Vue的项目。