Vue-Test-Utils
是 Vue.js
官方的单元测试实用工具库,它提供了一系列的 API
来使得咱们能够很便捷的去写 Vue
应用中的单元测试。html
主流的单元测试运行器有不少,好比 Jest
、Mocha
和 Karma
等,这几个在 Vue-Test-Utils
文档里都有对应的教程,这里咱们只介绍 Vue-Test-Utils + Jest
结合的示例。vue
Jest 是一个由 Facebook 开发的测试框架。Vue 对其进行描述:是功能最全的测试运行器。它所需的配置是最少的,默认安装了 JSDOM,内置断言且命令行的用户体验很是好。不过你须要一个可以将单文件组件导入到测试中的预处理器。咱们已经建立了 vue-jest 预处理器来处理最多见的单文件组件特性,但仍不是 vue-loader 100% 的功能。node
经过脚手架 vue-cli
来新建项目的时候,若是选择了 Unit Testing
单元测试且选择的是 Jest
做为测试运行器,那么在项目建立好后,就会自动配置好单元测试须要的环境,直接能用 Vue-Test-Utils
和 Jest
的 API
来写测试用例了。webpack
可是新建项目之初没有选择单元测试功能,须要后面去添加的话,有两种方案:ios
第一种配置:git
直接在项目中添加一个 unit-jest
插件,会自动将须要的依赖安装配置好。github
vue add @vue/unit-jest
复制代码
第二种配置:web
这种配置会麻烦一点,下面是具体的操做步骤。vue-router
安装 Jest
和 Vue Test Utils
vue-cli
npm install --save-dev jest @vue/test-utils 复制代码
安装 babel-jest
、 vue-jest
和 7.0.0-bridge.0
版本的 babel-core
npm install --save-dev babel-jest vue-jest babel-core@7.0.0-bridge.0
复制代码
安装 jest-serializer-vue
npm install --save-dev jest-serializer-vue
复制代码
Jest
Jest
的配置能够在 package.json
里配置;也能够新建一个文件 jest.config.js
, 放在项目根目录便可。这里我选择的是配置在 jest.config.js
中:
module.exports = { moduleFileExtensions: [ 'js', 'vue' ], transform: { '^.+\\.vue$': '<rootDir>/node_modules/vue-jest', '^.+\\.js$': '<rootDir>/node_modules/babel-jest' }, moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' }, snapshotSerializers: [ 'jest-serializer-vue' ], testMatch: ['**/__tests__/**/*.spec.js'], transformIgnorePatterns: ['<rootDir>/node_modules/'] } 复制代码
各配置项说明:
moduleFileExtensions
告诉 Jest
须要匹配的文件后缀transform
匹配到 .vue
文件的时候用 vue-jest
处理, 匹配到 .js
文件的时候用 babel-jest
处理moduleNameMapper
处理 webpack
的别名,好比:将 @
表示 /src
目录snapshotSerializers
将保存的快照测试结果进行序列化,使得其更美观testMatch
匹配哪些文件进行测试transformIgnorePatterns
不进行匹配的目录package.json
写一个执行测试的命令脚本:
{ "script": { "test": "jest" } } 复制代码
为了保证环境的一致性,咱们从建立项目开始一步一步演示操做步骤。
vue-cli
建立一个项目当前我用到的是 3.10.0
版本的 vue-cli
。开始建立项目:
vue create first-vue-jest
复制代码
选择 Manually select features
进行手动选择功能配置:
Vue CLI v3.10.0
┌───────────────────────────┐
│ Update available: 4.0.4 │
└───────────────────────────┘
? Please pick a preset:
VUE-CLI3 (vue-router, node-sass, babel, eslint)
default (babel, eslint)
❯ Manually select features
复制代码
勾选 Babel
、Unit Testing
:
? Check the features needed for your project: ◉ Babel ◯ TypeScript ◯ Progressive Web App (PWA) Support ◯ Router ◯ Vuex ◯ CSS Pre-processors ◯ Linter / Formatter ◉ Unit Testing ◯ E2E Testing 复制代码
选择 Jest
:
? Pick a unit testing solution:
Mocha + Chai
❯ Jest
复制代码
选择 In dedicated config files
将各配置信息配置在对应的 config
文件里:
? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? (Use arrow keys) ❯ In dedicated config files In package.json 复制代码
输入n,不保存预设:
? Save this as a preset for future projects? (y/N) n 复制代码
项目建立完成后,部分文件的配置信息以下:
babel.config.js
:
module.exports = { presets: [ '@vue/cli-plugin-babel/preset' ] } 复制代码
jest.config.js
, 这个文件的配置默认是预设插件的,能够按实际需求改为上面提到的配置 Jest
里的配置同样。
module.exports = { preset: '@vue/cli-plugin-unit-jest' } 复制代码
package.json
:
{ "name": "first-vue-jest", "version": "0.1.0", "private": true, "scripts": { "serve": "vue-cli-service serve", "build": "vue-cli-service build", "test:unit": "vue-cli-service test:unit" }, "dependencies": { "core-js": "^3.1.2", "vue": "^2.6.10" }, "devDependencies": { "@vue/cli-plugin-babel": "^4.0.0", "@vue/cli-plugin-unit-jest": "^4.0.0", "@vue/cli-service": "^4.0.0", "@vue/test-utils": "1.0.0-beta.29", "vue-template-compiler": "^2.6.10" } } 复制代码
用上面的步骤建立的项目完成项目后,咱们能够在 package.json
的 scripts
项中看到有个 test:unit
,执行它:
cd first-vue-jest npm run test:unit 复制代码
而后终端里会看到输出结果,PASS
表示测试用例经过了,这个是官方提供单元测试例子。下面咱们来写点本身的东西。
看上面的原型图,有这么几点明确的需求:
-
号表示,点击后删除该项√
号表示,点击后当前项移动到已完成列表x
号表示,点击后当前项移动到未完成列表写页面以前先把建立项目的时候生成的 HelloWorld.vue
和对应的测试文件 example.spec.js
删除;同时修改 App.vue
文件,引入 ToDoList
组件:
<template> <div id="app"> <ToDoList></ToDoList> </div> </template> <script> import ToDoList from './components/ToDoList' export default { components: { ToDoList } } </script> 复制代码
在 src/compoents
下新建一个文件 ToDoList.vue
,样式较多就不贴出来了,具体能够去看本项目源码:
<template> <div class="todolist"> <header> <h5>ToDoList</h5> <input class="to-do-text" v-model="toDoText" @keyup.enter="enterText" placeholder="输入计划要作的事情"/> </header> <h4 v-show="toDoList.length > 0">待完成</h4> <ul class="wait-to-do"> <li v-for="(item, index) in toDoList" :keys="item"> <p> <i>{{index + 1}}</i> <input :value="item" @blur="setValue(index, $event)" type="text" /> </p> <p> <span class="move" @click="removeToComplete(item, index)">√</span> <span class="del" @click="deleteWait(index)">-</span> </p> </li> </ul> <h4 v-show="completedList.length > 0">已完成</h4> <ul class="has-completed"> <li v-for="(item, index) in completedList" :keys="item"> <p> <i>{{index + 1}}</i> <input :value="item" disabled="true" type="text" /> </p> <p> <span class="move" @click="removeToWait(item, index)">x</span> <span class="del" @click="deleteComplete(index)">-</span> </p> </li> </ul> </div> </template> 复制代码
<script> export default { data() { return { toDoText: '', toDoList: [], completedList: [] } }, methods: { setValue(index, e) { this.toDoList.splice(index, 1, e.target.value) }, removeToComplete(item, index) { this.completedList.splice(this.completedList.length, 0, item) this.toDoList.splice(index, 1) }, removeToWait(item, index) { this.toDoList.splice(this.toDoList.length, 0, item) this.completedList.splice(index, 1) }, enterText() { if (this.toDoText.trim().length > 0) { this.toDoList.splice(this.toDoList.length, 0, this.toDoText) this.toDoText = '' } }, deleteWait(index) { this.toDoList.splice(index, 1) }, deleteComplete(index) { this.completedList.splice(index, 1) } } }; </script> 复制代码
页面写完,原型上的需求也大概开发完成,页面大概长以下样子:
接下来就是开始编写单元测试文件了,写以前咱们先把测试文件目录修改下为 __tests__
,同时修改 jest.config.js
为以下配置,注意其中的 testMatch
已经修改成匹配 __tests__
目录下的全部 .js
文件了。
module.exports = { moduleFileExtensions: [ 'js', 'vue' ], transform: { '^.+\\.vue$': '<rootDir>/node_modules/vue-jest', '^.+\\.js$': '<rootDir>/node_modules/babel-jest' }, moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' }, snapshotSerializers: [ 'jest-serializer-vue' ], testMatch: ['**/__tests__/**/*.spec.js'], transformIgnorePatterns: ['<rootDir>/node_modules/'] } 复制代码
在 __tests__/unit/
目录下新建文件 todolist.spec.js
,咱们约定测试某个 vue
文件,那么它的单元测试文件习惯命名成 *.spec.js
或 *.test.js
。
import { shallowMount } from '@vue/test-utils' import ToDoList from '@/components/ToDoList' describe('test ToDoList', () => { it('输入框初始值为空字符串', () => { const wrapper = shallowMount(ToDoList) expect(wrapper.vm.toDoText).toBe('') }) }) 复制代码
上面这个测试文件简要说明:
shallowMount
将会建立一个包含被挂载和渲染的 Vue
组件的 Wrapper
,只存根当前组件,不包含子组件。describe(name, fn)
这边是定义一个测试套件,test ToDoList
是测试套件的名字,fn
是具体的可执行的函数it(name, fn)
是一个测试用例,输入框初始值为空字符串
是测试用例的名字,fn
是具体的可执行函数;一个测试套件里能够保护多个测试用例。expect
是 Jest
内置的断言风格,业界还存在别的断言风格好比 Should
、Assert
等。toBe
是 Jest
提供的断言方法, 更多的能够到Jest Expect
查看具体用法。it('待完成列表初始值应该为空数组', () => { const wrapper = shallowMount(ToDoList) expect(wrapper.vm.toDoList.length).toBe(0) }) it('已完成列表初始值应该为空数组', () => { const wrapper = shallowMount(ToDoList) expect(wrapper.vm.completedList).toEqual([]) }) 复制代码
待完成和已完成列表,竟然是列表,因此存放数据的字段必须是 Array
类型,空列表就是空数组。若是第二个测试用例改为:
expect(wrapper.vm.completedList).toBe([])
复制代码
将会报错,由于 toBe
方法内部是调用 Object.is(value1, value2)
来比较2个值是否相等的,和 ==
或 ===
的判断逻辑不同。显然 Object.is([], [])
会返回 false
。
it('输入框值变化的时候,toDoText应该跟着变化', () => { const wrapper = shallowMount(ToDoList) wrapper.find('.to-do-text').setValue('晚上要陪妈妈逛超市') expect(wrapper.vm.toDoText).toBe('晚上要陪妈妈逛超市') }) it('输入框没有值,敲入回车的时候,无变化', () => { const wrapper = shallowMount(ToDoList) const length = wrapper.vm.toDoList.length const input = wrapper.find('.to-do-text') input.setValue('') input.trigger('keyup.enter') expect(wrapper.vm.toDoList.length).toBe(length) }) it('输入框有值,敲入回车的时候,待完成列表将新增一条数据,同时清空输入框', () => { const wrapper = shallowMount(ToDoList) const length = wrapper.vm.toDoList.length const input = wrapper.find('.to-do-text') input.setValue('晚上去吃大餐') input.trigger('keyup.enter') expect(wrapper.vm.toDoList.length).toBe(length + 1) expect(wrapper.vm.toDoText).toBe('') }) 复制代码
setValue
能够设置一个文本控件的值并更新 v-model
绑定的数据。.to-do-text
是一个 CSS
选择器;Vue-Test-Utils
提供了 find
方法来经过查找选择器,来返回一个 Wrapper
;选择器能够是 CSS
选择器、能够是 Vue
组件也能够是一个对象,这个对象包含了组件的 name
或 ref
属性,好比能够这样用:wrapper.find({ name: 'my-button' })
wrapper.vm
是一个 Vue
实例,只有 Vue
组件的包裹器才有 vm
这个属性;经过 wrapper.vm
能够访问全部 Vue
实例的属性和方法。好比:wrapper.vm.$data
、wrapper.vm.$nextTick()
。trigger
方法能够用来触发一个 DOM
事件,这里触发的事件都是同步的,因此没必要将断言放到 $nextTick()
里去执行;同时支持传入一个对象,当捕获到事件的时候,能够获取到传入对象的属性。能够这样写:wrapper.trigger('click', {name: "bubuzou.com"})
it('待完成列表支持编辑功能,编辑后更新toDoList数组', () => { const wrapper = shallowMount(ToDoList) wrapper.setData({toDoList: ['跑步半小时']}) wrapper.find('.wait-to-do li').find('input').setValue('绕着公园跑3圈') wrapper.find('.wait-to-do li').find('input').trigger('blur') expect(wrapper.vm.toDoList[0]).toBe('绕着公园跑3圈') }) 复制代码
先用 setData
给 toDoList
设置一个初始值,使其渲染出一个列表项;而后找到这个列表项,用 setValue
给其设置值,模拟了编辑;列表项的输入框是用 :value="item"
绑定的 value
, 因此 setValue
没法触发更新;只能经过 trigger
来触发更新 toDoList
的值。
it('待完成列表点击删除,同时更新toDoList数组', () => { const wrapper = shallowMount(ToDoList) wrapper.setData({toDoList: ['睡前看一小时书']}) expect(wrapper.vm.toDoList.length).toBe(1) wrapper.find('.wait-to-do li').find('.del').trigger('click') expect(wrapper.vm.toDoList.length).toBe(0) }) it('点击待完成列表中某项的已完成按钮,数据对应更新', () => { const wrapper = shallowMount(ToDoList) wrapper.setData({toDoList: ['中午餐后吃一个苹果']}) expect(wrapper.vm.toDoList.length).toBe(1) expect(wrapper.vm.completedList.length).toBe(0) wrapper.find('.wait-to-do li').find('.move').trigger('click') expect(wrapper.vm.toDoList.length).toBe(0) expect(wrapper.vm.completedList.length).toBe(1) }) it('点击已完成列表中某项的未完成按钮,数据对应更新', () => { const wrapper = shallowMount(ToDoList) wrapper.setData({completedList: ['唱了一首歌']}) expect(wrapper.vm.toDoList.length).toBe(0) expect(wrapper.vm.completedList.length).toBe(1) wrapper.find('.has-completed li').find('.move').trigger('click') expect(wrapper.vm.toDoList.length).toBe(1) expect(wrapper.vm.completedList.length).toBe(0) }) it('列表序号从1开始递增', () => { const wrapper = shallowMount(ToDoList) wrapper.setData({toDoList: ['早上作做业', '下午去逛街']}) expect(wrapper.vm.toDoList.length).toBe(2) expect(wrapper.find('.wait-to-do').html()).toMatch('<i>1</i>') expect(wrapper.find('.wait-to-do').html()).toMatch('<i>2</i>') }) it('当待完成列表为空的时候,不显示待完成字样', () => { const wrapper = shallowMount(ToDoList) wrapper.setData({toDoList: []}) expect(wrapper.find('h4').isVisible()).toBeFalsy() wrapper.setData({toDoList: ['明天去爬北山']}) expect(wrapper.find('h4').isVisible()).toBeTruthy() }) 复制代码
一个测试用例中能够写多个 expect
以保证断言的准确性。
最后咱们为了模拟异步测试,因此加一个需求,即页面加载的时候会去请求远程待完成列表的数据。 在项目根目录新建 __mocks__
目录,同时新建 axios.js
:
const toToList = { success: true, data: ['上午去图书馆看书', '下去出去逛街'] } export const get = (url) => { if (url === 'toToList.json') { return new Promise((resolve, reject) => { if (toToList.success) { resolve(toToList) } else { reject(new Error()) } }) } } 复制代码
修改 ToDoList.vue
,导入 axios
和增长 mounted
:
<script> import * as axios from '../../__mocks__/axios' export default { mounted () { axios.get('toToList.json').then(res => { this.toDoList = res.data }).catch(err => { }) }, }; </script> 复制代码
测试用例编写为:
it('当页面挂载的时候去请求数据,请求成功后应该会返回2条数据', (done) => { wrapper.vm.$nextTick(() => { expect(wrapper.vm.toDoList.length).toBe(2) done() }) }) 复制代码
对于异步的代码,写断言的时候须要放在 wrapper.vm.$nextTick()
里,且手动调用 done()
。
测试用例写了部分,若是咱们看下覆盖率如何,就须要要配置测试覆盖率。在 jest.config.js
里新增配置:
collectCoverage: true, collectCoverageFrom: ["**/*.{js,vue}", "!**/node_modules/**"], 复制代码
在 package.json
的 scripts
中新增一条配置:
"test:cov": "vue-cli-service test:unit --coverage" 复制代码
而后咱们在终端运行: npm run test:cov
,结果以下:
运行测试覆盖率命名后会在项目根目录生成 coverage
目录,浏览器打开里面的 index.html
: