Vue单元测试踩坑记录

  为了提高代码设计水平,测试是必不可少的。jest是facebook出的一个测试框架,里面自带断言库,而且VUE有个Vue Test Utils提供了官方支持,因此这里使用jest构建VUE单元测试部分。开始前建议先浏览一下官方网站,进行初步了解。

起步

安装&简易demo

新建一个文件夹,然后执行:

1
2
npm init
npm install jest -g

这就安装好了,接下来写一个简单的函数:

jestSimpleDemo

然后在你的项目文件夹下执行jest, 就会自动搜索所有.test.js和.spec.js文件进行测试。

增加配置

接着我们改一下目录,增加一个SRC文件夹,里面放着我们的原文件,然后新建test文件夹,将测试文件全部放进去
jestES6Demo
在demo.test.js中添加代码:

1
2
3
4
5
6
7
8
// demo.test.js
import checkNumber from '../src/demo/demo';

describe('decribe用来生成一个组', () => {
test('checkNumber', ()=> {
expect(checkNumber(2, 3)).toBe(5);
});
});

这时直接运行会报错,因为默认不支持ES6的部分语法,所以需要安装babel(注意babel的版本)进行转译:

1
npm install @babel/core @babel/preset-env babel-jest -D

在根目录新增babel.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
// babel.config.js
module.exports = {
presets: [
[
'@babel/preset-env',
{
targets: {
node: 'current',
},
},
],
],
};

并且在package.json中配置好入口文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// package.json
{
"name": "jestdemo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "jest"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.4.5",
"@babel/preset-env": "^7.4.5",
"babel-jest": "^24.8.0",
"jest": "^24.8.0"
}
}

jest.config.js是jest配置文件,后面一些配置就在这里添加。

1
2
3
4
// jest.config.js
module.exports = {
};

jest运行时会自动寻找默认的jest.config.js配置文件。如果要自定义目录,需要在package.json中指定路径:

1
2
3
"scripts": {
"test": "jest --config ./test/jest.config.js"
},

执行:

1
npm run test

得到以下结果,说明执行成功!
jestDemoSuccess

测试Vue组件

  Vue官方给我们提供了Vue Test Utils单元测试工具,Vue Test Utils 通过将组件隔离挂载,然后模拟必要的输入 (prop、注入和用户事件) 和对输出 (渲染结果、触发的自定义事件) 的断言来测试 Vue 组件。被挂载的组件会返回到一个包裹器内,而包裹器会暴露很多封装、遍历和查询其内部的 Vue 组件实例的便捷的方法。

简易Demo

以一个简单的组件为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// hello.vue
<template>
<div>
hello {{ msg }}
<p class="text">I'm jest demo</p>
<button class="count" @click="increment">Increment {{ count }}</button>
</div>
</template>

<script>

export default {
name: 'Hello',
data() {
return {
msg: 'picker',
count: 0
};
},
methods: {
increment() {
this.count++;
}
}
};
</script>

添加依赖

1
npm install vue-jest @vue/test-utils jest-serializer-vue -D

参考vue-cli2,修改jest配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// jest.config.js
const path = require('path');

module.exports = {
rootDir: path.resolve(__dirname, './'),
moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx', 'node', 'vue'], // 处理这些文件
moduleDirectories: ['node_modules', 'assets'], // 从这些目录去查找资源
moduleNameMapper: { // 进行转译, identity-obj-proxy用来模拟输入
'^@/(.*)$': '<rootDir>/assets/',
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 'identity-obj-proxy',
'\\.(css|less|scss)$': 'identity-obj-proxy'
},
transform: { // 可以理解成loader
'^.+\\.js$': 'babel-jest',
'.*\\.(vue)$': 'vue-jest'
},
transformIgnorePatterns: ['<rootDir>/node_modules/'],
testPathIgnorePatterns: [ // 忽略测试文件
'<rootDir>/projects/',
'<rootDir>/test/sub.test.js'
],
testRegex: 'hello.test.js', // 测试指定格式文件,默认全部.test.js(x)和.spec.js(x)文件
snapshotSerializers: ['<rootDir>/node_modules/jest-serializer-vue'], // 快照的序列化工具
collectCoverage: true, // 测试覆盖率
coverageDirectory: '<rootDir>/test/coverage',
collectCoverageFrom: [ // 测试覆盖率
'<rootDir>/testDemo/**/*.{js,vue}',
'!**/node_modules/**'
]
};

写测试文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// hello.test.js

import Vue from 'vue';
import { mount } from '@vue/test-utils';
import { renderToString } from '@vue/server-test-utils';
import hello from '../testDemo/hello.vue';

let wrapper;
let vm;

describe('vue组件测试', () => {
beforeEach(() => { // 每次测试前确保我们的测试实例都是是干净完整的。返回一个wrapper对象
wrapper = mount(hello);
});
afterEach(() => {
vm && vm.$destroy();
wrapper && wrapper.destroy();
});
it('Dom', () => {
expect(wrapper.contains('div')).toBe(true);
});
it('Content', () => {
expect(wrapper.find('.text').text())
.toEqual('I\'m jest demo');
});
it('Trigger', () => {
const button = wrapper.find('.count');
button.trigger('click');
expect(button.text())
.toEqual('Increment 1');
});
it('renderToString render component as a html', async () => {
const str = await renderToString(hello);
expect(str).toContain('<p class="text">I\'m jest demo</p>');
});
});

其中的API根据官方文档自行调整,配置完毕后再次运行,出现以下结果即说明测试通过:

helloVueTest

图中的表格就是我们配置的测试覆盖率。其中的几个参数意思是:

%stmts是语句覆盖率(statement coverage):是否每个语句都执行了
%Branch是分支覆盖率(branch coverage):是否每个if代码块都执行了
%Funcs是函数覆盖率(function coverage):是否每个函数都调用了
%Lines行覆盖率(line coverage):是否每一行都执行了

从图中可以看出hello.vue文件的执行率全部为100%,并且全部通过测试。说明组件逻辑计算没有问题,实际使用怎样不能确定,但是肯定没有人为的失误问题。组件测试不一定非要追求100%,有些组件其实只要测试输入输出可以达到要求即可。

前面jest.conf.js中设置了覆盖率测试结果的输出路径(coverageDirectory)为test文件夹下的coverage,测试完成后就会自动生成:
helloVueTest

组件挂载方式

  我们要测试VUE组件,你得找个地方挂载它,这样才可以调用VUE实例的一些属性与方法。可以使用Vue Test Utils官方API创建一个包裹器wrapper,将组件挂载上去,也可以收到创建VNode,只渲染,不挂载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Vue Test Utils API
import { mount } from '@vue/test-utils';
import hello from './hello.vue';

beforeEach(() => { // 每次测试前确保我们的测试实例都是是干净完整的。返回一个wrapper对象
wrapper = mount(hello);
});
describe('hello', () => {
it('renders a div', () => {
expect(wrapper.contains('div')).toBe(true);
})
})

//使用 Vue.$mount() 手动挂载
import hello from './hello.vue'

const Constructor = Vue.extend(hello);
// 渲染但不往Dom挂载,即可调用Vue实例的属性和方法
const vm = new Constructor().$mount();
  • 简单组件可以用Vue Test Utils的API,挂载时顺便将slot、propsData设置好,检测输出的结果是否符合预期。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 举例
import Vue from 'vue';
import sinon from 'sinon';
import { mount, shallowMount } from '@vue/test-utils';
import index from '../index.vue';

let wrapper;
let vm;

const leftSlot = '<div class="leftSlot">left icon</div>';
const left = '<p class="leftText">left text</p>'
const right = '<p class="rightText">right text</p>'
const title = '<p class="title">header title</p>'

describe('check index', () => {
beforeEach(() => {
wrapper = mount(index, {
propsData: {
theme: 'transparent',
leftOptions: {
showBack: true,
backText: 'goBack',
preventGoBack: false,
showMore: true
},
title: 'header组件',
transition: String,
rightOptions: {
showMore: true
}
}
});
});
afterEach(() => {
vm && vm.$destroy();
wrapper && wrapper.destroy();
});
it('DOM', () => {
expect(wrapper.find('.title').text()).toBe('header组件');
});
});

  • 复杂组件可以创建一个Vue的实例对象,在这个对象里引用你的组件,调用相关的API测试结果。下面这个例子是element-ui组件单元测试创建VUE实例的方法。使用时直接将引入的组件挂在创建的实例上即可。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
const createElm = function() {
const elm = document.createElement('div');
document.body.appendChild(elm);
return elm;
};
/**
* 创建一个 Vue 的实例对象
* @param {Object|String} Compo 组件配置,可直接传 template
* @param {Boolean=false} mounted 是否添加到 DOM 上
* @return {Object} vm
*/
export const createVue = function(Compo, mounted = false) {
if (Object.prototype.toString.call(Compo) === '[object String]') {
Compo = { template: Compo };
}
return new Vue(Compo).$mount(mounted === false ? null : createElm());
};

// 组件挂载
import child from '../child.vue';

it('create', done => {
const vm = createVue({
template: `
<div>
<button class="btn">a button</button>
<child
ref="autocomplete"
v-model="state"
:options="options"
@handle="handle"
>
hello world
</child>
</div>
`,
data() {
return {
restaurants: [],
state: '',
options: {
show: false
}
};
},
methods: {
handle(res) {
console.log('handle' + res);
},
},
mounted() {
}
}, true);

let elm = vm.$el;
let btnElm = elm.querySelector('btn');
expect(btnElm.text()).toBe('a button');
});

复杂Vue组件

  这里的复杂表示组件内包含Vuex、VueRouter等常用配套插件,不包括组件的业务逻辑

配合 Vue Router 使用

路由测试有三种方法:

  1. 使用了 router-link 或 router-view 的组件可以使用stub,但是一般项目都是编程式导航,因此使用第2种和第3种方式较好
1
2
3
4
5
import { shallowMount } from '@vue/test-utils'

shallowMount(Component, {
stubs: ['router-link', 'router-view']
})
  1. 引入要测试路由的组件,并创建一个真正的路由实例

如图,路由中有两个组件,App.vue是根组件

router

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// router.js
import Vue from 'vue';
import VueRouter from 'vue-router';
import Home from './Home.vue';
import Menu from './Menu.vue';

const routes = [
{ name: 'Home', path: '/Home', component: Home },
{ name: 'Menu', path: '/Menu', component: Menu }
];

Vue.use(VueRouter);

export default new VueRouter({ routes });

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// router.test.js
import { mount, createLocalVue } from '@vue/test-utils';
import VueRouter from 'vue-router';
import App from './App';
import Home from './Home';
import Menu from './Menu';
import router from './router';

const localVue = createLocalVue();
localVue.use(VueRouter);

describe('router', () => {
it('App router test', () => {
const wrapper = mount(App, { localVue, router });
router.push({ name: 'Menu' });
expect(wrapper.find(Menu).exists()).toBe(true);
router.push({ name: 'Home' });
expect(wrapper.find(Home).exists()).toBe(true);
expect(wrapper.vm.$route.name).toBe('Home');
});
});
  1. 伪造 $route 和 $router

如下,mock所需组件,并添加相关参数,检查最终结果是否符合预期

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// router.test.js
import { shallowMount, mount, createLocalVue } from '@vue/test-utils';
import VueRouter from 'vue-router';
import App from './App';
import Home from './Home';
import Menu from './Menu';

jest.mock('./Home', () => ({
name: 'Home',
render: h => h(
'div',
{
class: {
show: true
},
attrs: {
id: 'txt'
}
},
'i\'m render text'
)
}));

const routes = [
{ name: 'Home', path: '/Home', component: Home },
{ name: 'Menu', path: '/Menu', component: Menu }
];
const router = new VueRouter({ routes });

const localVue = createLocalVue();
localVue.use(VueRouter);

describe('router', () => {
it('App router test', () => {
// 这里需要用mount挂载,所以为了不污染全局的Vue类,使用createLocalVue创建一个新的提供挂载router的类
const wrapper = mount(App, { localVue, router });
router.push({ name: 'Menu' });
expect(wrapper.find(Menu).exists()).toBe(true);
router.push({ name: 'Home' });
expect(wrapper.find(Home).exists()).toBe(true);
expect(wrapper.find('#txt').text()).toBe('i\'m render text');
expect(wrapper.vm.$route.name).toBe('Home');
});
});
describe('Menu mock router', () => {
it('renders a username from query string', () => {
const username = 'alice';
// shallowMount渲染的是一个替身组件,<router-link>会被忽略,上面的wrapper.find(Menu)也找不到,因为shallowMount的子组件是stub,不存在的。
const wrapper = shallowMount(Menu, {
mocks: {
$route: {
params: { username }
}
}
});
expect(wrapper.find('.username').text()).toBe(username);
});
});

增加快照

  当你的组件达到理想状态并测试无误后,可以将当前状态保存为一个快照,这样后面再有改动就可以跟这个快照对比,可以大大提高测试的速度。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 为HTML根元素设置快照
describe('vue组件测试', () => {
beforeEach(() => {
wrapper = mount(hello);
});
afterEach(() => {
vm && vm.$destroy();
wrapper && wrapper.destroy();
});
it('snaps', () => {
expect(wrapper.element).toMatchSnapshot();
})
})

  设置好快照,下次有更改的东西直接会提示出来:
snapshot

更新快照可以用:

1
jset -u

常用测试项

  1. 检测Dom
1
2
3
4
5
6
it('renders a div', () => {
expect(wrapper.contains('div')).toBe(true);
});
it('p标签的内容是I\'m jest demo', () => {
expect(wrapper.find('.text').text()).toEqual('I\'m jest demo');
});
  1. 检测类名
1
2
3
4
5
it('check classes', () => {
const checkButton = wrapper.find('.check-box');
expect(checkButton.classes()).toContain('is-checked');
expect(checkButton.classes().indexOf('checked') === -1).toBe(true);
});
  1. 检测样式
1
2
3
4
it('传递的颜色应该是#fff', () => {
const icon = wrapper.find('.icon');
expect(icon.element.style.color).toEqual('rgb(255, 255, 255)');
});
  1. 检测方法有没有被调用
  • Vue Test Utils
1
2
3
4
5
6
7
8
9
10
it('click', () => {
const checkButton = wrapper.find('.check-box');
checkButton.trigger('click');
expect(wrapper.emitted().input.length).toBe(1);
expect(wrapper.emitted().input[0]).toBeTruthy();
expect(wrapper.emitted().input[0]).toEqual([0]);
expect(checkButton.classes()).toContain('is-checked');
checkButton.trigger('click');
expect(wrapper.emitted().input.length).toBe(2);
});
  • sinon
1
2
3
4
5
6
7
 it('triggle', () => {
const shallowWrapper = wrapper(Header);
const eventSpy = sinon.spy(wrapper.vm, '$emit');
wrapper.find('.gree-header-title').trigger('click');
expect(eventSpy.withArgs('on-click-title').calledOnce).toBeTruthy();
});
});
  1. 检测传入的数据有没有正常接收
    挂载组件时传入一组虚拟数据,根据传入的内容和得到的结果验证逻辑处理是否正确:
1
2
3
4
5
6
7
8
9
it('The text of check-box should be something, () => {
const shallowWrapper = shallowMount(box, {
slots: {
default: 'something'
}
})
expect(shallowWrapper.find('.check-box').text()).toBe('something');
shallowWrapper.destroy();
});

常用API

jest

  • 全局API
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 分组
descrip('title', () => {
it('test1', ()=>{
// do somthing
})
it('test2', ()=>{
// do somthing
})
})
beforeEach(fn, timeout) // 在当前作用域下每次执行前运行
afterEach(fn, timeout) // 在当前作用域下每次执行后运行
beforeAll(fn, timeout) // 全部代码执行前运行
// timeout: ms 终止前等待的时间,默认是5000, 5s到了后即使没有执行完测试用例也会终止
// 使用举例
beforeAll(() => console.log('global - beforeAll'));
afterAll(() => console.log('global - afterAll'));
beforeEach(() => console.log('global - beforeEach'));
afterEach(() => console.log('global - afterEach'));
test('', () => console.log('global - test'));
describe('Scoped / Nested block', () => {
beforeAll(() => console.log('Scoped - beforeAll'));
afterAll(() => console.log('Scoped - afterAll'));
beforeEach(() => console.log('Scoped - beforeEach'));
afterEach(() => console.log('Scoped - afterEach'));
test('', () => console.log('Scoped - test'));
});
// 执行顺序
// global - beforeAll
// global - beforeEach
// global - test
// global - afterEach
// Scoped - beforeAll
// global - beforeEach
// Scoped - beforeEach
// Scoped - test
// Scoped - afterEach
// global - afterEach
// Scoped - afterAll
// global - afterAll

describe(name, fn) // 创建一个测试用例的分组
test(name, fn, timeout) // 测试用例
it(name, fn, timeout) // 测试用例
test.each(table)(name, fn, timeout) // 当一个测试用例需要执行多次,但是每次仅仅是参数不同时用这个
// e.g.
test.each([[1, 1, 2], [1, 2, 3], [2, 1, 3]])(
'.add(%i, %i)',
(a, b, expected) => {
expect(a + b).toBe(expected);
},
);
  • 断言
1
2
3
4
5
6
7
8
9
10
11
12
13
14
expect(fn()).toBe(value) // 断言fn的执行结果是value
expect.extend(matchers) // 自定义断言
// e.g.
expect.extend({
matchers() {
return true;
},
});
it('numeric ranges', () => {
expect(100).matchers().toBe(true);
});

expect().toBeTruthy() // 结果为真
expect().toEqual() // 递归比较对象实例的所有属性(也称为“深度”相等)。
  • mock
1
2
3
4
5
6
jest.fn() // 提供一个虚拟方法
// e.g.
const returnsTrue = jest.fn(() => true);
returnsTrue();
console.log(returnsTrue()); // true;
expect(returnsTrue).toHaveBeenCalled();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
jest.mock(moduleName, factory, options) // 在需要时模拟一个模块。factory和options是可选的。比如测试路由时可以mock所需的路由模块

// 举例:
// sum.js
function sum() {
return 88;
}
module.exports = sum;

// sum.test.js
const sum = require('./sum.js');
jest.mock('./sum.js', () => jest.fn(() => 66));
describe('sum', () => {
it('sum should return 66', () => {
console.log(sum()); // 66,这里的sum()是mock的假方法
expect(sum()).toBe(66);
});
});

Debug

  vscode有jest插件,安装后可以快速开启debug。debug环境是在node下的,也可以自己配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"name": "vscode-jest-tests",
"request": "launch",
"args": [
"--runInBand"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"program": "${workspaceFolder}/node_modules/jest/bin/jest"
}
]
}

配置好后设置断点,按F5即可。

snapshot