vue2.x项目如何使用Jest框架+Vue Test Utils进行单元测试

451 阅读7分钟

什么是TDD、BDD

  • TDD:测试驱动开发,是一种软件开发方法,其中编写测试代码在编写实际功能代码之前进行。

流程:

1.编写一个失败的测试(红色)

2.编写最少量的代码使测试通过(绿色)

3.重构代码,确保测试仍然通过(重构)

  • BDD:行为驱动开发,强调团队之间的沟通和协作,关注软件的功能测试。

流程:

1.需求分析

2.描述需求定义文档

3.编写集成测试用例

优缺点:

  • TDD 测试覆盖率高,业务耦合度高,代码量大,比较独立。函数库,UI组件库适合使用单元测试,业务代码更适合集成测试。
  • BDD 关注产品功能,代码质量和测试覆盖率可能无法保证。所以还有人提出一种方案就是 BDD + TDD

什么是单元测试

单元测试是一种TDD开发方法。

前端单元测试是指对前端代码中的最小可测试单元进行检查和验证的过程。 这些单元可以是一个函数、一个组件或者一个模块等。前端单元测试的目的是确保每个独立的软件模块都按照预期运行,从而提高代码的质量和可维护性,同时也为进一步的集成测试和端到端测试提供坚实的基础。

前端单元测试通常使用专门的测试框架和断言库来进行,例如Jest、Mocha、karma、Jasmine等。测试人员需要编写测试用例,模拟用户的行为和输入,对前端代码进行测试,并验证其输出是否符合预期。如果测试结果不符合预期,就需要对代码进行调试和修复,直到测试通过为止。

image.png

什么是Vue Test Utils

Vue Test Utils是 Vue.js 官方的单元测试实用工具库

什么是jest

jest是一种前端自动化测试框架,集成了断言、仿真、快照、异步测试、覆盖率等各种工具,开箱即用。

如何使用单元测试

安装单元测试相关依赖jest @vue/test-utils @vue/cli-plugin-unit-jest babel-jest @vue/vue2-jest

npm install --save-dev @vue/cli-plugin-unit-jest@5.0.8 @vue/test-utils@1.3.6 jest@27.0.5 babel-jest@27.0.6 @vue/vue2-jest@27.0.0

或者通过该命令将自动安装Jest和VueTest Utils等所需工具

vue add @vue/cli-plugin-unit-jest

有关jest依赖的解释:

  • jest: Jest
  • @vue/test-utils:Vue Test Utils是Vue.js 官方的单元测试实用工具库
  • babel-jest:使用Babel自动编译JavaScript代码
  • @vue/vue2-jest:使用vue-jest去编译.vue文件
  • @vue/cli-plugin-unit-jest:在 Vue.js 项目中设置和集成 Jest 作为单元测试框架,会配置 Jest 测试环境,包括所需的依赖项、测试配置和测试运行脚本

项目文件目录结构 image.png

tests目录是自动化测试的工作区,可mock方法、mock请求、预置配置、加入工具方法、编写单元测试等。

在 package.json 中定义一个单元测试的脚本

// package.json
{
  "scripts": {
    "test": "jest"
  }
}

jest.config.js文件配置

jest.config.js用于配置jest的测试环境、es6语法转换、需要检测的文件类型、css预处理、覆盖率报告等。

1.初始化配置如下:

module.exports = {
   preset: '@vue/cli-plugin-unit-jest',
   // 运行每个测试文件前会先执行,比如一些全局变量。rootDir表示当前根目录
  setupFiles: ['<rootDir>/tests/unit/setup/main.setup.js'] //环境预置配置文件入口
}

2.有关jest配置的解释:

  • setupFiles:在运行单元测试前,先运行的文件,用于进行预制配置的设置,例如接口mock、插件配置、封装方法等;
   // tests/unit/setup/main.setup.js
   import './register-context'
   import './api' // api Mock
   import './utils' //工具方法
   import './plugins' // 插件声明

3.其他配置

3.1. jest不支持require.context,报错

// babel.config.js
module.exports = {
  env: {
    test: {
      plugins: ['require-context-hook']
    }
  }
}

新建register-context.js文件,然后引入到main.setup.js的最前面

// tests/unit/setup/register-context.js
// 解决require.context报错问题,必须放前面先执行
import registerRequireContextHook from 'babel-plugin-require-context-hook/register'
registerRequireContextHook()

3.2. element-ui的组件、以及自定义的全局组件不支持,报错

console.error [Vue warn]: Unknown custom element: <el-button> - did you register the component correctly? For recursive components, make sure to provide the "name" option.

console.error [Vue warn]: Unknown custom element: <IconHelp> - did you register the component correctly? For recursive components, make sure to provide the "name" option.

需要全局注册

// tests/unit/setup/plugins/global.js
import ElementUI from 'element-ui'
// 方式一:不会污染全局变量
import { createLocalVue } from '@vue/test-utils'
localVue.use(ElementUI)
// 全局注册Vue.prototype.$getQuery方法
localVue.prototype.$getQuery = jest.fn()

// 方式二:
import Vue from 'vue'
Vue.use(ElementUI)
// 自定义全局组件,引入即可,该目录文件下以及注册过了
import '@/components/Global/index'

全局注册Vue原型上的方法时,还可以直接在methods上注册

mount(TestTodo, {
   methods: { $getQuery: jest.fn() } 
})

编写测试用例

1.先写业务代码 TestTodo.vue

<template>
    <div>
      <div class="todo-title">{{ title }}</div>
      <el-dialog
        title="时效配置"
        :visible.sync="visible"
        width="50%"
        :before-close="handleClose"
      >
        <el-form ref="form" :model="form" label-width="134px" size="mini">
          <el-form-item label="总时效" prop="totalAging" :rules="rules">
            <el-input
              v-model.number="form.totalAging"
              style="width: 80%"
              oninput="if(value>9999)value=9999;if(value<1)value=1"
            ></el-input>
            小时
          </el-form-item>
          <el-form-item label="">
            <el-checkbox v-model="form.isWorkday">
              排除节假日
            </el-checkbox>
          </el-form-item>
          <el-form-item label="配置方式" :rules="rules" prop="approveType">
             <el-radio-group v-model="form.approveType">
              <el-radio
                v-for="(item, i) in approveTypeOptions"
                :key="i"
                :label="item.value"
              >
              {{ item.label }}
              </el-radio>
            </el-radio-group>
          </el-form-item>
          <el-form-item
            label="超时自动回复"
            :rules="rules"
            prop="autoFlow"
          >
            <el-select v-model="form.autoFlow">
              <el-option
                v-for="(item, i) in autoFlowOptions"
                :key="i"
                :label="item.label"
                :value="item.value"
              >
              </el-option>
            </el-select>
          </el-form-item>
          <template v-if="form.approveType === 1">
            <el-row
                class="single"
                v-for="(item, index) in form.approveConfig"
                :key="index"
                >
             <el-col :span="8">
              <el-form-item label="节点" label-width="80px">
                <span>{{ item.name }}</span>
              </el-form-item>
              </el-col>
              <el-col :span="8">
                <el-form-item
                  label="时效"
                  :rules="rules"
                  :prop="'approveConfig.' + index + '.singleAging'"
                  label-width="80px"
                >
                  <el-input
                    v-model.number="item.singleAging"
                    style="width: 50%"
                    oninput="if(value>99)value=99;if(value<1)value=1"
                  ></el-input>
                  小时
                </el-form-item>
              </el-col>
            </el-row>
          </template>
        </el-form>
        <span slot="footer" class="dialog-footer">
          <el-button @click="closeSelf">取 消</el-button>
          <el-button type="primary" @click="onSubmit">确 定</el-button>
        </span>
      </el-dialog>
    </div>
  </template>
  
  <script>
  import { get_aging, set_aging } from '@/api/TestTodo'
  export default {
    name: 'TodoMVC',
    props: {
      visible: {
        type: Boolean,
        default: false
      },
      id: {
        type: String,
        default: ''
      }
    },
    data() {
      return {
        title: '这是一个待办页面',
        rules: [
          {
            required: true,
            message: '必填项'
          }
        ],
        approveTypeOptions: [
          {
            value: 0,
            label: '整体'
          },
          {
            value: 1,
            label: '单点'
          }
        ],
        autoFlowOptions: [
          {
            value: 0,
            label: '同意'
          },
          {
            value: 1,
            label: '不同意'
          }
        ],
        form: {
          totalAging: undefined,
          id: '',
          isWorkday: false,
          approveType: 0,
          autoFlow:0,
          approveConfig: []
        }
      }
    },
    watch: {
      visible(val) {
        if (val && this.id) {
          this.getAging()
        }
      }
    },
    methods: {
      async getAging() {
        const postData = {
          id: this.id
        }
        const { code, data } = await get_aging(postData)
        if (code === 0) {
          this.form.id = data.id
          this.form.approveType = data.approveType
          this.form.totalAging = data.totalAging
          this.form.isWorkday = data.isWorkday
          this.form.autoFlow = data.autoFlow
          this.form.approveConfig = data.approveConfig
        }
      },
      onSubmit() {
        this.$refs.form.validate(async (valid) => {
          if (valid) {
            if (this.form.approveType===1) {
              var count = 0
              let tempArr = this.form.approveConfig
              tempArr.map((item) => {
                count += item.singleAging
              })
              if (count !== this.form.totalAging) {
                this.$message.error('总时效必须等于时效的总和')
                return
              }
            }
            const postData = this.form
            await set_aging(postData)
            this.closeSelf()
          } else {
            console.log('error submit!!')
            return false
          }
        })
      },
      closeSelf() {
        this.$emit('update:visible', false)
      },
      handleClose(done) {
        this.closeSelf()
        done()
      }
    }
  }
  </script>

2.测试用例 TestTodo.spec.js

import { mount, createLocalVue } from '@vue/test-utils'
import TestTodo from '@/views/demo/test/TestTodo.vue'
import ElementUI from 'element-ui'
const localVue = createLocalVue()
localVue.use(ElementUI)

describe('TestTodo.vue', () => {
  let wrapper
  beforeEach(() => {
    wrapper = mount(TestTodo, {
        localVue,
        propsData: {
            visible: true
        }
      })
  })
  afterEach(() => {
    wrapper.destroy()
  })
  it('查找todo-title节点的内容', async () => {
    // 查找DOM 节点
    expect(wrapper.find('.todo-title').text()).toBe('这是一个待办页面')
  })
  it('查找el-dialog组件的标题等属性', async () => {
    const visible = false
    await wrapper.setProps({ visible }) // 更新props,await 必须要加
    // 现在查找 el-dialog
    // 组件查找用findComponent
    const dialog = wrapper.findComponent({ name: 'ElDialog' })
    // 检查 el-dialog 是否存在
    expect(dialog.exists()).toBe(true)
    expect(dialog.props('visible')).toBe(visible)

    const dialogTitle = '时效配置'
    // 检查 el-dialog 的 title 属性是否正确
    expect(dialog.props('title')).toBe(dialogTitle)
    // 或者检查渲染后的 DOM 中是否包含正确的标题文本
    expect(dialog.find('.el-dialog__header').text()).toContain(dialogTitle)
  })
  it('查找按钮el-button', async () => {
    // 查找钮
    const footer = wrapper.find('.dialog-footer')
    expect(footer.isVisible()).toBe(true)
    const btns = footer.findAllComponents({ name: 'ElButton' })
    expect(btns).toHaveLength(2)
    expect(btns.at(0).text()).toBe('取 消')
    expect(btns.at(1).text()).toBe('确 定')
  })
  it('设置表单值', async () => {
    await wrapper.vm.$nextTick()
    const formItems = wrapper.findAllComponents({ name: 'ElFormItem' })
    // 检查第一个输入框用at(0)
    const firstItem = formItems.at(0)
    const textInput = firstItem.find('input[type=text]')
    await textInput.setValue('10000')
    expect(wrapper.vm.form.totalAging).toBe(9999)
    expect(textInput.element.value).toBe('9999')
    const rules = [
      {
        required: true,
        message: '必填项'
      }
    ]
    expect(firstItem.props('rules')).toEqual(expect.arrayContaining(rules))
    // 检查第二个复选框
    const secondItem = formItems.at(1)
    const checkboxInput = secondItem.find('input[type="checkbox"]')
    await checkboxInput.setChecked()
    // checkboxInput.element等价于checkedwrapper.vm.form.isWorkday
    expect(checkboxInput.element.checked).toBeTruthy()
    await checkboxInput.trigger('click')
    await checkboxInput.trigger('change')
    expect(wrapper.vm.form.isWorkday).toBeFalsy()

    // 检查第三个单选框
    const thirdItem = formItems.at(2)
    const radioGroup = thirdItem.findComponent({ name: 'ElRadioGroup' })
    const radios = radioGroup.findAllComponents({ name: 'ElRadio' })
    expect(radios).toHaveLength(2)
    // radioGroup.vm.value等价于wrapper.vm.form.approveType
    expect(radioGroup.vm.value).toBe(0)
    const secondRadio = radios.at(1).find('input[type="radio"]')
    await secondRadio.trigger('change')
    expect(wrapper.vm.form.approveType).toBe(1)

    // 检查第四个下拉框
    const options = formItems
      .at(3)
      .findComponent({ name: 'ElSelect' })
      .findAllComponents({ name: 'ElOption' })
    await options.at(1).trigger('click')
    expect(wrapper.vm.form.autoFlow).toBe(1)
  })
})
import { mount, createLocalVue } from '@vue/test-utils'
import TestTodo from '@/views/demo/test/TestTodo.vue'
import * as api from '@/api/TestTodo'
import ElementUI from 'element-ui'
const localVue = createLocalVue()
localVue.use(ElementUI)

// 模拟API返回结果
const mockResponse = {
  code: 0,
  data: {
    id: 446,
    approveType: 1,
    totalAging: 999,
    isWorkday: true,
    autoFlow: 1,
    approveConfig: [
      { name: '[业务]坐席组长', singleAging: null },
      { name: '[质检]质检主管', singleAging: null }
    ]
  }
}
jest.mock('@/api/appealProcess', () => ({
  get_aging: jest.fn(() => Promise.resolve(mockResponse)),
  set_aging: jest.fn(() =>
    Promise.resolve({
      code: 0,
      message: '操作成功'
    })
  )
}))
describe('test watch and methods', () => {
  const wrapper = mount(TestTodo, {
    localVue
  })
  wrapper.setProps({visible: true})
  it('表单校验and表单提交', async () => {
    expect(wrapper.vm.form.totalAging).toBeUndefined()
    // 整体时效
    const formItems = wrapper.findAllComponents({ name: 'ElFormItem' })
    expect(formItems).toHaveLength(4)
    const firstItem = formItems.at(0)
    // 触发输入框失去焦点事件,以触发验证
    await firstItem.find('input[type="text"]').trigger('blur')
    // 验证表单是否显示错误消息
    expect(wrapper.find('.el-form-item__error').exists()).toBe(true)
    // 设置值
    await firstItem.find('input[type="text"]').setValue('99')
    const options = formItems
      .at(3)
      .findComponent({ name: 'ElSelect' })
      .findAllComponents({ name: 'ElOption' })
    await options.at(1).trigger('click')

    const footer = wrapper.find('.dialog-footer')
    const btns = footer.findAllComponents({ name: 'ElButton' })
    await btns.at(1).trigger('click') // 触发点击事件
    // 校验是否成功
    expect(api.set_aging).toHaveBeenCalled()
    // 验证提交的数据
    const event = wrapper.emitted('update:visible')[0]
    expect(event).toHaveLength(1)
    expect(event[0]).toEqual(false)
  })
  it('表单提交and验证失败toast提示', async () => {
    // 请求接口获取默认值
    await wrapper.vm.getAging()
    expect(api.get_aging).toHaveBeenCalled()
    expect(wrapper.vm.form.approveConfig).toEqual(
      mockResponse.data.approveConfig
    )
    const formItems = wrapper.findAllComponents({ name: 'ElFormItem' })
    expect(formItems).toHaveLength(4 + wrapper.vm.form.approveConfig.length * 2)
    // 表单校验
    wrapper.vm.form.approveConfig.forEach(async (item, index) => {
      const formInput = formItems.at(4 + 2 * index + 1)
      await formInput.find('input[type="text"]').trigger('blur')
      // 验证表单是否显示错误消息
      expect(item.singleAging).toBeNull()
      expect(formInput.find('.el-form-item__error').exists()).toBe(true)
      // 填充表单
      await formInput.find('input[type="text"]').setValue('12')
    })

    // 填充表单
    const firstItem = formItems.at(0)
    await firstItem.find('input[type="text"]').setValue('199')
    // 查找 顶部按钮
    const footer = wrapper.find('.dialog-footer')
    const btns = footer.findAllComponents({ name: 'ElButton' })
    // 先模拟函数,再调用函数
    const spy = jest.spyOn(wrapper.vm.$message, 'error')
    await btns.at(1).trigger('click') // 触发点击事件
    expect(spy).toHaveBeenCalledWith('总时效必须等于时效的总和')
    // 恢复 $message.error 的原始实现(可选)
    spy.mockRestore()
    // 校验不通过重新填充表单
    await firstItem.find('input[type="text"]').setValue('24')
    await btns.at(1).trigger('click') // 触发点击事件
    // 验证提交的数据
    const event = wrapper.emitted('update:visible')[0]
    expect(event[0]).toEqual(false)
  })
})

执行test命令npm run test会执行全部xx.spec.js文件。如果想单独执行某个文件xx.js,可以npm run test xx

Vue Test Utils之Api

常用API

  • mount():会创建一个完整的 DOM 树,挂载整个组件树,包括嵌套组件
  • shallowMount():只会挂载最外层的组件,而不会挂载嵌套组件内部的内容,挂载速度快
  • createLocalVue()

Wrapper

Wrapper是一个对象,该对象包含了一个挂载的组件或 vnode,以及测试该组件或 vnode 的方法。

常用方法:

// 触发输入框失去焦点事件,以触发验证
 await wrapper.find('input[type="text"]').trigger('blur')
// 比如复选框、单选框、下拉框,可通过触发事件,实现选中效果
await wrapper.find('input[type="checkbox"]').trigger('change') 
// 设置选中可用 await wrapper.find('input[type="checkbox"]').setChecked()
await wrapper.find('input[type="radio"]').trigger('change')
const options = wrapper.findComponent({ name: 'ElSelect' }).findAllComponents({ name: 'ElOption' })
await options.at(1).trigger('click')
// 按钮点击事件
await wrapper.find('button').trigger('click')

WrapperArray

一个 WrapperArray 是一个包含 Wrapper 数组以及 Wrapper 的测试方法等对象。

常用方法

Wrapper、VueWrapper、ErrorWrapper

Jest之Api

Jest主要负责对测试结果进行断言。

常用全局设定

  • afterAll(fn,timeout):文件内所有测试完成后执行的钩子函数。
  • afterEach(fn,timeout):文件内每个测试完成后执行的钩子函数。
  • beforeAll(fn,timeout):文件内所有测试开始前执行的钩子函数。
  • beforeEach(fn,timeout):文件内每个测试开始前执行的钩子函数。
  • describe(name,fn):将多个相关的测试组合在一起的块。
  • describe.only(name, fn):当测试文件有多个块时,运行某个指定的describe
  • describe.skip(name, fn):当测试文件有多个块时,跳过特定的 describe 块
  • describe.each(table)(name, fn, timeout):可以用多组测试数据去测试同一测试用例。 这样你就可以只用编写一次测试代码。

执行顺序:

describe(1次) > beforeAll(1次) > beforeEach(n次) > it(n个) > afterEach(n次) > afterAll(1次)

describe.each([  
[1, 1, 2],  
[1, 2, 3],  
[2, 1, 3],  
])('.add(%i, %i)', (a, b, expected) => {  
  test(`returns ${expected}`, () => {  
    expect(a + b).toBe(expected);  
  });  
  
  test(`returned value not be greater than ${expected}`, () => {  
    expect(a + b).not.toBeGreaterThan(expected);  
  });  
  
  test(`returned value not be less than ${expected}`, () => {  
    expect(a + b).not.toBeLessThan(expected);  
  });  
});
  • test(name,fn,timeout):

常用Expect断言方法。

  • .not:判断expect内容不;
  • .toBe(value):判断expect内容是否与value相同;
  • .toBeTruthy():除了false,0,",null,undefined,NaN都将通过;
  • .toBeFalsy():与上述相反;
  • .toEqual(value):比较Object/Array是否相同。
  • .toBeNull():判断expect内容是否是null。
  • .toBeUndefined():判断expect内容是否是undefined。
  • .toBeNaN():判断expect内容是否是NaN。
  • .toContain(item):判断expect内容是否包含item。
  • .toMatch(regexp | string):断expect内容是否匹配regexp | string。
  • .toHaveLength(number):判断expect内容长度是否是number。
  • .toHaveProperty(keyPath,value?):判断expect内容是否有属性keyPath。
  • .toHaveBeenCalled():判断expect内容是否被调用。
  • .toHaveBeenCalledTimes(number):判断expect内容被调用次数是否为number。
  • .toHaveBeenReturned():判断expect内容是否有返回值。
  • .toHaveBeenReturnedWith(value):判断expect内容返回值是否为value。
  • .toHaveBeenReturnedTimes(number):判断expect内容返回次数是否为number。
  • expect.arrayContaining(array):判断expect内容是否包含array。

expect(['a', 'b', 'c']).toEqual(expect.arrayContaining(['a', 'b']))

expect(['a', 'e', 'c']).not.toEqual(expect.arrayContaining(['a', 'b']))

  • expect.objectContaining(object):判断expect内容是否包含object。

常用模拟函数

  • mockFn.mockClear():在两次断言之间清理模拟函数的使用数据。
  • mockFn.mockReset():会执行 mockFn.mockClear() 的所有操作,同时还会移除任何模拟的返回值或实现,将模拟函数完全重置回其初始状态
  • mockFn.mockRestore():会执行mockFn.mockRestore() 所做的所有事情,同时还会恢复原始(非模拟)的实现。当你想在某些测试用例中模拟函数,而在其他测试用例中恢复原始实现时。
  • mockFn.mockImplementation(fn):接受一个函数,该函数应作为模拟的实现。模拟本身仍将记录所有来自自身的调用和实例
  • mockFn.mockImplementationOnce(fn):接受一个函数,该函数将用作对模拟函数的单次调用的模拟实现。
  • mockFn.mockReturnValue(value):接受一个值,该值将在每次调用模拟函数时返回
  • mockFn.mockReturnValueOnce(value):接受一个值,该值将在对模拟函数的一次调用中返回。
  • mockFn.mockResolvedValue(value):等价于jest.fn().mockImplementation(() => Promise.resolve(value));
  • mockFn.mockResolvedValueOnce(value):等价于jest.fn().mockImplementationOnce(() => Promise.resolve(value));

常用Jest对象

  • jest.mock(moduleName,factory,options):使用自动模拟版本对模块进行模拟
jest.mock('@/api/TestTodo', () => ({ 
  get_aging: jest.fn(() => Promise.resolve(mockResponse)), 
  set_aging: jest.fn(() => Promise.resolve({ code: 0, message: '操作成功'}))
 }))

  • jest.fn(implementation?):返回一个新的、未使用的模拟函数。
  • jest.spyOn(object,methodName,accessType?):创建一个类似于 jest.fn 的模拟函数,同时还会跟踪对 object[methodName] 的调用。返回一个 Jest 模拟函数。
// 先模拟函数,再调用函数
    const spy = jest.spyOn(wrapper.vm.$message, 'error')
    await btns.at(1).trigger('click') // 触发点击事件后再验证
    expect(spy).toHaveBeenCalledWith('总时效必须等于时效的总和')

常见问题

  1. TypeError: Cannot read property '$createElement' of null

嵌套的子组件里面是一个函数式组件,由于函数式组件没有Vue实例和上下文 vue-test-utils 试图模拟 Vue 实例的某些行为,但是函数式组件不需要完整的 Vue 实例,因此没有 $createElement 方法。

  1. [vue-test-utils]: could not overwrite property $route, this is usually caused by a plugin that has added the property as a read-only value

使用mocks模拟$route,和安装Vue Router不能同时使用。 因为安装 Vue Router 会在 Vue 的原型上添加 $route和 $router只读属性。

参考:v1.test-utils.vuejs.org/zh/guides/u…

  1. TypeError: Cannot read property 'dispatch' of undefined

未引用store,需要注册vuex

参考:v1.test-utils.vuejs.org/zh/guides/#…

  1. TypeError: Cannot set property 'onkeydown' of null

~~注册组件时,组件嵌套有子组件,需要将子组件存根,只需要对子组件存根,如子组件里面再嵌套了子组件,不需要对子组件的子组件存根 用到挂载选项stubs,如:

import Foo from './Foo.vue'

mount(Component, { stubs: ['registered-component'] })~~