前端单测
主要内容
- 单元测试的相关内容
- 代码要求
- 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