Vue单元测试

Vue单元测试

  1. 新建一个vue项目
# 1.创建项目
vue create vue-jest-demo # 新建一个vue的项目(选择jest单元测试)

# 2.选择自定义配置
  default (babel, eslint)
> Manually select features

# 3.选择安装feature,也可以根据自己的喜好去安装
 (*) Babel
 ( ) TypeScript
 ( ) Progressive Web App (PWA) Support
 (*) Router
 (*) Vuex
 (*) CSS Pre-processors
 (*) Linter / Formatter
>(*) Unit Testing
 ( ) E2E Testing

 # 选择测试框架,我们选择jest
   Mocha + Chai
>(*) Jest

安装完毕后,会在根目录下存在一个 tests 的目录,其中里面包含一个简单的测试用例 example.spec.js 文件 和 jest.config.js 单元测试配置文件

  1. 项目目录结构
LiuJun-MacBook-Pro:vue-test-demo liujun$ tree
.
├── README.md
├── babel.config.js
├── jest.config.js # 单元测试的配置
├── package-lock.json
├── package.json # 项目的依赖
├── public
│   ├── favicon.ico
│   └── index.html
├── src
│   ├── App.vue
│   ├── assets
│   │   └── logo.png
│   ├── components
│   │   └── HelloWorld.vue
│   ├── main.js
│   ├── router
│   │   └── index.js
│   ├── store
│   │   └── index.js
│   └── views
│       ├── About.vue
│       └── Home.vue
└── tests # 单元测试的文件
    └── unit
        └── example.spec.js

项目的package.json文件

{
  "name": "vue-test-demo",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "test:unit": "vue-cli-service test:unit",
    "lint": "vue-cli-service lint"
  },
  "dependencies": {
    "core-js": "^3.6.5",
    "vue": "^2.6.11",
    "vue-router": "^3.2.0",
    "vuex": "^3.4.0"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "~4.5.0",
    "@vue/cli-plugin-router": "~4.5.0",
    "@vue/cli-plugin-vuex": "~4.5.0",
    "@vue/cli-service": "~4.5.0",


    "@vue/cli-plugin-unit-jest": "~4.5.0",
    "@vue/test-utils": "^1.0.3",

    "@vue/eslint-config-standard": "^5.1.2",
    "@vue/cli-plugin-eslint": "~4.5.0",
    "babel-eslint": "^10.1.0",
    "eslint": "^6.7.2",
    "eslint-plugin-import": "^2.20.2",
    "eslint-plugin-node": "^11.1.0",
    "eslint-plugin-promise": "^4.2.1",
    "eslint-plugin-standard": "^4.0.0",
    "eslint-plugin-vue": "^6.2.2",
    "node-sass": "^4.12.0",
    "sass-loader": "^8.0.2",
    "vue-template-compiler": "^2.6.11"
  }
}

项目 jest.config.json 文件的配置


module.exports = {
  preset: '@vue/cli-plugin-unit-jest'
}

  1. 运行单元测试
npm run test:unit

# 控制台输出的结果

LiuJun-MacBook-Pro:vue-test-demo liujun$ npm run test:unit

> vue-test-demo@0.1.0 test:unit /Users/liujun/Documents/huayun/test/vue-test-demo
> vue-cli-service test:unit

 PASS  tests/unit/example.spec.js
  HelloWorld.vue
    ✓ renders props.msg when passed (40ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        5.595s
Ran all test suites.

  1. 配置测试覆盖率和测试报告
    在根目录下的 jest.config.js 中,进行如下配置:
module.exports = {
  preset: '@vue/cli-plugin-unit-jest',
  moduleNameMapper: {
    "^@/(.*)$": "/src/$1"
  },
  testMatch: [
    "**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)"
  ],
  collectCoverage: true,
  coverageDirectory: "/tests/unit/coverage",
  collectCoverageFrom: [
    "src/components/**/*.vue",
    "src/utils/**/*.ts",
    "src/store/modules/*.ts",
    "!src/utils/axios.ts",
    "!src/utils/notify.ts"
  ]
}

配置的解释如下:

  1. preset: 预设的vue单元测试使用的插件。

  2. moduleNameMapper:模块别名配置。

  3. testMatch:测试文件查找规则,可以是统一放在src/tests目录下,也可以就近放在_ _tests__目录下。

  4. collectCoverage:是否进行测试覆盖率收集。

  5. coverageDirectory:测试报告存放位置。

  6. collectCoverageFrom:测试哪些文件和不测试哪些文件,你可以根据你的团队或者个人偏好进行设置。

在完善以上配置后,在终端运行npm run test:unit命令,可看测试覆盖率和测试报告。

> vue-cli-service test:unit

 PASS  tests/unit/example.spec.js
  HelloWorld.vue
    ✓ renders props.msg when passed (36ms)

----------|----------|----------|----------|----------|-------------------|
File      |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
----------|----------|----------|----------|----------|-------------------|
All files |        0 |        0 |        0 |        0 |                   |
----------|----------|----------|----------|----------|-------------------|
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        5.17s
Ran all test suites.

测试HelloWorld组件

1.简单测试HelloWorld组件

1.编写 HelloWorld.vue组件

<template>
  <div class="hello">
    <slot name="title">
      <span>count:{{count}}span>
    slot>
    <button
      class="btn btn-primary"
      :style="{height:'50px'}"
      @click="handlerBtnClick">
      {{msg}}
    button>
  div>
template>
<script>
export default {
  name: 'HelloWorld',
  props: {
    msg: String,
    defaultCount: {
      type: Number,
      default: 0
    }
  },
  data () {
    return {
      count: this.defaultCount
    }
  },
  methods: {
    handlerBtnClick () {
      this.count = this.count + 1
    }
  }
}
script>

<style scoped lang="scss">
.btn {
  color:green;
}
style>

2.编写 helloworld.spec.js 文件, 使用 ( @vue/test-utils库测试 )

import { shallowMount } from '@vue/test-utils'
import HelloWorld from '@/components/HelloWorld.vue'

describe('HelloWorld.vue', () => {
    
  it('create', () => {
      
    const msg = '按钮名称'
    const wrapper = shallowMount(HelloWorld, {
      propsData: { msg }
    })
    
    expect(wrapper.text()).toMatch(msg)
  })
    
})

3.执行 npm run test:unit 开始测试

或者

编写 helloworld.spec.js 文件, 不使用 ( @vue/test-utils库测试 )


import { createTest, createVue, destroyVM } from './utils';
import HelloWorld from '@/components/HelloWorld.vue'

describe('HelloWorld', () => {
  let vm;
  afterEach(() => {
    destroyVM(vm);
  });

  it('create', () => {
      
    vm = createTest(HelloWorld, {
      msg: '按钮名称'
    }, true);
    let buttonElm = vm.$el;
      
    expect(buttonElm.querySelector('.btn-primary').innerHTML).toContain('按钮名称')
      
  });

})

utils.js文件

// import Vue from 'vue';
import Vue from 'vue/dist/vue.js'; // 包含模板的编译模块

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 && vm.$destroy();
  vm.$el &&
  vm.$el.parentNode &&
  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);
};

执行 npm run test:unit 开始测试

2.测试HelloWorld组件完整的代码

1.使用 ( @vue/test-utils库测试) 的代码

import { shallowMount } from '@vue/test-utils'
import HelloWorld from '@/components/HelloWorld.vue'

describe('HelloWorld.vue', () => {

  it('create', () => {
    const msg = '按钮名称'
    const wrapper = shallowMount(HelloWorld, {
      propsData: { msg }
    })
    expect(wrapper.text()).toMatch(msg)
  })

  it('msg', () => {

    const msg = '按钮'
    const wrapper = shallowMount(HelloWorld, {
      propsData: { msg }
    })

    expect(wrapper.text()).toMatch(msg)

  })

  it('defaultCount', () => {

    const defaultCount = 10
    const wrapper = shallowMount(HelloWorld, {
      propsData: { defaultCount }
    })

    expect(wrapper.find('span').text()).toBe("count:"+defaultCount)

  })

  it('click', (done) => {

    const defaultCount = 0
    const wrapper = shallowMount(HelloWorld, {
      propsData: { defaultCount }
    })

    wrapper.find('.btn').trigger('click')

    wrapper.vm.$nextTick(()=>{
      // 界面跟新完成之后再断言
      expect(wrapper.find('span').text()).toBe("count:"+(defaultCount+1))
      done()
    })

  })

  it('slot title', () => {

    const titleHtml = '
liujun-title
'
const wrapper = shallowMount(HelloWorld, { slots: { title: titleHtml } }) expect(wrapper.find('.liujun-title').html()).toContain(titleHtml) }) })

2.不使用 ( @vue/test-utils库测试) 的代码


import { createTest, createVue, destroyVM } from './utils';
import HelloWorld from '@/components/HelloWorld.vue'

describe('HelloWorld', () => {
  let vm;
  afterEach(() => {
    destroyVM(vm);
  });

  it('create', () => {

    vm = createTest(HelloWorld, {
      msg: '按钮名称'
    }, true);

    let buttonElm = vm.$el;

    expect(buttonElm.querySelector('.btn-primary').innerHTML).toContain('按钮名称')
  });


  it('msg', () => {
    
    vm = createTest(HelloWorld, {
      msg: '按钮名称'
    }, true);

    let buttonElm = vm.$el;

    expect(buttonElm.querySelector('.btn-primary').innerHTML).toContain('按钮名称')
  });


  it('defaultCount', () => {
    let defaultCount = 10
    vm = createTest(HelloWorld, {
      defaultCount
    }, true);

    let buttonElm = vm.$el;

    expect(buttonElm.querySelector('span').innerHTML).toBe('count:'+defaultCount)
  });

  it('click', (done) => {
    
    let defaultCount = 10
    // 创建一个vue实例,并挂在dom上
    vm = createTest(HelloWorld, {
      msg: '按钮名称',
      defaultCount
    }, true);

    let buttonElm = vm.$el;

    buttonElm.querySelector('.btn-primary').click()

    setTimeout(_ => {
      // 等界面刷新之后断言
      expect(buttonElm.querySelector('span').innerHTML).toBe('count:'+(defaultCount+1))
      done();
    }, 20);
  });

  it('slot title', () => {
    
    // 创建一个vue实例,并挂在dom上
    vm = createVue({
      template: `
        
          
liujun-title
HelloWorld 按钮
`
, components: { HelloWorld } }, true); let buttonElm = vm.$el; expect(buttonElm.querySelector('.liujun-title').innerHTML).toContain('liujun-title') }); })

测试引用了第三方组件的组件

1.项目安装element组件库

npm install element-ui -S

# main.js
import Vue from 'vue'
import Element from 'element-ui'

Vue.use(Element)

2.编写 Login.vue组件






3.编写 Login.spec.js ( 使用@vue/test-utils测试库 )

import { shallowMount, mount, createLocalVue} from '@vue/test-utils'
import Login from '@/components/Login.vue'

import ElementUI, {
  Input,
  Button
} from "element-ui"
const localVue = createLocalVue()
localVue.use(ElementUI)

describe('Login.vue', () => {

  it('create', () => {

    const wrapper = shallowMount(Login, {
      localVue
    })

    expect(wrapper.findComponent(Button).text()).toMatch('提交')

  })

  it('age', () => {
    
    const age = 100
    
    const wrapper = shallowMount(Login, {
      localVue,
      propsData: { age }
    })

    expect(wrapper.findComponent(Input).vm.value).toBe(age)

  })

  it('success submit form', () => {
    
    const age = 100

    const wrapper = mount(Login, {
      localVue,
      propsData: { age }
    })

    // mock 一个函数
    let loginFn = jest.fn((res)=>{
      return res
    })
    // 监听 login 事件
    wrapper.vm.$on('login',loginFn)

    // 提交表单
    wrapper.findComponent(Button).trigger('click')

    // 断言 login 函数被调用
    expect(loginFn).toBeCalled()
    // 断言调用了一次 login 函数
    expect(loginFn.mock.calls.length).toBe(1)
    // 断言表单的对象为:{"age":100}
    expect(loginFn.mock.results[0].value).toEqual({"age":100})

  })

  it('success submit form 第二方法', () => {
    
    const age = 100

    // 只能使用 mount 挂载组件
    const wrapper = mount(Login, {
      localVue,
      propsData: { age }
    })

    // mock 一个函数
    let loginFn=jest.spyOn(wrapper.vm, 'login')
    // 重新实现该函数
    loginFn.mockImplementationOnce((res)=>{
      return res
    })

    // 提交表单
    wrapper.findComponent(Button).trigger('click')

    // 断言 login 函数被调用
    expect(loginFn).toBeCalled()
    // 断言调用了一次 login 函数
    expect(loginFn.mock.calls.length).toBe(1)
    // 断言表单的对象为:{"age":100}
    expect(loginFn.mock.results[0].value).toEqual({"age":100})

  })

  it('error submit form', (done) => {

    // 只能使用 mount 挂载组件
    const wrapper = mount(Login, {
      localVue,
      propsData: {  }
    })

    let loginElm = wrapper.vm.$el;

    // mock 一个函数
    let loginFn=jest.spyOn(wrapper.vm, 'login')
    // 重新实现该函数
    loginFn.mockImplementationOnce((res)=>{
      return res
    })

    // 提交表单
    wrapper.findComponent(Button).trigger('click')

    wrapper.vm.$nextTick(()=>{
      // 断言 login 函数被调用
      expect(loginFn).not.toBeCalled()
      // 断言 表单验证不通过
      expect(loginElm.querySelector('.el-form-item__error').innerHTML).toContain('年龄不能为空')
      // 结束断言测试
      done();
    }) 

  });


  it('reset form', () => {

    // 只能使用 mount 挂载组件
    const wrapper = mount(Login, {
      localVue,
      propsData: {  }
    })

    // 给表单输入值
    wrapper.vm.initForm(100)

    let loginElm = wrapper.vm.$el;

    // 点击重置表单
    // loginElm.querySelectorAll('.el-button')[1].click() // ok
    wrapper.findAllComponents(Button).at(1).trigger('click')

    // 断言  
    expect(wrapper.vm.numberValidateForm.age).toBe('')
    expect(loginElm.querySelector('.el-input__inner').value).toBe('')
  
  });

})

执行单元测试: npm run test:unit

> vue-test-demo@0.1.0 test:unit /Users/liujun/Documents/huayun/test/vue-test-demo
> vue-cli-service test:unit

 PASS  tests/unit/example.spec.js
 PASS  tests/unit/helloworld.spec.js
 PASS  tests/unit/login.spec.js

----------------|----------|----------|----------|----------|-------------------|
File            |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
----------------|----------|----------|----------|----------|-------------------|
All files       |      100 |      100 |      100 |      100 |                   |
 HelloWorld.vue |      100 |      100 |      100 |      100 |                   |
 Login.vue      |      100 |      100 |      100 |      100 |                   |
----------------|----------|----------|----------|----------|-------------------|

Test Suites: 4 passed, 4 total
Tests:       22 passed, 22 total
Snapshots:   0 total
Time:        13.359s
Ran all test suites.

4.编写 login.spec.js (不使用@vue/test-utils测试库 )


import { createTest, createVue, destroyVM } from './utils';
import Login from '@/components/Login.vue'

describe('Login', () => {
  let vm;
  afterEach(() => {
    destroyVM(vm);
  });

  it('create', () => {

    vm = createTest(Login, {}, true);

    let loginElm = vm.$el;

    expect(loginElm.querySelector('.el-button').innerHTML).toMatch('提交')

  });

  it('age', () => {

    let age = 100

    vm = createTest(Login, {
      age
    }, true);

    let loginElm = vm.$el;

    expect(loginElm.querySelector('.el-input__inner').value).toBe(age+"")
  
  });

  it('age 方式二', () => {

    vm = createVue({
      template: `
        
        
      `,
      components: {
        Login
      }
      }, true);

    let loginElm = vm.$el;

    expect(loginElm.querySelector('.el-input__inner').value).toBe("100")
  
  });

  it('success submit form', () => {

    let age = 100

    vm = createTest(Login, {
      age
    }, true);

    let loginElm = vm.$el;

    // mock 一个函数
    let loginFn = jest.fn((res)=>{
      return res
    })
    // 监听 login 事件
    vm.$on('login',loginFn)
    
    // 点击提交表单
    loginElm.querySelector('.el-button').click()

    // 断言 login 函数被调用
    expect(loginFn).toBeCalled()
    // 断言调用了一次 login 函数
    expect(loginFn.mock.calls.length).toBe(1)
    // 断言表单的对象为:{"age":100}
    expect(loginFn.mock.results[0].value).toEqual({"age":100})

  });

  it('error submit form', (done) => {

    vm = createTest(Login, {}, true);

    let loginElm = vm.$el;

    // mock 一个函数
    let loginFn = jest.fn((res)=>{
      return res
    })
    // 监听 login 事件
    vm.$on('login',loginFn)
    
    // 点击提交表单
    loginElm.querySelector('.el-button').click()

    setTimeout(()=>{
      // 断言 login 函数没有被调用
      // expect(loginFn).not.toBeCalled() // ok
      // 断言 表单验证不通过
      expect(loginElm.querySelector('.el-form-item__error').innerHTML).toContain('年龄不能为空')
      // 结束断言测试
      done();
    },20)
  });

  it('reset form', () => {

    vm = createTest(Login, {}, true);

    // 给表单输入值
    vm.initForm(100)

    let loginElm = vm.$el;
    // 点击重置表单
    loginElm.querySelectorAll('.el-button')[1].click()

    // 断言  
    expect(vm.numberValidateForm.age).toBe('')
    expect(loginElm.querySelector('.el-input__inner').value).toBe('')
  
  });

})

5.utils.js 测试工具

// import Vue from 'vue';
import Vue from 'vue/dist/vue.js'; // 包含模板的编译模块

// 全局注册 element 所有的组件
import Element from 'element-ui'
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 && vm.$destroy();
  vm.$el &&
  vm.$el.parentNode &&
  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);

执行单元测试: npm run test:unit

> vue-test-demo@0.1.0 test:unit /Users/liujun/Documents/huayun/test/vue-test-demo
> vue-cli-service test:unit

 PASS  tests/unit/example.spec.js
 PASS  tests/unit/helloworld.spec.js
 PASS  tests/unit/login.spec.js

----------------|----------|----------|----------|----------|-------------------|
File            |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
----------------|----------|----------|----------|----------|-------------------|
All files       |      100 |      100 |      100 |      100 |                   |
 HelloWorld.vue |      100 |      100 |      100 |      100 |                   |
 Login.vue      |      100 |      100 |      100 |      100 |                   |
----------------|----------|----------|----------|----------|-------------------|

Test Suites: 4 passed, 4 total
Tests:       22 passed, 22 total
Snapshots:   0 total
Time:        13.359s
Ran all test suites.

参考文章:

https://jestjs.io/docs/en/expect

https://vue-test-utils.vuejs.org/zh/api/wrapper/#%E5%B1%9E%E6%80%A7

https://wangtunan.github.io/blog/test/vueTest.html#%E6%B5%8B%E8%AF%95%E4%BB%8B%E7%BB%8D

你可能感兴趣的:(单元测试,vue.js,单元测试,前端)