前端单测
主要内容
- 单元测试的相关内容
- 代码要求
- jest + vue test utils
- 例子讲解
现状
很多人都认为前端都是ui dom,没啥好单测的,事实上确实也没很少有团队严格执行要求前端单测覆盖率的,多数是针对一些开源的库、基础库这种复用率极高的代码,不过很多团队已经开始在补充这块的建设
其实单元测试都是很有必要的,前端也不例外
为什么需要单元测试
- 增加程序的健壮性
- 保证重构正确性
- 代替注释阅读测试用例了解代码的功能
- 测试驱动开发(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后缀的文件
js文件
创建对应的**.spec.js, 编码单测用例,例子:
import Utils from '@/assets/js/util'
describe('js utils', () => {
test('isEmpty', () => {
expect(Utils.validNum(222)).toBeTruthy()
})
})
vue组件
步骤:
- 加载组件
- 初始化实例
- 模拟事件
- 设置data、props
- 判断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')
})
})
遇到的问题
- collectCoverageFrom设置!node_modules不生效,使用coveragePathIgnorePatterns替换
- Coverage生成的页面结果有问题,待排查
- jest最新版本24* 默认依赖babel7的版本, vue的文档中并没有说明
- 例子中的项目用的是babel6,为了减少配置时间,将jest,babel-jest 版本设置为23
目前存在的问题
vue-test-utils 版本问题
虽然是官方指定的test方案,但是目前仍然停留在1.0beta,令人慌慌的
相关链接
https://vue-test-utils.vuejs.org/
https://github.com/puppeteer/puppeteer