前端单元测试分享

前端单测

主要内容

  • 单元测试的相关内容
  • 代码要求
  • jest + vue test utils
  • 例子讲解

现状

很多人都认为前端都是ui dom,没啥好单测的,事实上确实也没很少有团队严格执行要求前端单测覆盖率的,多数是针对一些开源的库、基础库这种复用率极高的代码,不过很多团队已经开始在补充这块的建设

其实单元测试都是很有必要的,前端也不例外

image.png

为什么需要单元测试

  • 增加程序的健壮性
  • 保证重构正确性
  • 代替注释阅读测试用例了解代码的功能
  • 测试驱动开发(TDD,Test-Driven Development)

单元测试基本概念

断言(assert)

断言是编写测试用例的核心实现方式,根据期望值判断本条case是否通过

测试用例

设置一组输入条件、预期结果,判断该代码是否符合要求

覆盖率
  • 语句覆盖率(statement coverage)
  • 分支覆盖率(branch coverage)
  • 函数覆盖率(function coverage)
  • 行覆盖率(line coverage)

代码要求

覆盖范围

js工具类 + 基础组件 + vuex + 部分简单业务

代码要求

业务组件模块化,拆分为:业务组件(多层) + 基础组件

完善mock数据,增加npm run mock

使用第三方库,指定格式造数据

本地创建文件存储

本地启动服务,将数据存储在服务中

数据存储在某个稳定的服务器中

前端单元测试框架

技术选择

vue/test-utils + jest

为什么选择Jest

jest有facebook背书,并且集成完善,基本只依赖一个库就行,vue/test-utils友好支持jest

mocha需要其他比如chai、jsdom等配合使用

为什么选择vue/test-utils

官方推荐,持续有人更新维护,良好的支持了组件的setProps、setData、事件模拟、vuex、vue-router等,github上很多开源项目在使用:vuetify等

jest

基础语法

except:(期望)

toBe:值相等

toEqual:递归检查对象或数组

toBeUndefined

toBeNull

toBeTruthy

toMatch:正则

使用 expect(n).toBe(x)

异步处理

callbck done/promise/async await
// done callback
test('the data is peanut butter', done => {
  function callback(data) {
    try {
      expect(data).toBe('peanut butter');
      done();
    } catch (error) {
      done(error);
    }
  }
  fetchData(callback);
});
// promise
test('the data is peanut butter', () => {
  return fetchData().then(data => {
    expect(data).toBe('peanut butter');
  });
});
// async await
test('the data is peanut butter', async () => {
  const data = await fetchData();
  expect(data).toBe('peanut butter');
});

前置执行

// 重复执行, 每次调用都执行
beforeEach(() => {
  initializeCityDatabase();
});
afterEach(() => {
  clearCityDatabase();
});
// 单次执行
beforeAll(() => {
  return initializeCityDatabase();
});

afterAll(() => {
  return clearCityDatabase();
});

作用域

describe('matching cities to foods', () => {
  // Applies only to tests in this describe block
  beforeEach(() => {
    return initializeFoodDatabase();
  });

  test('Vienna <3 sausage', () => {
    expect(isValidCityFoodPair('Vienna', 'Wiener Schnitzel')).toBe(true);
  });

  test('San Juan <3 plantains', () => {
    expect(isValidCityFoodPair('San Juan', 'Mofongo')).toBe(true);
  });
});
// 查看执行顺序
beforeAll(() => console.log('1 - beforeAll'));
afterAll(() => console.log('1 - afterAll'));
beforeEach(() => console.log('1 - beforeEach'));
afterEach(() => console.log('1 - afterEach'));
test('', () => console.log('1 - test'));
describe('Scoped / Nested block', () => {
  beforeAll(() => console.log('2 - beforeAll'));
  afterAll(() => console.log('2 - afterAll'));
  beforeEach(() => console.log('2 - beforeEach'));
  afterEach(() => console.log('2 - afterEach'));
  test('', () => console.log('2 - test'));
});

mock

// mock ajax
jest.mock('axios', () => ({
  get: jest.fn()
}));
beforeEach(() => {
  axios.get.mockClear()
  axios.get.mockReturnValue(Promise.resolve({}))
});

jest.mock('axios', () => {
	post: jest.fn.xxx(() => Promise.resolve({
		status: 200
	}))
})
// mock fn
jest.fn(() => {
	// function body
})

Vue Test Utils API

挂载组件

  • mout(component)

创建一个包含被挂载和渲染的 Vue 组件的 Wrapper

  • createLocalVue

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

比如要测试vuex、router时使用

  • shallowMount

只挂载一个组件不渲染子组件

参数:

   propsData、slot等

属性

vm(Component)、element(HTMLElement)、Options

方法

设置属性

setProps、setData、setMethods

返回的组件属性

attribus、classes

查找

find、findAll、contains、exits

判断

is、isEmpty、isVisible、isVueInstance

事件触发

trigger、emit、emitOrder

Debugger

运行在node环境下,debugger需要特殊配置

# macOS or linux
node --inspect-brk ./node_modules/.bin/vue-cli-service test:unit

# Windows
node --inspect-brk ./node_modules/@vue/cli-service/bin/vue-cli-service.js test:unit

快速开始

前置要求

编辑器

node环境

vue工程

安装依赖:

  babel-jest(23.1), jest(23.1), vue-jest, @vue/test-utils, identity-obj-proxy

package.json配置,也可以单独提取到jest.config.js

scripts增加:"test": "jest"

coverage相关:收集覆盖率信息

moduleFileExtensions:通知jest处理的后缀文件

transform:vue-jest处理vue文件, babel-jest处理js文件

moduleNameMapper:

处理webpack中的resolve alias,

处理css、image等静态资源,需要依赖identity-obj-proxy, 创建fileMock.js

// fileMock.js
module.exports = 'test-file-stub';
"jest": {
    "collectCoverage": true,
    "coveragePathIgnorePatterns": [
      "/node_modules/",
      "package.json",
      "package-lock.json"
    ],
    // "collectCoverageFrom": [
    //   "**/*.{js,jsx}",
    //   "!**/node_modules/**",
    //  "!**/vendor/**"
    // ],
    "coverageReporters": [
      "html",
      "text-summary"
    ],
    "moduleFileExtensions": [
      "js",
      "vue"
    ],
    "transform": {
      ".*\\.(vue)$": "<rootDir>/node_modules/vue-jest",
      "^.+\\.js$": "<rootDir>/node_modules/babel-jest"
    },
    "moduleNameMapper": {
      "^@/(.*)$": "<rootDir>/src/$1",
      "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/tests/mocks/fileMock.js",
      "\\.(css|scss|sass)$": "identity-obj-proxy"
    }
  }

运行

npm install

npm run test

单元测试用例代码

根目录新建tests目录, 创建**.spec.js, spec是 Unit Testing Specification的缩写,jest会执行所有.spec.js后缀的文件

image.png

js文件

创建对应的**.spec.js, 编码单测用例,例子:

import Utils from '@/assets/js/util'
describe('js utils', () => {
  test('isEmpty', () => {
    expect(Utils.validNum(222)).toBeTruthy()
  })
})
vue组件
步骤:
  1. 加载组件
  2. 初始化实例
  3. 模拟事件
  4. 设置data、props
  5. 判断props、data是否正确, dom是否值是否符合预期
import { shallowMount } from '@vue/test-utils'
import Demo from './demo'

describe('demo vue component', () => {
  test('set props', () => {
    const vm = shallowMount(Demo, {
      // propsData: {
      //   data: 'test_data'
      // }
    })
    // set props
    vm.setProps({
      data: 'test_data'
    })
    // expect(wrapper.isVueInstance()).toBeTruthy()
    expect(vm.props().data).toBe('test_data')
  })
  test('set data', () => {
    const wrapper = shallowMount(Demo)
    wrapper.setData({
      count: 2
    })
    expect(wrapper.vm.count).toBe(2)
  })
  test('trigger increase click', () => {
    const wrapper = shallowMount(Demo)
    const increase = wrapper.find('.increase')
    increase.trigger('click')
    expect(wrapper.vm.count).toBe(1)
  })
  test('trigger reduce click', () => {
    const wrapper = shallowMount(Demo)
    expect(wrapper.vm.count).toBe(0)
    const increase = wrapper.find('.reduce')
    increase.trigger('click')
    expect(wrapper.vm.count).toBe(-1)
  })
})
vuex
主要步骤:
  • 加载组件
  • 初始化实例
  • 引用插件vuex
  • 伪造store
  • 编写测试逻辑

vuex主要元素包括actions、mutation、getter等,可以一个个单独测试,也可以跟随业务组件将 组件内事件调用,执行action,触发mutation,改变state到组件compute变化、dom变化一块处理。

mutation测试

mutation就是一个单独的函数执行,这里举一个例子,因为我们项目中的mutation都是后端数据,检查state变化

import decorator from '@/store/decorator'
import userInfo4Web from './userInfoMock'

describe('vuex decorator', () => {
    test('mutation getUserInfo', () => {
        const state = {
            userName: ''
        }
        decorator.mutations['USER_INFO'](state, userInfoMock)
        expect(state.userName).toBe('test')
    })
})

/*
	store/decorator.js  列举部分代码
*/
import * as mutation from './mutations'
const state = {
  userName: '',
}
const mutations = {
  [mutation.USER_INFO_REQUEST] (state, payload) {
    state.userName = ''
    let dataResult = payload.body
    if (!Util.isEmpty(dataResult)) {
      state.userName = dataResult.userName
    }
  }
}
const actions = {}
export default {
  state,
  mutations,
  actions
}
action测试

用于action只是用来处理状态提交或者处理异步请求,单独测试action只需要测试接口是否调用,是否返回promise等异步,这里不做单独的例子。

getter测试

项目中只是使用全量的store.state, 没有getter的代码,这里先不单独处理getter的例子,后面有需要在处理。

完整的vuex测试
  • 引用vuex插件
  • 构造action

因为项目中的action调用的第三方utils调用axios发起请求,成功返回后再执行mutation,这块模拟太复杂,暂时手动构造模拟action,直接在action执行mutation。 通过jest.fn构造一个action方法,再布局替换自己项目工程的action

  • 调用action
  • 查看state变化
import { createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
import { cloneDeep } from 'lodash'
import decorator from '@/store/decorator'
import userInfoMock from './userInfo'

describe('vuex decorator', () => {
    let store
    beforeEach(() => {
        const localVue = createLocalVue()
        localVue.use(Vuex)
        const actions = {
            getUserInfo: jest.fn(({ commit }) => {
                commit('USER_INFO', userInfoMock)
            })
        }
        const decoratorClone = cloneDeep(decorator)
        // 覆盖actions
        decoratorClone.actions = actions
        store = new Vuex.Store(decoratorClone)
    })
    test('all vuex test', () => {
        expect(store.state.userName).toBe('')
        store.dispatch('getUserInfo')
        expect(store.state.userName).toBe('test')
    })
})
vue复杂组件

尝试对代码中的userInfo.vue 写一些简单的单元测试,但是组件其实是包含了一些复杂逻辑在里面的,混合了vuex,初始化获取请求等,这里首先要关注下面几个问题:

  • 使用mapState,自定义state的时候,需要在包裹一层

解决mapState问题,decoratorClone.state.decorator = decoratorClone.state,但是值变化可能有问题

  • 组件内依赖element-ui等第三方组件需要处理

处理element-ui等插件,全量应用组件

  • 组件异步请求

初始化请求问题,mock ajax.get/post请求。

import { shallowMount, createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
import axios from 'axios'
import Vue from 'vue'
import { cloneDeep } from 'lodash'
import { Pagination } from 'element-ui'
import decorator from '@/store/decorator'
import userInfoMock from '@/mocks/userInfo'
import UserInfo from '@/userInfo.vue'

jest.mock('axios', () => ({
    get: jest.fn(),
}));
describe('header component test', () => {
    let store
    let localVue
    let wrapper
    beforeAll(async() => {
        localVue = createLocalVue()
        // 引入插件
        localVue.use(Vuex)
        localVue.use(Pagination)
      	// 省去其他element代码

        const decoratorClone = cloneDeep(decorator)
        // 覆盖actions
        const actions = {
            getUserInfo: jest.fn(({ commit }) => {
                commit('USER_REQUEST', userInfoMock)
            })
        }
        Object.assign(decoratorClone.actions, actions)
        decoratorClone.state.decorator = decoratorClone.state
        store = new Vuex.Store(decoratorClone)
        Vue.axios.get.mockClear()
        Vue.axios.get.mockReturnValue(Promise.resolve({}))

        // wrapper前置,减少重复初始化
        wrapper = await shallowMount(UserInfo, { store, localVue })
    })

    test("vue data instance", async () => {
        await wrapper.vm.$nextTick()
        expect(wrapper.vm.test).toBe('baisc')
    })
    test("user name render", async () => {
      	expect(store.state.userName).toBe('test')  
      	await wrapper.vm.$nextTick()
        expect(wrapper.find('.userName').text()).toBe('test')
    })
})

遇到的问题

  1. collectCoverageFrom设置!node_modules不生效,使用coveragePathIgnorePatterns替换
  2. Coverage生成的页面结果有问题,待排查
  3. jest最新版本24* 默认依赖babel7的版本, vue的文档中并没有说明
  4. 例子中的项目用的是babel6,为了减少配置时间,将jest,babel-jest 版本设置为23


目前存在的问题

vue-test-utils 版本问题

虽然是官方指定的test方案,但是目前仍然停留在1.0beta,令人慌慌的

vuejs/vue-test-utils#1329

相关链接

https://jestjs.io/

https://vue-test-utils.vuejs.org/

https://github.com/puppeteer/puppeteer

https://github.com/vuetifyjs/vuetify

https://github.com/jsdom/jsdom

暂无评论