Vue中的单元测试实践

1,889 阅读7分钟

前言

上一篇我们讲解了单测的基本理论与vue的单测入门,所谓千里之行,始于足下,我们已经完成了安装到入门,并完成了第一个单测用例,每一个出色的测试用例都是从第一个开始的。我们在学习任何技能的时候,一般都是先通过理论学习,然后再结合理论进行实践,本篇我们就来探讨一下在vue中进行单元测试的最佳实践以及规范

使用Chrome进行单测代码调试

在我们编写单元测试的过程中,避免不了出现一些代码的错误问题,此时我们想定位代码问题,只能通过命令行或者report,那么怎么模拟测试的运行环境、将测试的代码进行调试呢?node+chrome提供了这种能力,实现步骤如下:

1.在代码内需要调试的地方添加debugger

// increment.spec.js
// 导入测试工具集
import { mount } from "@vue/test-utils";
import Increment from "@/views/Increment";

describe("Increment", () => {
  // 挂载组件,获取包裹器
  const wrapper = mount(Increment);
  const vm = wrapper.vm;
  // 模拟用户点击
  it("button click should increment the count", () => {
    expect(vm.count).toBe(0);
    const button = wrapper.find("button");
    debugger;
    button.trigger("click");
    expect(vm.count).toBe(1);
  });
});

2.在package.json的scripts添加执行debug脚本

"test:unit:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --no-cache --runInBand",

3.执行如下命令

npm run test:unit:debug

4.打开Chrome浏览器,点击调试图标,即可打开调试窗口

image.png

image.png

点击开始调试按钮,你将获得与客户端代码调试一样的体验

vue中的组件

无论是vue或者react、ng项目,都存在组件的概念,组件通常分为UI组件、业务组件,不同类型的组件,对于测试的侧重点不一样,一般UI组件更关注界面以及交互的通用性,业务组件可能更关注业务功能的复用,大型的应用经常发生的事情就是,随着时间的增加,UI逻辑与业务逻辑越来越混乱,关注点很零散,这样会导致低的覆盖率,而单元测试将迫使你将UI逻辑与业务逻辑分开,这将对单元测试十分有益,所以保持二者分离十分有必要。

好的单元测试必须遵守AIR原则:

  1. A-Automatic(自动化原则) 单元测试应该是自动运行,自动校验,自动给出结果

  2. I-Independent(独立原则) 单元测试应该是独立运行,互相之间无依赖,对外部资源无依赖,多次运行之间无依赖

  3. R-Repeatable(可重复原则) 单元测试是可重复运行的,每次的结果都稳定可靠

UI组件

UI组件又称为基础组件,一般指的是提供基础的样式、逻辑、交互与通用能力的封装,并保持一定的可扩展性,通用性,前端同学为了提升开发效率,一般都会选择常见的UI库,类似ElementUI,AntDesign,或者公司内部的组件库,当然,随着项目的推进,通常也会沉淀出团队内的组件,它是前端组件的最小颗粒度,不受到业务的影响,所以称之为基础组件

对于 UI 组件来说,不推荐一味追求行级覆盖率,因为它会导致我们过分关注组件的内部实现细节,从而导致琐碎的测试。 取而代之的是,我们推荐把测试撰写为断言你的组件的公共接口,并在一个黑盒内部处理它。一个简单的测试用例将会断言一些输入 (用户的交互或 prop 的改变) 提供给某组件之后是否导致预期结果 (渲染结果或触发自定义事件)。

业务组件

业务组件一般指的对一套业务功能的封装,其中可能会用到多个UI组件,主要是为了单个或者多个项目中的类似场景的复用,业务组件就是为了减少业务代码开发过程中的重复工作,对业务逻辑进行封装,根据业务需求提供一定的扩展性、松耦合、扁平化的数据结构等。

局限性

单元测试不一定适合所有组件,每个项目,不适合的事情做起来反而会适得其反,所以写单元测试之前,先考虑清楚这个组件是不是应该写单元测试,比如代码里面充斥着颗粒度低,耦合度高的代码,你会发现你需要花费大量的时间去完成覆盖率,这会让我们花费大量时间去维护两套代码,所以我们要权衡利弊,考虑针对系统中比较稳定的、核心的组件写单元测试

如何测试组件

对于要测试的组件来说,一个很重要的问题就是,这个组件是做什么的,他的输入输出是什么? 一旦确定输入和输出,我们就有了测试方向,可以将组件视为黑盒或者一个函数,他们接受输入,形成输出,测试不同的输入对于输出的影响,这是核心点,下面列出测试范围:

UI组件

  • 生命周期
  • 输入参数props
  • 渲染文本测试
  • 事件交互测试
  • 异步测试

业务组件

  • 生命周期
  • 输入参数props
  • 渲染文本测试
  • dom测试
  • 涉及到逻辑的内联样式测试
  • 事件交互测试
  • mock数据
  • 异步测试
  • 流程模拟
  • vuex测试
  • 路由模拟测试

原则:语句覆盖率达到 60% ;核心模块的语句覆盖率和分支覆盖率至少都要达到 80%

上述测试范围的具体实现在Vue Test Utils官方文档已经写的非常详细了,基本都可以参考其接口进行实现,附上文档地址:

Vue Test Utils文档地址:vue-test-utils.vuejs.org/zh/

实现案例

UI组件
UI组件实例

MyButton.vue

<template>
  <button :class="['my-button', `my-button--${type}`, { 'my-button--disabled': disabled }]" :disabled="disabled" @click="handleClick">
    <i :class="icon" v-if="icon"></i>
    <span v-if="$slots.default"><slot></slot></span>
  </button>
</template>

<script>
export default {
  name: "my-button",
  props: {
    type: {
      type: String,
      default: "default",
    },
    disabled: {
      type: Boolean,
      default: false,
    },
    icon: {
      type: String,
      default: "",
    },
  },
  methods: {
    handleClick(evt) {
      this.$emit("click", evt);
    },
  },
};
</script>

<style scoped lang="less">
.my-button {
  border: 1px solid #dcdfe6;
  color: #606266;
  white-space: nowrap;
  cursor: pointer;
  background: #fff;
  display: inline-block;
  padding: 12px 20px;
  font-size: 14px;
  border-radius: 4px;
}

.my-button-primary {
  color: #fff;
  background-color: #409eff;
  border-color: #409eff;
}

.my-button--success {
  color: #fff;
  background-color: #67c23a;
  border-color: #67c23a;
}

.my-button--disabled {
  cursor: not-allowed;
}
</style>

MyButton.spec.js

import { mount } from "@vue/test-utils";
import MyButton from "@/components/MyButton";

describe("Button", () => {
  it("render button", () => {
    const wrapper = mount(MyButton, {
      slots: {
        default: "ok",
      },
    });
    const button = wrapper.find("button");
    expect(button.exists()).toBe(true);
    expect(button.text()).toBe("ok");
    expect(button.classes("my-button--default")).toBe(true);
  });

  it("render a primary button", () => {
    const wrapper = mount(MyButton, {
      slots: {
        default: "ok",
      },
      propsData: {
        type: "primary",
      },
    });
    const button = wrapper.find("button");
    expect(button.classes("my-button--primary")).toBe(true);
  });

  it("render a disabled button", () => {
    const wrapper = mount(MyButton, {
      slots: {
        default: "ok",
      },
      propsData: {
        disabled: true,
      },
    });
    const button = wrapper.find("button");
    expect(button.classes("my-button--disabled")).toBe(true);
    expect(button.attributes("disabled")).toBe("disabled");
  });

  it("icon", () => {
    const wrapper = mount(MyButton, {
      slots: {
        default: "ok",
      },
      propsData: {
        icon: "correct",
      },
    });
    const correct = wrapper.find("button .correct");
    expect(correct.exists()).toBe(true);
  });

  it("click", async () => {
    const wrapper = mount(MyButton, {
      slots: {
        default: "ok",
      },
    });
    const button = wrapper.find("button");
    await button.trigger("click");
    expect(wrapper.emitted().click).toBeTruthy();
  });
});
UI组件运行结果

image.png

image.png

业务组件
组件分析

这是一个选择版本的组件,如图所示:

  1. 默认:

image.png

  1. 鼠标移入:

image.png

  1. 点击编辑:

image.png

  1. 点击删除后 向外部发出事件
测试思路
  • 断言props对页面渲染的影响,覆盖props相关条件分支语句,断言vm._data改变
  • 模拟鼠标移入场景,触发事件 mouseenter,断言dom渲染,vm._data改变
  • 模拟鼠标移出场景,触发事件mouseleave,断言dom渲染,vm._data改变
  • 模拟鼠标移入场景,触发事件 mouseenter
  • 模拟点击编辑按钮,触发事件click,断言相关vm._data改变
  • 触发自定义事件“visible-change”,断言相关vm._data改变
  • 触发下拉Item的click事件,断言自定义事件“updatePackageVersion”触发
  • 模拟搜索输入框输入,模拟触发input的 “change”事件,断言vm._data改变
  • 模拟点击删除按钮,断言自定义事件“deletePackage ”被触发
实例

Dependency.vue

<template>
  <div class="dependency" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
    <p class="dependency-selected">
      <span class="dependency-name">{{ pkg.name }}</span>
      <span class="dependency-version" v-if="!clientId || version !== '' || appType === 2">
        <span class="dependency-latest">{{ version }}</span>
      </span>
    </p>
    <div v-if="isInOperation && ((pkg.key !== 'platform' && appType === 2) || appType === 0)" class="dependency-operations">
      <span class="dependency-operation" @click="handleDeletePackage"><i class="mp-icon-trash-2"></i></span>
      <el-dropdown trigger="click" @command="handleCommand" @visible-change="handleVisibleChange">
        <span class="dependency-operation" ref="setting"><i class="mp-icon-edit-2"></i></span>
        <el-dropdown-menu slot="dropdown" class="versions-menu">
          <li class="app-version-filter">
            <el-input
              placeholder="版本号"
              v-model="versionKeywords"
              icon="search"
              debounce="500"
              :on-icon-click="handleVersionsKeywordChange"
              @change="handleVersionsKeywordChange"
            >
            </el-input>
          </li>
          <li class="versions-menu-options">
            <ul class="versions-sub-menu">
              <el-dropdown-item
                :class="[{ 'dep-selected': option.ver === version }]"
                :command="option"
                v-for="(option, index) in stableVersions"
                :key="index"
                >{{ option.ver }}</el-dropdown-item
              >
            </ul>
          </li>
        </el-dropdown-menu>
      </el-dropdown>
    </div>
  </div>
</template>

<script>
export default {
  name: 'application-dependency',
  props: {
    appType: Number,
    clientId: String,
    pkg: Object
  },
  data() {
    return {
      versionKeywords: '',
      defaultVersions: [],
      stableVersions: [],
      version: '',
      latest: '',
      stable: '',
      beta: '',
      isOperationShow: false,
      isInOperation: false,
      currentVersion: '',
      type: 0,
      types: [
        {
          label: '最新版本',
          value: 0
        },
        {
          label: '内测版本',
          value: 2
        }
      ]
    }
  },
  computed: {
    versions() {
      const pkg = this.pkg
      let versions = []
      if (pkg.versions) {
        versions = pkg.versions
      } else {
        if (pkg.version) {
          versions = [
            {
              state: 5,
              ver: pkg.version
            }
          ]
        }
      }

      return versions
    }
  },
  watch: {
    versions() {
      this.stableVersions = this.getStableVersions()
    }
  },
  created() {
    const stableVersions = this.getStableVersions()
    const latest = stableVersions[0]
    const pkg = this.pkg

    this.stableVersions = stableVersions
    this.defaultVersions = stableVersions

    if (latest) {
      if (pkg.key === 'platform') {
        this.version = pkg.currentVersion || latest.ver
      } else {
        this.version = latest.ver
      }
    }
  },
  methods: {
    getStableVersions() {
      if (this.versions.length === 0) {
        return this.versions
      }
      return this.versions.filter(version => version.state === 5)
    },
    handleVersionsKeywordChange() {
      this.stableVersions = this.defaultVersions.filter(version => version.ver.indexOf(this.versionKeywords) > -1)
    },
    handleVisibleChange(visible) {
      if (visible) {
        this.isInOperation = true
        this.isOperationShow = true
      } else {
        this.isInOperation = false
        this.isOperationShow = false
        this.stableVersions = []
        this.versionKeywords = ''
      }
    },
    handleCommand(cmd) {
      this.version = cmd.ver

      this.$emit('updatePackageVersion', {
        key: this.pkg.key,
        version: this.version,
        isLatest: false
      })
    },
    handleMouseEnter() {
      this.isInOperation = true
    },
    handleMouseLeave() {
      if (!this.isOperationShow) {
        this.isInOperation = false
      }
    },
    handleDeletePackage() {
      this.$emit('deletePackage', this.pkg.key)
    }
  }
}
</script>

Dependency.spec.js

// 导入测试工具集
import { mount, createLocalVue } from '@vue/test-utils'
import Dependency from '@/components/Dependency'
import ElementUI from 'element-ui'
const localVue = createLocalVue()
localVue.use(ElementUI)

describe('Dependency', () => {
  it('测试渲染组件文本', () => {
    const pkg = {
      key: 'platform',
      appId: 60531,
      name: '你太帅了',
      version: 'latest',
      currentVersion: null,
      versions: [
        {
          ver: '3.0.5.24',
          state: 5
        },
        {
          ver: '3.0.5.23',
          state: 5
        },
        {
          ver: '3.0.5.22',
          state: 5
        }
      ]
    }
    const wrapper = mount(Dependency, {
      propsData: {
        pkg
      },
      localVue
    })
    // expect(wrapper.element).toMatchSnapshot()
    expect(wrapper.find('.dependency-name').text()).toBe(pkg.name)
    expect(wrapper.find('.dependency-latest').text()).toBe('3.0.5.24')
    expect(wrapper.vm.versions.length).toBe(3)
    expect(wrapper.vm.stableVersions.length).toBe(3)
  })

  it('pkg.versions不存在,版本列表为当前pkg.version,长度为1', () => {
    const pkg = {
      key: 'platform',
      appId: 60531,
      name: '你太帅了',
      version: 'latest',
      currentVersion: null
    }
    const wrapper = mount(Dependency, {
      propsData: {
        pkg
      },
      localVue
    })

    expect(wrapper.vm.versions.length).toBe(1)
  })

  it('pkg.key不等于platform', () => {
    const pkg = {
      key: 'product',
      appId: 60531,
      name: '产品',
      version: 'latest',
      currentVersion: null,
      versions: [
        {
          ver: '3.0.5.24',
          state: 5
        },
        {
          ver: '3.0.5.23',
          state: 5
        },
        {
          ver: '3.0.5.22',
          state: 5
        }
      ]
    }
    const wrapper = mount(Dependency, {
      propsData: {
        pkg
      },
      localVue
    })

    expect(wrapper.vm.version).toBe('3.0.5.24')
  })

  it('clientId存在、version不存在、appType不等于2,不会渲染版本文字', () => {
    const pkg = {
      key: 'platform',
      appId: 60531,
      name: '你太帅了',
      version: 'latest',
      currentVersion: null,
      versions: []
    }
    const wrapper = mount(Dependency, {
      propsData: {
        pkg,
        clientId: '1111111',
        appType: 1
      },
      localVue
    })
    expect(wrapper.find('.dependency-version').exists()).toBe(false)
  })

  it('mouseenter后:渲染dropDown、点击修改按钮、点击选择版本、搜索、点击删除按钮', async () => {
    const wrapper = getMouseEnterWrapper()

    const vm = wrapper.vm

    const dependencyDiv = wrapper.find('.dependency')

    // 触发mouseenter
    await dependencyDiv.trigger('mouseenter')

    expect(vm.isInOperation).toBe(true)

    expect(wrapper.find('.dependency-operations').exists()).toBe(true)

    await dependencyDiv.trigger('mouseleave')

    expect(vm.isInOperation).toBe(false)

    // 触发mouseenter
    await dependencyDiv.trigger('mouseenter')

    const elDropDown = wrapper.findComponent({ name: 'el-dropdown' })

    // 点击修改按钮
    elDropDown.vm.$emit('visible-change', true)

    expect(vm.isOperationShow).toBe(true)

    const menuItem = wrapper.find('.versions-sub-menu li')

    await menuItem.trigger('click')

    // 触发emit,更新外部数据
    expect(wrapper.emitted().updatePackageVersion).toBeTruthy()

    elDropDown.vm.$emit('visible-change', false)

    expect(vm.isInOperation).toBe(false)
    expect(vm.isOperationShow).toBe(false)
    expect(vm.stableVersions.length).toBe(0)
    expect(vm.versionKeywords).toBe('')

    const elInput = wrapper.findComponent({ name: 'el-input' })

    vm.versionKeywords = '3.0.5.23'

    elInput.vm.$emit('change')

    expect(vm.stableVersions.length).toBe(1)

    // 点击删除
    const dependencyOperation = wrapper.find('.dependency-operation')

    await dependencyOperation.trigger('click')

    // 触发emit,更新外部数据
    expect(wrapper.emitted().deletePackage).toBeTruthy()
  })
})

function getMouseEnterWrapper() {
  const pkg = {
    key: 'platform',
    appId: 60531,
    name: '你太帅了',
    version: 'latest',
    currentVersion: null,
    versions: [
      {
        ver: '3.0.5.24',
        state: 5
      },
      {
        ver: '3.0.5.23',
        state: 5
      },
      {
        ver: '3.0.5.22',
        state: 5
      }
    ]
  }
  const wrapper = mount(Dependency, {
    propsData: {
      pkg,
      appType: 0
    },
    localVue
  })

  return wrapper
}

业务组件运行结果

image.png

image.png

测试接口请求的案例

List.vue

<template>
  <div class="list-wrapper">
    <div class="list-item" v-for="item in listData">
      <span class="list-name">{{ item.name }}</span>
      <span class="list-img">{{ item.img }}</span>
      <span class="list-price">{{ item.price }}</span>
    </div>
  </div>
</template>

<script>
import axios from 'axios'
export default {
  name: 'list',
  data() {
    return {
      listData: []
    }
  },
  created() {
    this.getList()
  },
  methods: {
    async getList() {
      const response = await axios.get('mock/service')
      this.listData = response.data
    }
  }
}
</script>

<style></style>

List.spec.js

import { mount } from '@vue/test-utils'
import List from '@/components/List'
import flushPromises from 'flush-promises'

// 模拟接口返回数据
const mockData = {
  'mock/service': [{ name: 'songm', price: '10000', img: 'http://aa.png' }]
}
jest.mock('axios', () => ({
  get: jest.fn(url => Promise.resolve({ data: mockData[url] }))
}))

describe('List', () => {
  it('模拟请求:', async () => {
    const wrapper = mount(List)
    await flushPromises()
    expect(wrapper.vm.listData.length).toBe(1)
    expect(wrapper.findAll('.list-item').length).toBe(1)
    expect(
      wrapper
        .findAll('.list-name')
        .at(0)
        .text()
    ).toBe('songm')
  })

  afterEach(() => {
    jest.clearAllMocks()
  })
})

这里使用了 flush-promises 来等待Promise的状态刷新,使用jest mock接口,根据接口参数进行数据返回模拟数据

注意:axios接口从外部文件引入同样适用,上面的测试用例无需任何更改

api/test.js

import axios from 'axios'
export const getList = () => axios.get('mock/service')

List.vue

<template>
  <div class="list-wrapper">
    <div class="list-item" v-for="item in listData">
      <span class="list-name">{{ item.name }}</span>
      <span class="list-img">{{ item.img }}</span>
      <span class="list-price">{{ item.price }}</span>
    </div>
  </div>
</template>

<script>
import { getList } from '@/api/test'
export default {
  name: 'list',
  data() {
    return {
      listData: []
    }
  },
  created() {
    this.getList()
  },
  methods: {
    async getList() {
      const response = await getList()
      this.listData = response.data
    }
  }
}
</script>

<style></style>
测试Vuex的案例

TestVuex.vue

<template>
  <button @click="handleIncrement">{{ count }}</button>
</template>

<script>
import { mapActions, mapState } from 'vuex'
export default {
  name: 'test-vuex',
  computed: {
    ...mapState(['count'])
  },
  methods: {
    ...mapActions(['changeCount']),
    handleIncrement() {
      const count = this.count + 1
      this.changeCount(count)
    }
  }
}
</script>

<style></style>

TestVue.spec.js

import { mount, createLocalVue } from '@vue/test-utils'
import TestAction from '@/components/TestAction'
import Vuex from 'vuex'
import store from '@/store'

const localVue = createLocalVue()

localVue.use(Vuex)

describe('TestAction', () => {
  it('点击button,触发changeCount调用', async () => {
    const wrapper = mount(TestAction, { store, localVue })

    await wrapper.find('button').trigger('click')

    expect(wrapper.vm.count).toBe(1)
  })
})

测试vuex的时候,我们并不关心action做了什么,或者store是什么,我们应该关心action在什么时候触发,以及预期的值是什么,我们可以直接测试我们的store模块,也可以模拟store模块,当然,如果我们并不关心界面情况,可以不通过 Vue Test Utils 和 Vuex 测试它们,可以把它们当成js模块来测试

测试Vue-router的案例
import { shallowMount, createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'
import router from '@/router'
import App from '@/App.vue'

const localVue = createLocalVue()

localVue.use(VueRouter)

describe('App', () => {
  it('测试router', async () => {
    const wrapper = shallowMount(App, {
      localVue,
      router
    })
    expect(wrapper.vm.$route.path).toBe('/')
    await router.push('/about')
    expect(wrapper.vm.$route.path).toBe('/about')
  })
})

在router或者vuex的测试中,最好使用localVue进行安装,避免不必要的麻烦,当我们安装vuie-router后,router-link 和 router-view 组件就被注册了。这意味着我们无需再导入可以在应用的任意地方使用它们。

总结

综合来看,单元测试的好处很多,我们在前面也讲过了,可劲酒虽好,也不要贪杯哦。我们在日常工作中,还是要以功能为主,单测为辅,不能颠倒了主次,导致本末倒置,最后花费大量的时间去维护两套逻辑,所以尽量覆盖项目中的核心组件与核心场景。 另外阻碍我们写单元测试的通常不是能力问题,而是时间问题,现今互联网节奏巨快,通常没有足够的时间去写好单测,那么如何能交付一份满意的单测答卷呢,技巧很重要,把握尺度,熟练技巧,摸清套路,我想你一定可以得心应手。

参考

Vue Test Utils 官方文档

《Testing Vue.js Applications》 ---- Edd Yerburgh

jest官方文档