在vue项目中使用单元测试

目前笔者负责的项目迭代十分频繁,前端需要处理业务逻辑日益增多,除了考虑如何保证开发速度之外,还必须考虑系统的稳定性,因此决定在项目引入单元测试。

本文主要整理在Vue项目中如何使用单元测试,包括如何编写测试用例、如何编写易于测试的代码等问题。

<!--more-->

参考

1. 安装环境

由于项目是使用vue-cli搭建的,因此添加vue-test-util套件还是比较容易的,主要是添加jest及相关依赖

vue add unit-jest

然后会自动下载相关依赖,同时生成一个tests目录,此外还会自动生成一个示例测试文件

接着运行单元测试的命令(这个命令也是在上一步中自动添加到package.json中的)

npm run test:unit

没报错的话就说明环境应该搭好了。

当然,一个真实的项目不会像HelloWorl这样简单,下面整理实际项目中遇见的一些测试问题及解决方案。

2. 基础测试

2.1. setup启动文件

项目中如果使用了不需要测试的外部全局变量(如window对象),但却在node环境下运行测试,则可能抛出异常,对于这些全局变量,常见的做法是提前在node环境下准备好这些全局对象。

而这种在启动测试用例前的准备工作可以放在启动文件中进行处理,往往命名为setup.js,而默认的vue add unit-jest不会创建该文件,需要我们自己创建并配置jest

首先配置全局文件

// jest.config.js,
module.exports = {
  setupFiles: ['<rootDir>/tests/unit/setup.js'], // 指定setup的位置
  //...其他配置
 }
}

然后再tests/unit/下创建setup.js文件,最后在该文件中处理准备工作相关逻辑即可

// <rootDir>/tests/unit/setup.js

// mock全局对象appInfo
window.appInfo = {
    name: 'shymean',
    version: '1.2.3'
}

在运行测试用例前会先运行setup中的代码,这样测试期间遇见这些全局变量就不会报错了

附: jest的配置项

2.2. 处理非js模块的依赖

如果某个模块使用了非js模块(如css),则在测试该模块时会抛出异常,此时需要安装jest-transform-stub

npm install --save-dev jest-transform-stub

然后在jest配置文件中(项目根目录下的jest.config.js)通过moduleNameMapper配置项进行处理

// jest.config.js,
module.exports = {
  preset: '@vue/cli-plugin-unit-jest',
  transformIgnorePatterns: ['/node_modules/'],
  moduleNameMapper: {
    "^.+.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)quot;: "jest-transform-stub"
  }
}

stub是在测试中用来替代真正依赖对象的一种方式,除此之外还有fakemockspy等方式,在后面的具体测试场景中会提到

2.3. 当前时间的处理

在业务代码中,往往会存在类似的获取当前客户端时间并进行判断的逻辑,如

function inActivityTime() {
    const startTime = '2020-10-01 00:00:00'
    const endTime = '2020-11-30 00:00:00'
    const now = moment() // 或者是 +new Date() 之类获取当前时间的方法
    return now >= moment(startTime) && now <= moment(endTime)
}

而在测试这个方法时,应需要编写多个时间点下的测试用例。从实现上来看,这个方法本身不便于测试,更合适的处理应当是将当前时间戳通过参数的形式传进入

function inActivityTime(now) {
    const startTime = '2020-10-01 00:00:00'
    const endTime = '2020-11-30 00:00:00'
    return now >= moment(startTime) && now <= moment(endTime)
}

这样只需要通过控制参数,就能编写不同的测试用例了。

it("inActivityTime 当前时间是否在活动时间内", ()=>{
    expect(inActivityTime('2020-09-01 00:00:00')).toBe(false)
    expect(inActivityTime('2020-12-01 00:00:00')).toBe(false)
    expect(inActivityTime('2020-11-01 00:00:00')).toBe(true)
})

设计是美好的,但现实往往是残酷的。一种现实是:在我们编写测试用例的时候,业务中可能已经存在大量类似的不便于测试的代码(从另外一方面证明了TDD的好处),所幸jest提供了一些模拟当前时间的方法,参考

不过经过一番折腾之后,我发现了最简单的方式:使用mockdate这个库

it("inActivityTime 当前时间是否在活动时间内", ()=>{
    MockDate.set('2020-09-01 00:00:00')
    expect(inActivityTime()).toBe(false)

    MockDate.set('2020-12-01 00:00:00')
    expect(inActivityTime('2020-12-01 00:00:00')).toBe(false)

    MockDate.set('2020-11-01 00:00:00')
    expect(inActivityTime('2020-11-01 00:00:00')).toBe(true)
})

2.4. 测试模块API和网络请求

某些需要测试的功能会执行副作用代码,如在一个action中发送网络请求。

就测试而言我们只希望验证这个方法是否正确调用,或者仅仅希望这个方法会按照期望返回数据或者抛出异常,而不希望发送真实的请求。这种场景可以使用jest.mock来处理

假设现在api/user.js封装了所有用户信息相关的接口

// api/user.js
export const getUserInfo(){
    return request.get('/api/v1/user/info/')
}

首先使用jest.mock代理整个模块,然后通过mockResolvedValue模拟返回值即可

import {getUserInfo} from '@/api/user'

jest.mock('@/api/user')

getUserInfo.mockResolvedValue({
  msg: 'SUCCESS',
  code: 200,
  data: {
      // ... mock user info
  }
})

假如是直接在某个地方里面通过axios.get('/xxx/xxx/url').then()之类调用网络接口,这种通过拦截请求接口模拟返回值的方法就不太适用了。从这一点也可以看见,测试驱动开发貌似更容易写出容易测试和维护的代码。

getUserInfo.mockResolvedValue返回的本身是一个jest.fn()mock方法,他包含一些特定的属性和方法,比如

  • mockImplementationOnce直接重写整个方法的实现
  • mock.calls历史调用记录,可以通过mock.calls.length判断调用测试,通过mock.calls[0][0]获取第1次调用的第1个参数,诸如此类

3. 测试store

在笔者的项目中,大量的业务逻辑都会通过vuex放在store中按module进行拆分和处理,视图组件只负责展示和处理独立的逻辑。因此store是最需要优先进行测试的

参考:

3.1. 测试mutation

由于mutation只是接受statepayload的纯函数,因此测试起来十分方便,

// store/modules/user.js
export default {
    state:{
        userInfo:null
    },
    mutations:{
        setUserInfo(state, payload){
            state.userInfo = payload
        }
    }
}

如果现在要测试这个setUserInfo,则直接测试这个模块的mutations.setUserInfo方法即可

import user from '@/store/modules/user.js'

it('setUserInfo 设置用户详情', () => {
    const { setUserInfo } = user.mutations
    let userInfo = { uid:1 }
    const state = { userInfo }
    setUserTrialScheduleDetail(state, userInfo)
    expect(state.userInfo).toBe(userInfo)
})

如果不是为了测试代码覆盖率,我个人认为像上面这种简单的mutation不需要测试,如果mutation还包含一些额外的计算,则可以按照函数单元测试的方法编写测试用例,验证参数和边界条件等。

3.2. 测试getters

getters本质上也是一个返回计算数据的纯函数,无非参数变成了state、getters、rootState、rootGetters

// store/modules/user.js
export default {
    state:{
        userInfo:null
    },
    getters:{
        userAvatar(state){
            if(!state.userInfo) return ''
            return state.userInfo.avatar.replace(/https?:/, '')
        }
    }
}

对于简单的getter,也可以使用跟上面测试mutation的方法一样测试

import user from '@/store/modules/user.js'

it('userAvatar 获取用户头像', () => {
    const { userAvatar } = user.getters

    let userInfo = { avatar: 'http://shymean.com/avatar.png' }
    expect(userAvatar({ userInfo })).toBe('//shymean.com/avatar.png')

    expect(userAvatar({ userInfo:null })).toBe('')
})

如果需要getter或者rootState、rootGetters等参数,也可以通过构造对象的形式传入。

在后面我们会介绍测试完整的store,而不是像现在这样测试单个独立的方法

3.3. 测试action

整个store模块中最麻烦的就是是测试action,因此action会处理各种异步逻辑,同时调用commit、dispatch等方法,包含比较复杂的业务逻辑

// store/modules/user.js
export default {
    state:{
        userInfo:null
    },
    mutations:{
        setUserInfo(state, payload){
            state.userInfo = payload
        }
    },
    actions:{
        async fetchUserInfo({commit}){
            const {data} = await getUserInfo()
            commit('setUserInfo', data)
        }
    }
}

对于上面这个fetchUserInfo而言,我们需要测试的是

  • 内部调用了getUserInfo这个接口,并异步获取到返回值
  • 内部调用了commit('setUserInfo'),更新state

从测试的角度来说,我们不需要发送真实的getUserInfo请求,而是期望检测getUserInfo被调用,并将返回值的data属性作为参数传递给commit('setUserInfo'),因此可以这样写测试用例

// 使用async 返回一个Promise
it("fetchUserInfo 获取用户基本信息", async () => {
    const { fetchUserInfo } = user.actions;

    const userInfo = { uid: 1 };
    getUserInfo.mockResolvedValueOnce({
        msg: "SUCCESS",
        code: 200,
        data: userInfo,
    });

    const mockCommit = jest.fn(() => {});

    await fetchUserInfo({ commit: mockCommit });

    expect(mockCommit.mock.calls.length).toBe(1)
    expect(mockCommit.mock.calls[0][0]).toBe('setUserInfo')
    expect(mockCommit.mock.calls[0][1]).toBe(userInfo)
});

3.4. 测试完整的store

上面简单介绍了分别单元化mutations、getters和actions的测试,这种做法的好处是测试用例非常详细,但由于state、commit等参数都是模拟的,可能无法保证整个系统的可靠性测试。接下来介绍通过store.commitstore.dispatch等来测试一个真正运行的store

为了保证每个测试用例在单个纯净的store,需要使用createLocalVue创建新的store

import { createLocalVue } from '@vue/test-utils'
import storeConfig from '@/store/config'

let store
beforeAll(() => {
  const localVue = createLocalVue()
  localVue.use(Vuex)
  store = new Vuex.Store(cloneDeep(storeConfig))
})

这样可以保证单个测试用例的

以上面的getters.userAvatar为例,正常逻辑的话需要先设置state.userInfo,然后测试getters.userAvatar的值

it('userAvatar 获取用户头像', () => {
    store.commit('user/setUserInfo', { avatar: 'http://shymean.com/avatar.png' })
    expect(store.getters['user/userAvatar']).toBe('//shymean.com/avatar.png')

    store.commit('user/setUserInfo', null)
    expect(store.getters['user/userAvatar']).toBe('')
})

这样,就可以通过常规的store数据流来控制并测试。在实际编写测试用例期间,也遇见一些不容易测试的store代码,最常见的就是某些方法里面依赖了全局变量。

4. 测试Vue组件

测试组件最基本的思路就是:把测试目标组件之外的其他信息都给屏蔽掉,只测试当前组件的相关逻辑

4.1. mock外部变量

由于Vue组件本身也是一个JS模块,因此可以使用颗粒化的数据来测试对于组件自身的computedmethods等属性,

  • 测试computed是否返回了预期的计算属性
  • 测试methods是否执行了预期的逻辑,比如调用函数、dispacth对应action等

然而现实总是残酷的,组件除了自己的data、computed、methods和生命周期函数之外,往往还依赖一些外部变量,如

  • 插件注册到vue实例上的属性如vuex的$store、vue-router的$router和$route,ElementUI的$message等原型方法
  • props、provide通过父组件或组件组件注入的数据
  • 其他游离在组件作用域之外的自由变量

就测试而言,对于这些外部变量,最常规的做法就是将其mock掉,只测试组件自身的逻辑,参考:mount和shallowMount配置参数

const wrapper = shallowMount(TestComponent, {
    mocks: {
        $route: {query:{}}
    }
})

需要注意的是,某些插件如vue-router等在Vue.use(VueRouter)之后,会添加只读的$route属性到vue实例上,这种情况下如果通过mountshallowMount传入mocks配置项模拟$route则会抛出异常

[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

一种解决办法是在Vue.use(VueRouter)处添加环境变量判断,当运行测试用例时不执行该逻辑;另外一种解决办法时将该逻辑移动到测试用例不会加载的依赖模块之外进行注册,比如放在main.js入口文件等地方

其解决思想就是避免在这些插件添加只读属性导致mock失败,然后再通过传入mocks配置项模拟$route等外部数据

除了外部变量之外,在比较长的方法中可能还存在诸如内联函数等无法在测试代码中访问到的变量,对于这种变量,除了通过mockImplementationOnce模拟实现获取参数来测试之外,目前没有找到比较好的测试方法,感觉最好的办法是编写易于测试的代码

4.2. 忽略子组件

整个Vue应用由组件构成,一个组件内部往往会依赖其他组件。为了保证测试边界清晰,只希望测试当前组件,忽略所依赖的子组件,则可以通过shallowMount结合jest.mock(子组件模块)来实现

<template>
    <div class="page">
        <div class="page_tt">parent</p>
        <Children></Children>
    </div>

</template>

可以将整个children组件模块给mock调

jest.mock('./children.vue', () => ({
  render(h) {
    h(); // 模拟子组件,并返回一个空节点
  },
}));

const wrapper = shallowMount(Parent, {});

it('标题渲染正确', () => {
    expect(wrapper.find('.page_tt').text()).toEqual('parent');
});

5. 小结

本文主要整理了如何通过vue-test-utils测试vue代码,包括

  • 基础的测试功能,包括处理全局逻辑,当前时间及依赖模块API
  • 测试store,颗粒化测试单个mutation和action,以及整体测试store
  • 测试Vue组件,包括通过mock配置项处理外部依赖、忽略子组件等

这篇文章主要记录当前测试vue项目积累的一些经验,由于目前整个项目的单元测试正在完善中,在这个过程中肯定还会遇见其他问题,日后会继续补充。

补写测试用例期间最大的收获就是体会到了“测试驱动开发”对于代码设计和实现的意义,在后续的开发过程中,会更加注重如何编写易于测试与可维护的代码。