Vue 单元测试编写入门

920 阅读9分钟

作者:Rui

基础概念

单元测试

单元测试允许你将独立单元的代码进行隔离测试,其目的是为开发者提供对代码的信心。通过编写细致且有意义的测试,你能够有信心在构建新特性或重构已有代码的同时,保持应用的功能和稳定。

  • 独立单元 --- 应用、组件、函数等
  • 隔离测试 --- 隔离单个组件的每个部分进行测试

测试运行器

测试运行器 (test runner) 就是运行测试的程序。

官方推荐

名称特点特征
Jest简易型集成断言、JSDom、覆盖率报告等功能,几乎零配置,可在Node虚拟浏览器环境运行测试
Mocha灵活性可以选择不同的库来满足断言等常见功能,可在Node.js和浏览器里运行测试,比较年老参考文献较多

vue-test-utils

Vue Test Utils 是 Vue.js 官方的单元测试实用工具库,提供了一系列非常方便的工具。它是与测试运行器无关的,主流的JavaScript测试运行器都支持。

vue-test-utils 在 Vue 和 Jest 之间提供了一个桥梁,暴露出一些接口,让我们更加方便地通过 Jest 为 Vue 应用编写单元测试。

安装

通过vue-cli创建项目时启用单元测试

# 1.创建vue项目
vue create [project-name]

# 2.选择"Manually select features" 和 "Unit Testing",选择"Jest"作为测试运行器

# 3.安装完成,cd [project-name]进入项目目录并运行
yarn test:unit

2

3

4

安装成功后,package.json文件中会有 运行命令 "test:unit": "vue-cli-service test:unit",并安装了 @vue/cli-plugin-unit-jest@vue/test-utils 依赖

在已有vue项目中使用单元测试

# unit testing
vue add @vue/unit-jest

# or:
vue add @vue/unit-mocha

同样,执行成功之后package.json文件中会添加 运行命令 "test:unit": "vue-cli-service test:unit",安装 @vue/cli-plugin-unit-jest@vue/test-utils 依赖,并生成 jest.config.js 配置文件。

除了以上的安装方法,Vue 官方还提供了使用 Mocha 和 webpack、Karma 和不经过构建就能使用的安装方法。

使用(@vue/unit-jest)

  1. tests/unit 文件夹中新建测试用例文件,以 (...).spec.[j, t]s?(x) 命名。vue-test-uitls 会扫描 tests/unit 下面的所有以 spec 后缀命名的 .js/.jsx/.ts/.tsx 文件,执行测试脚本,文件名不对、文件位置不正确都不会被扫描到。除此,根目录下的 _tests_ 文件夹里的 {j, t}s?(x) 文件也会被扫描到。

    可以通过 Jest 配置段落中 testMatch 属性进行重新指定,默认是 ["**/__tests__/**/*.[jt]s?(x)", "**/tests/unit/*.spec.[jt]s?(x)"]

    1

  2. 编写测试用例

<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  props: {
    msg: String
  },
  data () {
    return {
      info: ''
    }
  },
  methods: {
    say () {
      this.info = '55'
    }
  }
}
</script>
import { shallowMount } from '@vue/test-utils'
import HelloWorld from '@/components/HelloWorld.vue'

describe('HelloWorld.vue', () => {
  it('renders props.msg when passed', () => {
    const msg = 'new message'
    const wrapper = shallowMount(HelloWorld, {
      propsData: { msg }
    })
    expect(wrapper.text()).toMatch(msg)
    wrapper.vm.say()
    expect(wrapper.vm.msg).toEqual(msg)
    expect(wrapper.vm.info).toEqual('55')
  })
})

describe(name, fn):定义一个测试套件,name是测试套件的名字,fn 是具体的可执行的函数,一个测试文件可包含多个测试套件,但通常将一组相关的测试放在同一个套件中,这样测试用例代码结构化,易于维护。

it(name, fn) :定义一个测试用例,表示一个单独的测试,是测试的最小单位,一个测试套件里可包含多个测试用例。

expect:定义一个断言,它接受一个参数(运行测试内容的结果),返回一个对象,这个对象调用匹配器(例如上例的toMatch),匹配器的参数就是预期结果,运行结果与预期结果对比即可得到测试结果。

wrapper.vm:返回wrapper对应的 vue 示例,可以通过该实例使用组件里的 data、methods 等参数。

  1. 终端执行 yarn test:unit test5.spec.jsyarn test:unit

    前者是只运行某个测试文件,后者是运行全部测试文件

常用技巧

浅渲染

Vue Test Utils 允许通过 shallowMount 方法只挂载一个组件而不渲染其子组件 (即保留它们的存根),因为重复渲染所有的子组件可能会让我们的测试变慢,而且子组件可能存在一些异步行为(异步请求)会影响测试。

shallowMountmount 的区别:shallowMount 方法只挂载一个组件而不渲染其子组件 (即保留它们的存根),而 mount 会渲染该组件的子组件。

测试覆盖率

需求覆盖率是指测试对需求的覆盖程度,通常的做法是将每一条分解后的软件需求和对应的测试建立一对多的映射关系,最终目标是保证测试可以覆盖每个需求,以保证软件产品的质量。

Jest 提供了多种格式的测试覆盖率报告,只需要简单的配置即可得到测试的覆盖率报告。

使用方法

  1. package.json文件中添加命令行:
"test": "vue-cli-service test:unit --coverage"
  1. 扩展 jest 配置:

通常在 package.jsonjest.config.js 中的添加 collectCoverage 属性,也可以添加其他属性来定义需要收集测试覆盖率信息的文件。

jest.config.js 文件

module.exports = {
  preset: '@vue/cli-plugin-unit-jest',
  collectCoverage: true, // 开启测试覆盖报告
  collectCoverageFrom: ['<rootDir>/src/**/*.{js,vue}', '!**/node_modules/**'], // 收集覆盖率报告的文件
  coverageDirectory: '<rootDir>/tests/report', // Jest 输出覆盖文件的目录。
  coveragePathIgnorePatterns: ['<rootDir>/node_modules/'], // 忽略哪些文件
  coverageThreshold: { // 覆盖率阀值,当某个测试文件低于阀值就会产生警告
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: -10
    },
    './src/views/Home.vue': {
      branches: 40,
      statements: 40
    },
    './src/views/Test.vue': {
      branches: 40,
      statements: 40
    },
    './src/views/*.vue': {
      statements: 90
    }
  }
}

测试覆盖率报告

指标:

  • %stmts是语句覆盖率(statement coverage):是不是每个语句都执行了
  • %Branch分支覆盖率(branch coverage):是不是每个if代码块都执行了
  • %Funcs函数覆盖率(function coverage):是不是每个函数都调用了
  • %Lines行覆盖率(line coverage):是不是每一行程序都执行了
  • Uncovered Line:没有被执行的代码行数

当设置了 coverageDirectory 属性时,会在对应的路径下生成测试覆盖率报告文件,而且报告文件是以 html 格式呈现,打开文件能看到更直观的报告。

测试异步行为

来自 Vue 的更新

Vue 会异步地将未生效的 DOM 批量更新,这意味着变更一个响应式 property 后,为了断言这个变化,你的测试需要等待 Vue 完成更新,可以通过 await Vue.nextTick()awaitthen 的方式解决

<template>
  <div>
    <button @click="add">+1</button>
    <div class="result">{{ result }}</div>
  </div>
</template>
<script>
export default {
  data () {
    return {
      result: 0
    }
  },
  methods: {
    add () {
      this.result++
    }
  }
}
</script>
import Test from '@/views/Test1'
import { shallowMount } from '@vue/test-utils'
import Vue from 'vue'

describe('vue更新异步', () => {
  it('使用await', async () => {
    const wrapper = shallowMount(Test)
    await wrapper.find('button').trigger('click')
    expect(wrapper.find('.result').text()).toEqual('1')
    await wrapper.find('button').trigger('click')
    expect(wrapper.find('.result').text()).toEqual('2')
  })
  it('使用then', () => {
    const wrapper = shallowMount(Test)
    wrapper.find('button').trigger('click').then(() => {
      expect(wrapper.find('.result').text()).toEqual('1')
    })
  })
  it('使用nextTick', async () => {
    const wrapper = shallowMount(Test)
    wrapper.find('button').trigger('click')
    await Vue.nextTick() // 注释这行代码将会不通过测试
    expect(wrapper.find('.result').text()).toEqual('1')
  })
})

trigger 返回一个可以像上述示例一样被 await 或像普通 Promise 回调一样被 then 链式调用的 Promise。

可以被 await 的方法有:

  • setData
  • setValue
  • setChecked
  • setSelected
  • setProps
  • trigger

来自外部行为的更新

一些来自外部的异步行为,如异步请求等也会影响测试结果。

<template>
  <div>
    <button @click="getData">获取</button>
    <div class="info">{{ info }}</div>
  </div>
</template>

<script>
import axios from 'axios'
export default {
  name: 'Test2',
  data () {
    return {
      info: ''
    }
  },
  methods: {
    getData () {
      axios.get('https://mg-api.37.com.cn.website/articlelist/44/81?webId=44&categoryId=81')
        .then(res => {
          const data = res.data.data.array[0]
          this.info = data.title
        })
    }
  }
}
</script>
import Test2 from '@/views/Test2'
import { shallowMount } from '@vue/test-utils'

jest.mock('axios', () => ({
  get: () => Promise.resolve({
    data: {
      data: {
        array: [
          { name: 'test', title: '测试' }
        ]
      }
    }
  })
}))

describe('异步请求测试', () => {
  it('测试1', () => {
    const wrapper = shallowMount(Test2)
    wrapper.find('button').trigger('click')
    expect(wrapper.find('.info').text()).toEqual('测试')
  })
})

组件中的异步请求使用mock模拟,上面的测试用例会执行失败,因为在 getData 方法执行完毕前就对结果进行断言,此时异步请求还没返回结果并执行赋值操作。

Jest 提供了 done 回调来告知测试什么时候结束,所以可以使用 done 结合 $nextTicksetTimeout 来确保进行断言前一句处理完 Promise 回调。

除此,可以使用 flush-promises 依赖的方法来处理异步,flush-promises 会刷新所有处于 pending 状态或 resolved 状态的 Promise,这样就可确保断言前完成 Promise 回调。

import Test2 from '@/views/Test2'
import { shallowMount } from '@vue/test-utils'
import flushPromises from 'flush-promises'
import Vue from 'vue'

jest.mock('axios', () => ({
  get: () => Promise.resolve({
    data: {
      data: {
        array: [
          { name: 'test', title: '测试' }
        ]
      }
    }
  })
}))

describe('异步请求测试', () => {
  it('使用nextTick测试', async () => {
    const wrapper = shallowMount(Test2)
    wrapper.find('button').trigger('click')
    await wrapper.vm.$nextTick()
    expect(wrapper.vm.info).toEqual('测试')
  })

  it('使用flushPromises依赖测试', async () => {
    const wrapper = shallowMount(Test2)
    wrapper.find('button').trigger('click')
    await flushPromises()
    expect(wrapper.find('.info').text()).toEqual('测试')
  })

  it('使用setTimeout测试', done => {
    const wrapper = shallowMount(Test2)
    wrapper.find('button').trigger('click')
    setTimeout(() => {
      expect(wrapper.find('.info').text()).toEqual('测试')
      done() // 如果没有执行done回调,定时器不起作用
    }, 500)
  })
})

使用案例

测试DOM结构

通过 mountshallowMountfindfindAll 方法都可以返回一个包裹器对象,包裹器会暴露很多封装、遍历和查询其内部的Vue组件实例的便捷的方法。

其中,findfindAll 方法接受一个选择器作为参数,find 方法返回匹配选择器的 DOM 节点或 Vue 组件的 Wrapper,findAll 方法返回所有匹配选择器的 DOM 节点或 Vue 组件的 WrappersWrapperArray

选择器

  • CSS 选择器:
    • 标签选择器 (divpimg)
    • 类选择器 (.foo.bar)
    • 特性选择器 ([foo][foo="bar"])
    • id 选择器 (#foo#bar)
    • 伪选择器 (div:first-of-type)
    • ······
  • Vue 组件
  • 查找选项对象:
    • Name:可以根据一个组件的 name 选择元素。wrapper.find({ name: 'my-button' })
    • Ref:可以根据 $ref 选择元素。wrapper.find({ ref: 'myButton' })

如果是想通过Vue组件和查找选项对象方式来匹配组件,官方会推荐使用 findComponentgetComponent 方法替代 find 方法,而且官方表示在以后的版本会取消掉使用 find 来匹配组件。

<template>
  <div>
    <div id="info">123</div>
    <test1 ref="test1"/>
    <test2 />
  </div>
</template>

<script>
import Test1 from './Test1'
import Test2 from './Test2'
export default {
  name: 'Test2',
  components: {
    Test1,
    Test2
  }
}
</script>
import Test2 from '@/views/Test2'
import Test3 from '@/views/Test3'
import { shallowMount } from '@vue/test-utils'

describe('测试DOM结构', () => {
  it('测试', () => {
    const wrapper = shallowMount(Test3)
    expect(wrapper.find('#info').exists()).toBe(true)
    // expect(wrapper.find(Test2).exists()).toBe(true) // 如果寻找组件官方推荐使用findComponent或者getComponent
    expect(wrapper.findComponent(Test2).exists()).toBe(true)
    expect(wrapper.getComponent(Test2).exists()).toBe(true)
    // expect(wrapper.find({ ref: 'test1' }).exists()).toBe(true) // 建议使用findComponent或者getComponent代替find
    expect(wrapper.findComponent({ ref: 'test1' }).exists()).toBe(true)
  })
})

测试样式

测试样式可以通过 classes() 来测试,它可以判断包裹器对象是否包含某个类。

it('测试', async () => {
  const wrapper = shallowMount(Test2)
  expect(wrapper.find('#info').classes('active')).toBe(true)
})

测试Props

当测试父组件向子组件传递数据的行为时,我们想要测试的是当我们传递给子组件一个特定的参数时,子组件是否会按照我们所断言的变化。

可以通过在以下两种方式进行测试:

  • 初始化时使用 propsData 属性进行组件渲染

    it('测试', () => {
      const wrapper = shallowMount(Test, {
      	propsData: {
      	  msg: '123'
      	}
      })
    })
    
  • 使用 setProps 方法对组件 props 参数进行赋值

    it('测试', async () => {
      const wrapper = shallowMount(Test)
      await wrapper.setProps({ msg: '456' }) // 这里要使用await,否则会测试不通过
    })
    

可以对 Props 的类型、是否必须和默认值进行自定义的验证,通过 Vue 实例的 $options 获取包括 Props 在内的初始化选项:

<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  props: {
    msg: {
      type: String,
      required: true,
      default: 'hello'
    }
  }
}
</script>
// vm.$options返回Vue实例的初始化选项 
it('测试2', () => {
  const wrapper = shallowMount(HelloWorld, {
    propsData: {
      msg: '123'
    }
  })
  const msg = wrapper.vm.$options.props.msg
  expect(msg.type).toEqual(String)
  expect(msg.required).toBeTruthy()
  expect(msg.default).toBe('hello')
})

测试自定义事件

父子组件之间需要进行通讯,父传子通过 props,子传父通常使用 $emit

子组件

<template>
  <div>
    <button @click="add">+1</button>
  </div>
</template>

<script>
export default {
  name: 'MyButton',
  methods: {
    add () {
      this.$emit('add')
    }
  }
}
</script>

父组件

<template>
  <div>
    <my-button @add="increase" />
    <div class="result">{{ result }}</div>
  </div>
</template>

<script>
import MyButton from './MyButton'
export default {
  name: 'Test5',
  components: {
    MyButton
  },
  data () {
    return {
      result: 0
    }
  },
  methods: {
    increase () {
      this.result++
    }
  }
}
</script>

测试的步骤为:

  1. 当子组件发生点击事件时是否触发 add 方法;

  2. 子组件点击事件发生后,父组件的 increase 方法是否触发

import MyButton from '@/views/MyButton'
import Test5 from '@/views/Test5'
import { shallowMount, mount } from '@vue/test-utils'

describe('测试自定义事件', () => {
  const childFn = jest.fn()
  const parentFn = jest.fn()
  const child = shallowMount(MyButton)
  const parent = shallowMount(Test5)
  child.setMethods({
    add: childFn
  })
  parent.setMethods({
    increase: parentFn
  })
  it('测试MyButton', async () => {
    await child.find('button').trigger('click')
    // expect(child.vm.add).toBeCalled() // 与toBeCalled匹配的函数需为mock或spy方法,故这行代码错误
    expect(childFn).toBeCalled()
    expect(childFn).toHaveBeenCalledTimes(1)
  })

  it('测试父组件1', () => {
    // await child.find('button').trigger('click') 这里不能通过这个方法触发parentFn,因为child和parent是相互独立的
    // await parent.find('button').trigger('click') 因为parent是通过shallowMount渲染的,所以其实找不到button元素
    parent.findComponent(MyButton).vm.$emit('add')
    expect(parentFn).toBeCalled()
    expect(parentFn).toHaveBeenCalledTimes(1)
  })

  it('测试父组件2', async () => {
    const _parent = mount(Test5)
    const _parentFn = jest.fn()
    _parent.setMethods({
      increase: _parentFn
    })
    await _parent.find('button').trigger('click')
    expect(_parentFn).toBeCalled()
    expect(_parentFn).toHaveBeenCalledTimes(1)
  })
})

注意点:

  1. 使用 toBeCalled 匹配器要求被执行的方法是 mockspy 方法
  2. mountshallowMount 的区别
  3. 测试自定义事件时将要执行的方法(如上例的 add 和 increase方法) mock 掉,一方面可以在运行测试用例时省略执行方法的步骤,减少运行时间,另一方面,本次测试只是检验相关的方法是否被调用而不必了解方法内部的逻辑,若需要测试方法是否通过可针对方法进行单独测试。

测试插槽

插槽(slots)是用来在组件中插入、分发内容的。

我们可以往插槽中插入文本内容、html代码或者组件。

组件

<template>
  <div>
    <div class="slot1">
      <slot></slot>
    </div>
    <div class="slot2">
      <slot name="testSlot"></slot>
    </div>
  </div>
</template>

<script>
export default {
  name: 'Test6'
}
</script>

测试用例

import Test from '@/views/Test6'
import MyButton from '@/views/MyButton'
import { shallowMount } from '@vue/test-utils'

describe('测试插槽', () => {
  it('测试默认插槽', () => {
    const wrapper = shallowMount(Test, {
      slots: {
        default: '<span>测试</span>'
      }
    })
    expect(wrapper.find('.slot1').text()).toBe('测试')
    expect(wrapper.find('.slot1').find('span').html()).toBe('<span>测试</span>')
  })

  it('测试具名插槽', () => {
    const wrapper = shallowMount(Test, {
      slots: {
        testSlot: MyButton
      }
    })
    console.log(wrapper.find('.slot2').html())
    expect(wrapper.find('.slot2').find('button').exists()).toBeTruthy()
  })
})

测试 router-linkrouter-view

有时候组件中会用到 router-link 进行跳转,当组件中使用 router-linkrouter-view 组件时就涉及到路由,我们需要往项目中添加路由 Vue Router。

但是,在测试中应该杜绝在基本的 Vue 构造函数中安装 Vue Router,因为安装 Vue Router 之后 Vue 的原型上会增加 $route$router 这两个只读属性,为了避免这样的事情发生,需要创建一个 localVue 并对其安装 Vue Router,以防止原始的 Vue 被污染。

createLocalVue 返回一个 Vue 的类供你添加组件、混入和安装插件而不会污染全局的 Vue 类。

<template>
  <div>
    <router-link :to="{ name: 'About' }">跳转</router-link>
  </div>
</template>

<script>
export default {
  name: 'Test7'
}
</script>
import Test from '@/views/Test7'
import About from '@/views/About.vue'
import { mount, createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'

const localVue = createLocalVue()
localVue.use(VueRouter)
const routes = [{ path: '/about', name: 'Aboute', component: About }]
const router = new VueRouter({
  routes
})

describe('测试router-link', () => {
  it('测试', async () => {
    const wrapper = mount(Test, {
      localVue,
      router
    })
    await wrapper.find('a').trigger('click')
  })
})