Vue Jest 单元测试实战指南

771 阅读3分钟

背景:
     因为近期在做Jest相关的单元测试的编写,手上负责的项目又是老项目的技术架构,在此过程中遇到了很多的问题特此在这里记录一下,一是为了留个记录方便之后查询,二是让大家也帮忙校正解决的方法是否正确,好了话不多说直接开始!

相关技术栈

 "nuxt": "^2.15.7",
 "babel-jest": "27.4.4",
 "jest": "27.4.4",
 "jest-transform-stub": "^2.0.0",
 "vue-jest": "3.0.4",
 "@vue/test-utils": "1.3.0",
 "babel-core": "7.0.0-bridge.0",

Tips: 需要注意的是本文使用的是@vue/test-utils(v1)版本哦~

准备工作:

配置jest.config.js文件

module.exports = {
  coverageDirectory: '.coverage',
  testMatch: ['**/__tests__/specs/*.js'],
  testEnvironment: 'jsdom',
  collectCoverageFrom: ['**/demo/*.vue'],
  moduleFileExtensions: ['vue', 'js'],
  transform: {
    '^.+\\.js$': 'babel-jest',
    '.*\\.(vue)$': 'vue-jest',
    '.+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$':
      'jest-transform-stub',
  },
  coveragePathIgnorePatterns: ['/node_modules/', 'package.json', 'yarn.lock'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '@pages/(.*)$': '<rootDir>/src/pages/$1',
  },
  coverageThreshold: {
    global: {
      branches: 100,
    },
  },
}


以上的配置,文档说的很清楚这里就不多做解释了。
Tips:**:匹配0到多个子目录,递归匹配子目录

配置.babelrc文件

{
  "env": {
    "test": {
      "presets": [
        [
          "@babel/preset-env",
          {
            "targets": {
              "node": "current"
            }
          }
        ]
      ]
    }
  }
}

测试文件:src/pages/demo/index.vue

<template>
  <div>hello jest</div>
</template>

<script>
  export default {
    name: 'demo',
    components: {},

    data() {
      return {}
    },
    computed: {},
    created() {},
    mounted() {},

    beforeDestroy() {},

    methods: {},
  }
</script>


单元测试文件:src/__tests__/specs/demo.specs.js

import Demo from '@pages/demo'
import { shallowMount } from '@vue/test-utils'

describe('demo.vue', () => {
  it('测试开始', async () => {
    const wrapper = shallowMount(Demo)
    window.console.log(wrapper)
  })
})

新增命令:package.json

"scripts": {
    ...
    "test-watch": "jest --watchAll"
  },

准备工作完成
执行

yarn test-watch or npm run test-watch

【例子1】组件挂载vuex

index.vue

<template>
  <div :class="displayModeClassName">
    <h2 v-if="title">{{ title }}</h2>
    <div class="box" v-if="isMobile">这是H5</div>
    <div class="box" v-else>这是PC</div>
    <button @click="handleClick">测试按钮</button>
  </div>
</template>

<script>
  import { mapMutations, mapState, mapGetters } from 'vuex'

  export default {
    name: 'demo',
    components: {},

    data() {
      return {}
    },
    computed: {
      ...mapState(['isMobile', 'title']),
      ...mapGetters(['displayModeClassName']),
    },
    created() {},
    mounted() {},

    beforeDestroy() {},

    methods: {
      ...mapMutations(['setTitle']),
      handleClick() {
        this.setTitle('hello world')
      },
    },
  }
</script>



demo.specs.js

import Vuex from 'vuex'
import Demo from '@pages/demo'
import { shallowMount, createLocalVue } from '@vue/test-utils'

let store, config
const localVue = createLocalVue()

localVue.use(Vuex)

beforeEach(() => {
  store = new Vuex.Store({
    state: {
      isMobile: true,
      title: '',
    },
    mutations: {
      setTitle(state, name) {
        state.title = name
      },
      setMobile(state, name) {
        state.isMobile = name
      },
    },
    getters: {
      displayModeClassName: (state) => {
        if (state.isMobile) {
          return 'mobile'
        }
        return 'desktop'
      },
    },
  })

  config = {
    store,
    localVue,
  }
})

describe('测试vuex', () => {
  it('测试标题', async () => {
    const wrapper = shallowMount(Demo, config)
    store.commit('setTitle', 'hello jest')

    await wrapper.vm.$nextTick()
    const aTitle = wrapper.find('h2')
    expect(aTitle.exists()).toBe(true)
    expect(aTitle.text()).toBe('hello jest')
  })

  it('测试分辨率', async () => {
    const wrapper = shallowMount(Demo, config)
    store.commit('setMobile', false)

    await wrapper.vm.$nextTick()
    const aBox = wrapper.find('.box')
    expect(aBox.exists()).toBe(true)
    expect(aBox.text().indexOf('PC') !== -1).toBe(true)
  })
  it('测试事件', async () => {
    const wrapper = shallowMount(Demo, config)
    
    await wrapper.vm.$nextTick()
    const aBox = wrapper.find('button')
    await aBox.trigger('click')

    const aTitle = wrapper.find('h2')
    expect(aTitle.exists()).toBe(true)
    expect(aTitle.text()).toBe('hello world')
  })
})

如果vuex中有modules的话原理相同,参考以下代码

demo.specs.js

...
const modules = {
  detail: {
    namespaced: true,
    state: {
      ...
    },
    mutations: {
     ...
    },
  },
}
beforeEach(() => {
  store = new Vuex.Store({
   modules,
    state: {
      isMobile: true,
      title: '',
    },
    ...
  })
})
...

具体的我就不做演示了哈

【例子2】挂载全局变量

通常我们在开发组件的时候避免不了引入多语言类似于@nuxtjs/i18n这样插件它会默认挂载到vue.prototype中,然后在组件中使用this.$t(xxx)的形式,其他就是我们也会自定义的一些原型方法或者变量等,挂载到vue.prototype中,下面演示如何实现挂载。
详情可以查看mocks属性

yarn add @nuxtjs/i18n -S or npm install @nuxtjs/i18n -S

注入自定义的全局变量 src/plugins/globalVar.js

import Vue from 'vue'
Vue.prototype.$__CONFIG__ = {
  info: '我是标题',
}
Vue.prototype.$__CONFIG__FUNC = () => [1, 2, 3]

nuxt.config.js

plugins: [
    { src: '~/plugins/game-config.js', mode: 'client' },
  ],

index.vue

<template>
  <div>
    <h2>{{ $t('common.title') }}</h2>
    <p>{{ $__CONFIG__.info }}</p>
    <div class="item" v-for="(item, index) in rnderArrs" :key="index">
      {{ item }}
    </div>
  </div>
</template>

<script>
  export default {
    name: 'demo',
    data() {
      return {}
    },
    computed: {
      rnderArrs() {
        return this.$__CONFIG__FUNC()
      },
    }
  }
</script>

demo.specs.js

import Demo from '@pages/demo'
import { shallowMount, createLocalVue } from '@vue/test-utils'
let store, config, mocks
const localVue = createLocalVue()

// warn: 仅供模拟、以实际项目为主
const i18n = {
  zh_CN: {
    common: {
      title: '测试',
    },
  },
}

beforeEach(() => {
  mocks = {
    $t: (key) => {
      const keys = key.split('.')
      return i18n['zh_CN'][keys[0]][keys[1]]
    },
    $__CONFIG__: {
      info: 'jest',
    },
    $__CONFIG__FUNC: () => [4, 5, 6],
  }
  config = {
    store,
    localVue,
    mocks,
  }
})

describe('测试vue原型链', () => {
  it('测试 i18n', async () => {
    const wrapper = shallowMount(Demo, config)

    await wrapper.vm.$nextTick()
    const oTitle = wrapper.find('h2')

    expect(oTitle.text()).toBe('测试')
  })

  it('测试 自定义变量', async () => {
    const wrapper = shallowMount(Demo, config)

    await wrapper.vm.$nextTick()
    const oInfo = wrapper.find('p')

    expect(oInfo.text()).toBe('jest')
  })

  it('测试 自定义变量方法', async () => {
    const wrapper = shallowMount(Demo, config)

    await wrapper.vm.$nextTick()
    const oInfo = wrapper.findAll('.item')

    expect(oInfo.length).toBe(3)
  })
})

【例子3】挂载三方UI组件库

vant2.x 为例,导入的方式无外乎两种:全部导入按需导入,在单元测试中跟vue使用是一样的,需要那种方式就use那种即可

index.vue

<template>
  <div>
    <van-field v-model="value" label="文本" placeholder="请输入用户名" />
  </div>
</template>

<script>
  import { Field } from 'vant'
  import Vue from 'vue'
  Vue.use(Field)
  ...
</script>

demo.specs.js


import { shallowMount, createLocalVue } from '@vue/test-utils'
...
// 全部导入
// import vant from 'vant' 
// const localVue = createLocalVue()
// localVue.use(vant)
import { Field } from 'vant' // 按需导入
let store, config
const localVue = createLocalVue()
localVue.use(Field)

...

【例子4】挂载自定义指令

通常在开发中关于指令要么是自己写,或者引入三方就拿vue-lazyload为例演示

index.vue

<template>
  <div>
    <img alt="" v-lazy="require(`@/assets/images/demo.png`)" />
  </div>
</template>
...
<script>
 

demo.specs.js

import Demo from '@pages/demo'
import { shallowMount, createLocalVue } from '@vue/test-utils'
let store, config
const localVue = createLocalVue()
...
const lazy = jest.fn()
localVue.directive('lazy', lazy)
...

【例子5】jest.mock模板文件的方法

很多的时候我们在组件中调用的请求方法或者utils方法都统一个文件中,但是实际得到方法的返回值可能依赖其他模板各个方法的嵌套等,这个时候我们可以用jest.mock整个模板对对应方法进行返回,如下:

utils.js

export const foo = () => {
  return [1, 2, 3]
}

export const sortfoo = () => {
  return foo().sort()
}

export default {
  foo,
  sortfoo,
}

index.vue

<template>
  <div>
    <ul>
      <li v-for="item in arrss" :key="item">{{ item }}</li>
    </ul>
  </div>
</template>

<script>
  import { foo } from './utils' // 1、2、3
  export default {
    name: 'demo',
    components: {},
    data() {
      return {}
    },
    computed: {
      arrss() {
        return foo()
      },
    },
  ...
  }
</script>

demo.specs.js

import Demo from '@pages/demo'
import { shallowMount, createLocalVue } from '@vue/test-utils'
import { jest } from '@jest/globals'
let store, config
const localVue = createLocalVue()

// 对于整个模板进行mock、然后对模板的对应方法特殊处理即可
jest.mock('@pages/demo/utils', () => ({
  foo: () => {
    return [4, 5, 6]
  },
}))

beforeEach(() => {
  config = {
    store,
    localVue,
  }
})

describe('11', () => {
  it('测试 222', async () => {
    const wrapper = shallowMount(Demo, config)
    console.log(wrapper.text()) // 4、5、6
  })
})

以上的方法会改变到vue真实组件的数据,
尝试过使用jest.spyOnjest.fn但是不能满足我的需求,且spyOn和fn的使用场景翻阅了很多的资料发现网上的资料都是一样的,没有卵用有知道的小伙伴下面留言考诉我,感谢~

【例子6】单元覆盖率 测试commitlint拦截

提交commit的时候依赖于huskylint-staged

yarn add lint-staged husky -D

配置package.json

"scripts": {
    ...
    "test-watch": "jest --watchAll",
    "test": "jest",
    "coverage": "npm run test -- --coverage --watchAll=false || exit 0"
  },
  ...
  "devDependencies":{
   "husky": "^8.0.1",
    "lint-staged": "^13.0.3",
  },
  "lint-staged": {
    "*.{js,vue}": [
      "npm run coverage"
    ]
  }

增加hook钩子

npx husky add .husky/pre-commit "npx lint-staged"

然后我们随便写一些报错的单元测试,就会出现以下截图:

image.png

最后

关于以上的关于jest的问题也会在开发项目中不定期更新,如果能帮助到大家,省去了翻阅资料的时间那就非常有价值了感谢~

参考资料