vue测试入门与实践

vue测试入门与实践

引言:

在自动化测试中,测试前端应用 一般有3种测试方式 单元测试(unit tests), 快照测试(snapshot tests), 端对端测试(end-to-end tests). 这里我先简单介绍一下前端的3种测试方式,然后着重讲前端单元测试,在对大型vue项目上进行单元测试时遇到的困难 以及解决方法。

e2e测试

端对端测试是最直接的测试类型,在前端应用中,端对端测试使浏览器自动化,从用户的角度检查应用程序是否正常运行。它验证的是一个完整的操作链是否能够完成

e2e测试示例

1
2
3
4
5
6
7
8
9
10
11
// 计算器用例
function testCalculator(browser) {
browser
.url('http://localhost:8080') // 本地启动浏览器 localhost:8080
.click('#button-1') // 模拟用户 点击计算器按钮 1
.click('#button-plus') // 点击按钮 +
.click('#button-1') // 点击按钮 1
.click('#button-equal') // 点击按钮 =
.assert.containsText("#result", "2") // 断言页面上输出的结果是 2
.end(); // 这个e2e测试实例结束。
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// nutty管理端的e2e 测试
it('拖拽测试', (browser) => {
browser.pause(500)
.assert.urlContains('http://test.nutty.pcg.com/nutty-cms/index.html#/workspace/349')
.waitForElementVisible('.nutty-component__container')
.waitForElementVisible('.nutty-component__container--template')
.assert.attributeEquals({
selector: '.nutty-component__list li',
index: 0,
}, 'data-component-name', 'button')
.moveToElement({ selector: '.nutty-component__list li', index: 0 }, 5, 5)
.mouseButtonDown(0)
.waitForElementVisible('.nutty-button')
.moveToElement('.nutty-canvas', 100, 100)
.pause(1000)
.waitForElementVisible('.nutty-canvas .nutty-button')
.saveScreenshot(`${browser.globals.path}drag.png`);
});
});

nutty管理端组件拖拽测试

e2e测试(属于黑盒测试的一种) 将应用看成一个整体,站在用户的角度上来测试,模拟用户行为,我们只要关心功能实现,不需要关心内部细节。

e2e测试 比起手动测试节省了大量时间,它解决了之前的系统测试都是手动测试的问题。

e2e测试的问题:

  1. e2e测试很慢、启动浏览器可能都要花几秒、而且如果有的网站很慢,更会拖累速度。如果全靠e2e测试,那么整个测试可能要几十分钟、甚至数小时。
  2. e2e测试debug很困难,一般来说抛错后 需要打开浏览器,以重现该错误。这个在本地相对还好,一旦在线上的ci流水线上挂了,那么很难顶。
  3. e2e测试里 难以避免有外部依赖,这样会导致测试不稳定,很容易存在某个api宕机 导致线上流水线一时半会跑不过的问题。

快照测试

传统的快照测试会在浏览器中启动应用程序,并对呈现的页面进行截图。 他们会将新拍摄的屏幕截图与保存的屏幕截图进行比较,如果存在差异,则会显示错误。 当操作系统或浏览器版本之间的差异导致测试失败(即使快照未更改)时,此类快照测试也会出现问题。

jest测试框架编写快照测试。 Jest快照测试可以比较JavaScript中的任何可序列化的值(指可以转换为字符串然后再转换为值的任何代码),而不是比较屏幕截图。 您可以使用它们来比较Vue组件的DOM输出。

良好的前端测试

一个良好的前端测试,一般是由3种测试按一定比例混合而成的。 这是vue测试开发者推荐用的比例。

image-20200812221047394

避免不稳定测试的最好方法是不编写测试,因此只包含一部分e2e测试。

写测试的流程:

  1. 根据业务线需求 确定需要的组件
  2. 编写每个组件的源码和单元测试
  3. 设计组件的样式
  4. 为完成的组件添加快照测试
  5. 在浏览器中手动测试
  6. 编写e2e测试

什么是单元测试?

单元测试是软件开发非常基础的一部分。单元测试会封闭执行最小化单元的代码,使得添加新功能和追踪问题更容易。Vue 的单文件组件使得为组件撰写隔离的单元测试这件事更加直接。它会让你更有信心地开发新特性而不破坏现有的实现,并帮助其他开发者理解你的组件的作用。

为什么要写单元测试

组件的单元测试有很多好处:

  • 提供描述组件行为的文档
  • 节省手动测试的时间
  • 减少研发新特性时产生的 bug
  • 改进设计
  • 促进重构

自动化测试使得大团队中的开发者可以维护复杂的基础代码。

单元测试的要求

  • 可以快速运行

  • 易于理解

    单个函数(方法)的圈复杂度 不要太高。。。。

  • 只测试一个独立单元的工作 单元测试不能有外部依赖,保证测试用例稳定。

    一般情况下 尽量少使用mount,尤其是子组件存在 副作用的情况下。 avoid deep rendering when there can be side effects

  • 良好的单元测试可以断言组件的最终状态,而无需考虑实现细节。

怎么写单元测试?

单测3步

数据准备 arrange (set up for the test. In our case, we render the component).
触发行为 act (execute actions on the system).
断言输出 assert (ensure the actual result matches your expectations).

测一个vue组件测的是什么?

输入输出

  • 输入
    • Component props 组件props输入
    • User actions (like a button click) 用户行为(例如点击、输入等)
    • Vue events (外部传入的事件)
    • Data in a Vuex store (store数据)
    • Data in route (route数据)
    • 本地数据 (web本地储存等)
  • 输出
    • Emitted events
    • External function calls (methods的函数调用)
    • UI变化 (UI改变)
    • store改变 (store改变)

给定的输入(属性,触发的事件)而非实现来测试输出(通常是呈现的HTML)

举个单元测试例子

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
// 一个简单的vue组件。
<template>
<div>
<div class="avator" v-if="isAvator">{{avator}}</div>
<input v-model="username" />
<div v-if="error" class="error">
{{ error }}
</div>
<div>
<h1>{{ msg }}</h1>
<button class=".msg-btn" @click="onClick">click me</button>
</div>
</div>
</template>

<script>
export default {
name: "Hello",
props: {
isAvator: {
type: Boolean,
default: false,
},
avator: {
type: String,
default: '',
},
},
data() {
return {
username: "",
msg: "default message"
};
},
methods: {
onClick() {
this.msg = "new message";
},
},
computed: {
error() {
return this.username.trim().length < 7
? "Please enter a longer username"
: "";
},
},
};
</script>
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
59
60
61
62
63
64
65
66
67
// Hellow.vue组件的单元测试
import { shallowMount } from '@vue/test-utils'
import Hello from './Hello.vue'

/**
* @author chengfu
* @priority P2
* @casetype unit
*/
describe('测试Hello组件', ()=> {
it('Hello', () => {
// 渲染这个组件
const wrapper = shallowMount(Hello)

// `username` 因为字符串只有4个字符 所以错误信息会渲染
wrapper.setData({ username: 'test' })

// 确认错误信息被渲染了
expect(wrapper.find('.error').exists()).toBe(true)

// 将名字更新至足够长
wrapper.setData({ username: 'Lachlan' })

// 断言错误信息不再显示了
expect(wrapper.find('.error').exists()).toBe(false)
})

it('测试头像', () => {
// 挂载Hello组件的时候 传入props
const wrapper = shallowMount(Hello, {
propsData: {
isAvator: true,
avator: 'xxx',
}
})

// 判断avator div是否存在
expect(wrapper.find('.avator').exists()).toBe(true)

// 将props里的isAvator改为false
wrapper.setProps({isAvator: false})

// 判断avator div是否存在
expect(wrapper.find('.avator').exists()).toBe(false)
})


it('测试msg-btn 按钮点击', () => {
const wrapper = shallowMount(Hello)

// 找到 点击 msg-button
wrapper.find('.msg-btn').trigger('click')

// 断言h1渲染new message
expect(wrapper.find('h1').text()).toEqual('new message')
})

it('测试onclick 函数', () => {
const wrapper = shallowMount(Hello)

// 调用methods里的onclick方法
wrapper.vm.onClick()

// 断言data里面的值是new message
expect(wrapper.vm.msg).toBe('new message')
})
})

安装准备

1
tnpm i -D @vue/test-utils @tencent/jest-preset-halo vue-jest vue-template-compiler

在 package.json 中增加单元测试相关配置

1
2
3
4
5
6
7
8
9
10
11
12
// Vue 项目package.json
{
"scripts": {
"test": "jest"
},
"jest": {
"preset": "@tencent/jest-preset-halo/vue",
"collectCoverageFrom": [
"**/src/**/*.{js,vue}"
]
}
}

编写单测

编写vue单测的思路

先将vue组件隔离挂载,生成一个wrapper实例(先简单理解为一个vue组件实例), 然后对这个vue实例模拟必要的输入,然后断言vue组件的输出。

Vue Test Utils 通过将它们隔离挂载,然后模拟必要的输入 (prop、注入和用户事件) 和对输出 (渲染结果、触发的自定义事件) 的断言来测试 Vue 组件。

@vue/test-utils中一些常见的api

API

mount, shallowMount

image-20200813171416340

1
2
3
4
5
6
7
8
9
10
// 被测vue组件
<template>
<el-scrollbar
ref="scrollContainer"
:vertical="false"
class="scroll-container"
>
<slot/>
</el-scrollbar>
</template>
1
2
3
4
5
6
7
8
9
10
11
/**
* @author chengfu
* @priority P2
* @casetype unit
*/
describe('ScrollPane', () => {
it('test ScrollPane.vue', () => {
const wrapper = mount(ScrollPane);
expect(wrapper.element).toMatchSnapshot();
});
});
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
// mount挂载生成的快照
exports[`{"name":"ScrollPane","author":"chengfu","priority":"P2","casetype":"unit"} {"name":"test ScrollPane.vue","author":"chengfu","priority":"P2","casetype":"unit"} 1`] = `
<div
class="scroll-container el-scrollbar"
>
<div
class="el-scrollbar__wrap el-scrollbar__wrap--hidden-default"
>
<div
class="el-scrollbar__view"
/>
</div>
<div
class="el-scrollbar__bar is-horizontal"
>
<div
class="el-scrollbar__thumb"
style="width: 0px; transform: translateX(0%); webkit-transform: translateX(0%);"
/>
</div>
<div
class="el-scrollbar__bar is-vertical"
>
<div
class="el-scrollbar__thumb"
style="height: 0px; transform: translateY(0%); webkit-transform: translateY(0%);"
/>
</div>
</div>
`;

// shallowMount生成的快照
shallowMount 方法只挂载一个组件而不渲染其子组件 子组件完全被隔离、里面的生命周期都不会触发。
<el-scrollbar
class="scroll-container"
/>

// ps: 在使用 mount 或 shallowMount 方法时,挂载组件会触发组件内的所有生命周期,
// 但是如果要出触发beforeDestroy 和 destroyed, 要使用Wrapper.destroy()。

Wrapper

一个 Wrapper 是一个包括了一个挂载组件或 vnode,以及测试该组件或 vnode 的方法。

  • vm

Component (只读):这是该 Vue 实例。你可以通过 wrapper.vm 访问一个实例所有的方法和属性。这只存在于 Vue 组件包裹器或绑定了 Vue 组件包裹器的 HTMLElement 中。

  • find

返回一个DOM节点或者vue组件的wapper

  • trigger

在wrapper的DOM节点上触发一个指定的事件

  • contains

判断是否包含选择的元素或者组件

  • exists

断言wrapper是否存在

  • setProps

设置vm的props属性

  • setData

设置vm的data属性

单测具体编写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { shallowMount } from '@vue/test-utils'; // 首先必须引入@vue/test-utils
import Hello from './Hello.vue' // 引入被测组件


/**
* @author chengfu
* @priority P2
* @casetype unit
*/
describe('测试Hello组件', ()=> {
it('Hello', () => {
// 渲染这个vue组件,然后对这个vue组件进行测试,这一步是最最基本的。
const wrapper = shallowMount(Hello)

// `username` 因为字符串只有4个字符 所以错误信息会渲染
wrapper.setData({ username: 'test' })

// 确认错误信息被渲染了
expect(wrapper.find('.error').exists()).toBe(true)
})
})

启动测试

1
2
npm run test // 启动jest测试
npm run test -- --coverage // 启动jest测试并查看单元测试覆盖率

image-20200809211241739

image-20200809211313268

推荐插件

提升效率,每次测试的时候 都要手动运行jest启动命令、 这个插件在ctrl + s(自动保存的时候),自动运行jest用例,过了打√、错了x。

image-20200809211624596

image-20200809211744842

jest:Toggle Coverage Overlay。 能查看哪里还没有被覆盖。

怎么在chrome上断点调试jest?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { shallowMount } from '@vue/test-utils'
import Item from '../Item.vue'
describe('Item.vue', () => {
test('renders "item"', () => {
const wrapper = shallowMount(Item)
debugger // 在需要打断点的地方,加这个debugger。
expect(wrapper.text()).toContain('item')
})
})

// package.json 本地的node必须是8.9以上。
"test:unit:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --no-cache --runInBand"

// 测试调试。
npm run test:unit:debug

然后打开chrome://inspect。

img

必须点击Resume

img

img

单元测试的难点及问题

环境准备(挂载vue实例时的问题)。

vue项目本身就是一个整体 而单元测试,将一个组件从整体里抽离出来后 让它能够运行(也就是环境准备),这部分最难的。

一个vue组件一般会有很多依赖,这些依赖我们要么在shallowMount的时候 将依赖的实例传入,要么mock掉。总而言之不能让vue组件因为缺少这些依赖无法正常运行。

Component Tree

在vue项目中非常容易遇到的一些 环境准备问题及解决方案。

别名无法识别

vue项目中经常有配置别名、而在单测代码中是无法读取到别名的。

1
2
3
4
5
6
7
// webpack.base.config.js
resolve: {
extensions: ['.js', '.vue', '.json'],
alias: {
'@': resolve('src')
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// MessageList.vue
<template>
<div id="app">
<MessageList :messages="messages"/>
</div>
</template>

<script>
import MessageList from '@/components/MessageList'

export default {
name: 'app',
data: () => ({ messages: ['Hey John', 'Howdy Paco'] }),
components: {
MessageList
}
}
</script>

image-20200806152044090

别名无法识别解决方案:在package.json jest中配置别名。

1
2
3
4
5
6
7
8
9
"jest": {
"preset": "@tencent/jest-preset-halo/vue",
"collectCoverageFrom": [
"**/src/**/*.{js,vue}"
],
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1"
}
},
element-ui未注册

一个大型vue项目,难以避免用了各种ui组件,而且这种ui组件一般都是全局注册的,我们进行单个组件的测试时,是无法访问全局的ui组件的,那么这个时候 据需要在单测代码中 进行ui组件注册或者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
<template>
<el-select>
<el-option
v-for="(item, index) in componentTypeList"
:key="item.text + index"
:label="item.text"
:value="item.text"
>
<div :style="{'text-indent': `${item.indent}px`}">{{ item.text }}</div>
</el-option>
</el-select>
</template>

<script>

export default {
name: 'ScrollPane',
data() {
return {
left: 0,
};
},
};
</script>


// 测试代码
const wrapper = mount(ScrollPane);
expect(wrapper.element).toMatchSnapshot();

1
2
3
4
5
6
7
8
import Vue from 'vue';
import ElementUI from 'element-ui';
import ScrollPane from '../src/component/ScrollPane/index.vue';

Vue.use(ElementUI); // 测试文件里也要导入ElementUI,并挂载。

const wrapper = mount(ScrollPane);
expect(wrapper.element).toMatchSnapshot();

如果是比较复杂的vue组件在挂载时 还能遇到router未定义、vuex未定义、全局变量未定义。。。这些放在后面的怎么测vue逻辑里面。

怎么测试vue组件ui

快照测试

前端组件的输出一般输出的是html字符串,所以ui测试一般用快照测试。

1
2
3
4
5
6
// 环境准备

const wrapper = mount(ScrollPane);
// 模拟输入 props data等。。。

expect(wrapper.element).toMatchSnapshot();

每次修改ui后,快照都会改变,所以要手动更新快照。 当然如果装了jest vs code插件,vs code会提示开发者更新。

1
2
// 手动更新快照  运行这个命令,更新快照。
npm run test -- --u

判断html是否按预期渲染

1
2
3
4
5
6
7
8
9
10
// Hello.vue的单测示例  
it('测试msg-btn 按钮点击',async () => {
const wrapper = shallowMount(Hello)

// 找到 点击 msg-button, 页面会出现
await wrapper.find('.msg-btn').trigger('click')

// 断言h1渲染new message
expect(wrapper.find('h1').text()).toEqual('new message')
})

怎么测试vue组件逻辑

vue组件逻辑包含 methods,自定义事件、vuex、vue-router等

测试vue methods

建议采用模拟用户交互的形式 进行调用methods。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
it('测试msg-btn 按钮点击', () => {
const wrapper = shallowMount(Hello)

// 找到 点击 msg-button
wrapper.find('.msg-btn').trigger('click')

// 点击后 data里面的值更新成 new message
expect(wrapper.vm.msg).toBe('new message')

// 断言h1渲染new message
// expect(wrapper.find('h1').text()).toEqual('new message')
})

it('测试onclick 函数', () => {
const wrapper = shallowMount(Hello)

// 调用methods里的onclick方法
wrapper.vm.onClick()

// 断言data里面的值是new message
expect(wrapper.vm.msg).toBe('new message')
})

这里两种方法测的东西都一样,不过前者是通过模拟用户店里按钮 然后调用onClick方法,后面这种是直接通过wrapper实例调用的onClick方法。

我比较推荐前者的写法,模拟用户行为的输入,这才是这个vue组件原有的输入。

测试computed

直接调用 computed方法和method方法一样 都可以通过wrapper.vm方法名 进行调用。

1
2
3
4
5
6
7
8
9
10
  computed: {
componentTypeList() {
const nestedCount = 0;
const result = this.getComponentTypeList(this.componentList, nestedCount);
return result;
},
},

import { componentList } from './fixture/componentList';
expect(wrapper.vm.getComponentTypeList(componentList, 0)).toMatchSnapshot();

测试抛错

需要在断言里用一个箭头函数包起来,返回一个错误对象,直接对抛错进行断言 会导致测试用例失败。

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
// 被测函数
/** 校验config里所有字段是否存在 */
export function validateConfig(config) {
if (
config.hasOwnProperty.call(config, 'CardOnly')
&& config.hasOwnProperty.call(config, 'ItemType')
&& config.hasOwnProperty.call(config, 'MaxItem')
&& config.hasOwnProperty.call(config, 'MinItem')
&& config.hasOwnProperty.call(config, 'CardReportKeys')
&& config.hasOwnProperty.call(config, 'ItemReportKeys')
) {
return true;
}

throw new ConfigError(`config property error: ${config}`);
}


it('传入的config参数缺少CardOnly字段', () => {
const config = {
ItemType: 'COUPON_ITEM',
MaxItem: 1,
MinItem: 1,
CardReportKeys: [],
ItemReportKeys: [],
dealWithLostCard: 'keep',
};
expect(() => {
validateConfig(config);
}).toThrowError(ConfigError);
}

测试异步

vue组件内的异步操作 主要包括,方法里的ajax请求或其他异步函数 还有dom更新。

测试ajax

ajax请求必须被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
// 被测组件 加载时 发起ajax请求。  
created() {
this.getLists();
},
methods: {
async getLists() {
const schema = {
dataKey: '31f323acc9012c5a38fcc55c8b9ca0f7', // 数据库唯一key
content: {
query: {
dlTransferTemplate: {},
},
},
};
try {
const result = await myAxios.post(schemaExecuteUrl, {
schema: JSON.stringify(schema),
});
this.tableDatas = result.data.query.dlTransferTemplate.rows;
this.total = this.tableDatas.length;
} catch (e) {
console.error(e);
}
},
}
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
import 'regenerator-runtime'; // 必须要引入,不然在测试代码中 跑异步操作, 会报错。 vscode会自动引入。 但是请注意lint规范 no-unused-vars。
import MockAdapter from 'axios-mock-adapter';
import { myAxios } from '../src/utils/request'; // 我们自己封装的一个axios实例。

const mock = new MockAdapter(myAxios);


it('test methods getLists', async () => {
mock.reset(); // 这个是reset之前的mock操作。
mock.onPost('http://taiyi.cs.pcg.com/api/taiyi').reply(200, {
data: {
query: {
dlTransferTemplate: {
count: 1,
rows: [{
id: 1,
card_name: 'BASICINFO',
config: '{}',
name: 'test',
operator: '',
str_schema: '',
create_time: '2020-04-24 13:41:08',
update_time: '2020-04-26 21:41:09',
}],
},
},
},
});

await wrapper.vm.getLists();
expect(wrapper.vm.total).toBe(1);
});

axios-mock-adapter 极力推荐这个moc库,这个还可mock networkError。 mock network timeout等。

非不得已 不要手动去mock axios。

dom更新

Vue 会异步的将未生效的 DOM 批量更新,避免因数据反复变化而导致不必要的渲染。

其中一种办法是使用 await Vue.nextTick(),一个更简单且清晰的方式则是 await 那个你变更状态的方法,例如 trigger

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
dom 发生更新  vue这个操作是异步的  所以断言dom的变化要等待m更新完毕。

it('测试msg-btn 按钮点击', async () => {
const wrapper = shallowMount(Hello)

// 找到 点击 msg-button并 点击
await wrapper.find('.msg-btn').trigger('click')

// 断言h1渲染new message
expect(wrapper.find('h1').text()).toEqual('new message')
})

it('测试msg-btn 按钮点击', async () => {
const wrapper = shallowMount(Hello)

// 找到 点击 msg-button并 点击
wrapper.find('.msg-btn').trigger('click')

// 等待vue dom更新完毕。
await Vue.nextTick()

// 断言h1渲染new message
expect(wrapper.find('h1').text()).toEqual('new message')
})

可以被 await 的方法有:

测试自定义事件

一般而言自定事件的常见应用是在组件与组件共享数据的时候使用。子组件发出事件,父组件监听事件进行响应。

进行自定义事件的测试过程中,依赖到了两个组件。这种依赖多个组件的测试方式是不提倡的。

一般而言,对emit事件的(子)组件的进行断言就行。wrapper提供了一个emitted方法,可以捕获到该组件发出的所有事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// App.vue
<template>
<modal
v-if="displayModal"
@close-modal="closeModal"
>
</template>

data: {
return: {
displayModal: true
}
}

methods: {
closeModal() {
this.displayModal = false;
}
}
1
2
3
4
5
6
7
8
9
10
11
import App from '../App.vue'
import { shallowMount } from '@vue/test-utils'
import Modal from '../components/Modal.vue'

describe('App.vue', () => {
test('hides Modal when Modal emits close-modal', () => {
const wrapper = shallowMount(App)
wrapper.find(Modal).vm.$emit('close-modal')
expect(wrapper.find(Modal).exists()).toBeFalsy()
})
})

这个是一 bad case。为了测试一个custom event。引入了两个组件。违反了单元测试 只测试一个独立单元的工作 规范。

good case

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// MyLogin.vue
<template>
<div>
</div>
</template>

<script>
export default {
name: "MyLogin",

methods: {
emitEvent() {
this.$emit("myEvent", "name", "password")
}
}
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import MyLogin from "@/components/MyLogin.vue"
import { shallMount } from "@vue/test-utils"

describe("MyLogin", () => {
it("emits an custom event", () => {
const wrapper = mount(MyLogin)

wrapper.vm.emitEvent()

console.log(wrapper.emitted())
// 打印结果
// { myEvent: [ [ 'name', 'password' ] ] }

expect(wrapper.emitted().myEvent[0]).toEqual([ 'name', 'password' ])
})
})
  • emitted

返回一个包含由 Wrapper vm 触发的自定义事件的对象。

返回值:{ [name: string]: Array> }

挂载在根Vue原型上的方法或属性。

一般的方法或属性 我推荐在根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
// 被测vue组件
methods: {
handleSubmitAsync() {
return this.$http.get("/api/v1/register", { username: this.username })
.then(() => {
this.submitted = true
})
.catch((e) => {
throw Error("Something went wrong", e)
})
}
}

// mock的 http。
let url = ''
let data = ''

const mockHttp = {
get: (_url, _data) => {
return new Promise((resolve, reject) => {
url = _url
data = _data
resolve()
})
}
}

// 测试代码
it("测试 $http被mock后的状态", () => {
const wrapper = shallowMount(FormSubmitter, {
mocks: {
$http: mockHttp
}
})

wrapper.find("[data-username]").setValue("alice")
wrapper.find("form").trigger("submit.prevent")

expect(wrapper.find(".message").text())
.toBe("Thank you for your submission, alice.")
})

尽量少往 Vue根实例上挂载属性或方法,然后在子组件调用,这种很难测。

测试vuex

当挂载组件的时候,组件内声明周期构造里执行了 vuex相关操作,会报错。

image-20200810164627394

我们必须在挂载组件的时候 注入一个store。

站在测试的角度,我们不关心这个 action 做了什么或者这个 store 是什么样子的。我们只需要知道这些 action 将会在适当的时机触发,以及它们触发时的预期值。我们可以把 store 传递给一个 localVue,而不是传递给基础的 Vue 构造函数。

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

localVue 是一个独立作用域的 Vue 构造函数,我们可以对其进行改动而不会影响到全局的 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
import Vue from 'vue';
import Vuex from 'vuex';
import store from '../src/store/index';
import pageSpec from '../src/page/pageSpec/index.vue';
import { createLocalVue, shallowMount } from '@vue/test-utils';

Vue.use(ElementUI); // 测试文件里也要导入ElementUI,并挂载。
const localVue = createLocalVue(); // 特别注意。这里的localVue类是深拷贝根Vue类所有的属性和方法的,所以这里的创建的localVue也是有挂载了ElementUI的。
localVue.use(Vuex);

const wrapper = mount(pageSpec, {
localVue,
store,
});



// store.js
const store = new Vuex.Store({
modules: {
app,
tagsView,
....,
},
getters,
});
export default store;

这样一个store的数据 在测试用例里面会相互依赖,不太好。

还有一种方法,重构store。

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
// store.js
export const store = new Vuex.Store({ ... })

export const createStore = () => {
return new Vuex.Store({ ... })
}

// 测试用例
const store = createStore();
const factory = (opts = {}) => {
return shallowMount(Foo, {
...opts,
store,
})
}

// 这样每个store就不会相互依赖。
it('测试ADD_POSTS', () => {
const wrapper = factory();
wrapper.vm.$store.commit('ADD_POSTS', [{ id: 1, title: 'Post' }])
});

it('测试ADD_CONT', () => {
const wrapper = factory();
wrapper.vm.$store.commit('ADD_CONT', { count: '1' })
});

Vuex里面的方法,应该最好单独拿出来测试。

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
describe('mutations测试', () => {
it('测试UPDATE_CURRENT_FORM_VIEW', () => {
store.commit('UPDATE_CURRENT_FORM_VIEW', currentFormView);
expect(store.state.currentFormView).toEqual(currentFormView);
});

it('测试UPDATE_RULER', () => {
store.commit('UPDATE_RULER', ruler);
expect(store.state.ruler).toEqual(ruler);
});

it('测试UPDATE_STATE_BY_NAME 根据字段名更新store中的对应字段的值', () => {
store.commit('UPDATE_STATE_BY_NAME', stateName);
Object.keys(stateName).forEach((key) => {
expect(store.state[key]).toBe(stateName[key]);
});
});

it('测试COMPOUND_ADD_CANVAS 复合组件增加一个图层', () => {
store.commit('COMPOUND_ADD_CANVAS', tabName);
});
});

describe('action测试', () => {
it('测试UPDATE_CURRENT_FORM_VIEW', () => {
store.action('UPDATE_CURRENT_FORM_VIEW', currentFormView);
expect(store.state.currentFormView).toEqual(currentFormView);
});
});

ps: vuex里不要有存在那种 没有变更状态的操作,列如action里 并没有改变state的操作,没有改变state的action不能被断言。

测试vue路由

在挂载组件的时候,没有router的话,在组件内声明周期内如果有 有关路由的操作(router-view、编程导航等) 也会抛错,导致测试失败中断。

image-20200812203602121

1
2
3
4
// Home.vue
created() {
this.bid = this.$route.params.bid
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import Vue from 'vue';
import VueRouter from 'vue-router';
import { constantRouterMap } from '../src/router'; // 引入暴露出来路由对象。
import { createLocalVue, shallowMount } from '@vue/test-utils';

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

const router = new VueRouter({ // 自己生成router实例,根据自己的router生成vue组件。
mode: 'hash',
routes: constantRouterMap,
});

const wrapper = shallowMount(Home, {
localVue,
router,
store
});

在测试中,你应该杜绝在基本的 Vue 构造函数中安装 Vue Router。安装 Vue Router 之后 Vue 的原型上会增加 $route$router 这两个只读属性。

安装 Vue Router 会在 Vue 的原型上添加 $route$router 只读属性。

这意味着在未来的任何测试中,伪造 $route$router 都会失效。

image-20200812205221410

1
2
3
4
5
6
7
8
9
10
11
const wrapper = shallowMount(pageSpec, {
localVue,
router,
mocks: { // 用mock的$route替换 原有的route。 但是因为`$route`、`$router`在原型上都是只读属性,所以会报错。
$route: {
path: '/',
params: { bid: 1 },
},
},
store,
});

所以不能修改 $route属性

因为路由需要在应用的全局结构中进行定义,且引入了很多组件,所以最好集成到 end-to-end 测试。对于依赖 vue-router 功能的独立的组件来说,你可以使用mocks 仿造它们。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { mount } from '@vue/test-utils'

const $route = {
path: '/',
hash: '',
params: { id: '123' },
query: { q: 'hello' }
}

const wrapper = mount(Component, {
mocks: {
// 在挂载组件之前
// 添加仿造的 `$route` 对象到 Vue 实例中
$route
}
})

最佳实践

工厂函数初始化数据

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
import { shallowMount } from '@vue/test-utils'
import Foo from './Foo'

const factory = (values = {}) => {
return shallowMount(Foo, {
data () {
return {
...values
}
}
})
}

describe('Foo', () => {
it('renders a welcome message', () => {
const wrapper = factory()

expect(wrapper.find('.message').text()).toEqual("Welcome to the Vue.js cookbook")
})

it('renders an error when username is less than 7 characters', () => {
const wrapper = factory({ username: '' })

expect(wrapper.find('.error').exists()).toBeTruthy()
})
})

工厂函数的目的就是为了保证 数据不会相互依赖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 比较好的工厂函数实践。
const createFactoryVue = (opts = {}) => {
const localVue = createLocalVue()
localVue.use(VueRouter)
localVue.use(Vuex)

const store = createStore()
const router = createRouter()
return shallowMount(Foo, {
...opts,
store,
router,
})
}

如何快速的编写单测

TDD是测试驱动开发(Test-Driven Development)的英文简称,是敏捷开发中的一项核心实践和技术,也是一种设计方法论。 TDD的原理是在开发功能代码之前,先编写单元测试用例代码,测试代码确定需要编写什么产品代码。这样的好处是 迫使开发者在编写代码前 要考虑组件设计。前端单元测试一般不会严格遵循TDD,一般是编写代码后 再加端对端和快照测试

当然 我们这边其实 感觉。。。 更多的是先写代码,然后再写测试,甚至有可能是代码已经写了很久了,已经忘了怎么写了 然后再补充单元测试。。。 甚至还有可能是这个代码不是你写的 然后你要给这个业务代码 补充测试。。。

那么怎么快速的编写这种单测呢?

举个栗子

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
<template>
<el-select
>
<el-option
v-for="(item, index) in componentTypeList"
:key="item.text + index"
:label="item.text"
:value="item.text"
>
<div :style="{'text-indent': `${item.indent}px`}">{{ item.text }}</div>
</el-option>
</el-select>
</template>
<script>
const INDENT_WIDTH = 20;
export default {
name: 'ItemSelector',
props: {
componentList: {
type: Array,
default: () => [],
},
},
computed: {
componentTypeList() {
const nestedCount = 0;
const result = this.getComponentTypeList(this.componentList, nestedCount);
return result;
},
},
methods: {
getComponentTypeList(list, nestedCount = 0) {
let result = [];
list.forEach((component) => {
result.push({
indent: nestedCount * INDENT_WIDTH,
text: this.getComponentType(component),
});

if (component.children && component.children.length) {
result = result.concat(this.getComponentTypeList(component.children, nestedCount + 1));
}
});

return result;
},
getComponentType(component) {
return component.compoundType || component.componentName;
},
},
};
</script>

这个vue组件 编写单测的难点在哪? 在于输入 在于componentList这个的数据结构是什么,所以只要构造了componentList的数据结构 基本上就测完了。 从这个函数的逻辑 我能预测到这个componentList的数据不会小,所以最好不要去直接写这种mock的入参,尤其是你不知道或者忘记这玩意到底结构是怎么样的情况下。

而这个组件已经在组卡管理端上使用了,所以最快的方法就是去组卡管理端上打断点调试,或者直接打console.log(JSON.stringify(this.componentList))打印出数据, 然后把他格式化。

什么样的vue组件是不好测的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
// NumStepper.vue
<template>
<div>
<button class="plus" v-on:click="updateNumber(+1)">加</button>
<button class="minus" v-on:click="updateNumber(-1)">减</button>
<button class="zero" v-on:click="clear">清</button>
</div>
</template>

<script>
export default {
props: {
targetData: Object,
clear: Function
},
methods: {
updateNumber: function(n) {
this.targetData.num += n;
}
}
}
</script>


// NumberDisplay.vue
<template>
<div>
<p>{{somedata.num}}</p>
<NumStepper :targetData="somedata" :clear="clear" />
</div>
</template>

<script>
import NumStepper from "./NumStepper"

export default {
components: {
NumStepper
},
data() {
return {
somedata: {
num: 999
},
tgt: this
}
},
methods: {
clear: function() {
this.somedata.num = 0;
}
}
}
</script>

NumStepper.vue 测试起来非常复杂,因为它关联了外部组件的实现细节。

测试场景中需要一个额外的 NumberDisplay.vue 组件,用来重现外部组件、向目标组件传递数据和方法,并检验目标组件是否正确修改了外部组件的状态。

不难想象,假如 NumberDisplay.vue组件再依赖其他组件或环境变量、全局方法等,事情将变得更糟糕,可能需要单独实现若干测试专用组件,甚至根本无法测试。

测试vue3组件

使用Composition API测试组件构建应该与测试标准组件没有什么不同,因为我们不是在测试实现,而是在测试输出(该组件做什么,而不是它如何执行)。 展示一个使用Vue 2中的Composition 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
<template>
<div>
<div class="message">{{ uppercasedMessage }}</div>
<div class="count">
Count: {{ state.count }}
</div>
<button @click="increment">Increment</button>
</div>
</template>

<script>
import Vue from 'vue'
import VueCompositionApi from '@vue/composition-api'

Vue.use(VueCompositionApi)

import {
reactive,
computed
} from '@vue/composition-api'

export default {
name: 'CompositionApi',

props: {
message: {
type: String
}
},

setup(props) {
const state = reactive({
count: 0
})

const increment = () => {
state.count += 1
}

return {
state,
increment,
uppercasedMessage: computed(() => props.message.toUpperCase())
}
}
}
</script>
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
import { mount } from "@vue/test-utils"

import CompositionApi from "@/components/CompositionApi.vue"

describe("CompositionApi", () => {
it("renders a message", () => {
const wrapper = mount(CompositionApi, {
propsData: {
message: "Testing the composition API"
}
})

expect(wrapper.find(".message").text()).toBe("TESTING THE COMPOSITION API")
})

it("increments a count when button is clicked", async () => {
const wrapper = mount(CompositionApi, {
propsData: { message: '' }
})

wrapper.find('button').trigger('click')
await wrapper.vm.$nextTick()

expect(wrapper.find(".count").text()).toBe("Count: 1")
})
})

输入props,输出html。

点击按钮, html count变化。

如果只断言 组件的最终状态,不去测试具体实现。那么vue2的测试对vue3也是可以直接用的。

Reference

Vue 组件的单元测试 官方文档

Testing Vue.js Applications vue-utils的开发者写的书

Vue Testing Handbook

-------------本文结束 感谢您的阅读-------------