vue单元测试

为什么需要测试

测试是完善的研发体系中不可或缺的一环。前端同样需要测试,你的 CSS 改动可能导致页面错位、JS 改动可能导致功能不正常。尽管测试领域工具层出不穷,在前端的自动化测试上面却实施并不广泛,但是前端偏向界面所以还是有很多人依旧以手工测试为主。

vue 中的单元测试

端到端测试(E2E)

E2E 或者端到端(End-To-End)或者 UI 测试是一种测试方法,它用来测试一个应用从头到尾的流程是否和设计时候所想的一样。简而言之,它从一个用户的角度出发,认为整个系统都是一个黑箱,只有 UI 会暴露给用户。

单元测试

测试驱动开发(TDD:Test- Driven Development),单元测试就不陌生。
单元测试是用来对一个模块、一个函数或者一个类来进行正确性检验的测试工作。
比如写个加法函数 add(a, b){ return a + b},我们可以编写出以下几个

1
2
3
测试用例如
输入 1 和 1,期待返回结果是 2;
输入非数值类型,比如 None、[]、0,期待抛出异常。

把上面的测试用例放到一个测试模块里,就是一个完整的单元测试。
如果单元测试通过,说明我们测试的这个函数能够正常工作。如果单元测试不通过,要么函数有 bug,要么测试条件输入不正确,总之,需要修复使单元测试能够通过。
那在 Vue 中的单元测试中主要使用两个工具分别是( Karma+ Mocha)

自动化测试框架

Jest、Mocha、Jasmine、sinon、chai、Karma、vue-test-utils 都是什么?

名词类型名词
Jest测试框架Jest 由 Facebook 开发,用于测试 JavaScript 代码(尤其是使用 React JS 开发的应用程序集成了断言、JSDom、覆盖率报告等,是一款几乎零配置的测试框架
提供 snapshot 功能
Mocha测试框架基于 JavaScript 的自动化测试框架,用于测试使用 Node.js 运行的应用程序
比较年老的测试框架,在 JavaScript 界使用更广泛,可参考文献更多
Jasmine测试框架主要用于异步测试,是一个功能丰富的 JavaScript 自动化测试框架
Jasmine 需要很多配置
sinon测试框架用于 JavaScript 的测试监视(spy)、桩(stub)和仿制(mock)功能。不依赖其他类库,兼容任何单元测试框架
chai断言库一套 TDD(测试驱动开发)/BDD(行为驱动开发)的断言库
expect/should 库
Karma运行器不是测试框架,也不是断言库,是允许你的 JavaScript 代码在复杂的浏览器运行的工具(抹平浏览器障碍)
vue-test-utils单元测试工具库vue 官方提供的,专门为测试单文件组件而开发

Mocha + Chai 的了解

Karma

  1. Karmai 是一个基于 Node.js 的 Javascript 测试执行过程管理工具( Test Runner)。该工具在 Vue 中的主要作用是将项目运行在各种主流 Web 浏览器进行测试。
  2. 换句话说,它是一个测试工具,能让你的代码在浏览器环境下测试。需要它的原因在于,你的代码可能是设计在浏览器端执行的,在 node 环境下测试可能有些 bug 暴露不出来另外,浏览器有兼容问题, karma 提供了手段让你的代码自动在多个浏览器( chrome, firefox,ie 等)环境下运行。如果你的代码只会运行在 node 端,那么你不需要用 karma。

Mocha

  1. Mocha(发音摩卡)是一个测试框架,在 vue-ci 中配合 Mocha.本身不带断言库,所以必须先引入断言库,Chai 断言库实现单元测试。
  2. Mochas 的常用命令和用法不算太多,而 Chai.js 断言库 API 中文文档,很简单,多查多用就能很快掌握。

断言库

所谓”断言”,就是判断源码的实际执行结果与预期结果是否一致,如果不ー致就抛出一个错误。下面这句断言的意思是调用 add(1,1),结果应该等于 2。

1
2
var expect = require(chai).expect;
expect(1 + 1).toBe(2);

Chai 是一种断言库(http://chaijs.com/)
所有的测试用例(it 块)都应该含有一句或多句的断言。它是编写测试用例的关键。断言功能由断言库来实现。

Chai 的基本使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
expect(4 + 5).to.be.equal(9); // 判断4+5等于9 true
expect(4 + 5).to.be.not.equal(10); // 判断4+5不等于10 true
expect({ bar: "baz" }).to.be.deep.equal({ bar: "baz" }); // 深度判断是否相等

expect(true).to.be.ok; // 判断是true
expect(false).to.not.be.ok; // 判断是 false

// 判断类型
expect('test').to be.a('string');
expect({ foo: bar }).to.be.an('object');

//判断是否包含
expect([1, 2, 3]).to.include(2);

vue 单元测试的三个步骤

Vue 组件单元测试的一般思路

渲染这个组件,然后断言这些标签是否匹配组件的状态

  1. 安排(Arrange):为测试做好设置。在我们的用例中,是渲染了组件
  2. 行动(Act):对系统执行操作
  3. 断言(Assert):确保真实的结果匹配你的期望,我们需要断言以确保组件运行正确(断言就是比较,判断正不正确,1+1 是不是等于 2,就是一个最简单的断言)

预装环境:

在 Node 环境下安装 vue-cli

1
npm install-g vue-cli

通过 vue-cli 初始化项目文件,命令如下

1
vue init webpack [projectName]

可以运行初始化的测试

1
npm run unit

Vue 脚手架已经初始化了一个 Helloworld.spec.js 的测试文件去测试 Hellowrold.vue,你可以在 test/unit/specs/Helloworld.spec.js 下找到这个测试文件(提示:将来所有的测试文件,都将放 specs 这个目录下,并以测试脚本名 .spec.js 结尾命名!)

安装 Vue. js 官方的单元测式实用工具库(方便在 node 环境下操作 Vue)

1
npm install --save-dev @vue/test-utils

添加 jest.conf.js 和 setup.js 文件

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
// src/tests/unit/jest.conf.js
const path = require("path");

module.exports = {
rootDir: path.resolve(__dirname, "../../"), // 同 webpack.context
moduleFileExtensions: [
// 同 webpack.resolve.extensions
"js",
"json",
"vue"
],
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1" // 同 webpack.resolve.alias
},
transform: {
// 同 webpack.module.rules
"^.+\\.js$": "<rootDir>/node_modules/babel-jest",
".*\\.(vue)$": "<rootDir>/node_modules/vue-jest"
},
setupFiles: ["<rootDir>/test/unit/setup"], // 同 webpack.entry
coverageDirectory: "<rootDir>/test/unit/coverage", // 同 webpack.output
collectCoverageFrom: [
// 同 webpack 的 rule.include
"src/**/*.{js,vue}",
"!src/main.js",
"!src/router/index.js",
"!**/node_modules/**"
]
};
1
2
3
4
// src/tests/unit/setup.js
import Vue from "vue";

Vue.config.productionTip = false;

关于单元测试

mount 和 shallowMount

对于包含许多子组件的组件来说,整个渲染树可能会非常大。重复渲染所有的子组件可能会让我们的测试变慢。
Vue Test Utils 允许你通过 shallowMount 方法只挂载一个组件而不渲染其子组件 (即保留它们的存根)

createLocalVue

createLocalVue 返回一个 Vue 的类,供你添加组件、混入和安装插件而不会污染全局的 Vue 类。
可通过 options.localVue 来使用

1
2
3
4
5
6
7
8
9
10
11
12
import { createLocalVue, shallowMount } from "@vue/test-utils";
import Foo from "./Foo.vue";

const localVue = createLocalVue();
const wrapper = shallowMount(Foo, {
localVue,
mocks: { foo: true }
});
expect(wrapper.vm.foo).toBe(true);

const freshWrapper = shallowMount(Foo);
expect(freshWrapper.vm.foo).toBe(false);

await

Vue 会异步的将未生效的 DOM 批量更新,避免因数据反复变化而导致不必要的渲染。在实践中,这意味着变更一个响应式 property 之后,为了断言这个变化,你的测试需要等待 Vue 完成更新。其中一种办法是使用 await Vue.nextTick(),一个更简单且清晰的方式则是 await 那个你变更状态的方法

可以被 await 的方法有:

  1. setData
  2. setValue
  3. setChecked
  4. setSelected
  5. setProps
  6. trigger
1
2
3
4
await wrapper.find("input").trigger("keydown", { key: "a" });
// ==> 等价于
wrapper.find("input").trigger("keydown", { key: "a" });
await Vue.nextTick();

toEqual 和 toBe

toBe()toEqual()toEqual() 检查等价 . 另一方面, toBe() 确保它们是完全相同的对象 .

我会说比较值时使用 toBe() ,比较对象时使用 toEqual() .

比较基元类型时, toEqual()toBe() 将产生相同的结果 . 比较对象时, toBe() 是一个更严格的比较,如果它不是内存中完全相同的对象,则返回 false . 因此,除非您想确保它与内存中的完全相同,否则请使用 toEqual() 来比较对象 .

现在,当查看数字时 toBe()toEqual() 之间的区别时,只要您的比较正确,就不会有任何差别 . 5 将始终相当于 5 .

1
2
3
4
5
6
7
8
9
10
11
12
13
// 它失败原因x和y是不同的实例,不等于(x === y)=== false。您可以使用toBe表示字符串,数字或布尔值等原语,其他所有内容都使用toEqual。例如:
x = 4;
y = 4;
x === y; // true

x = "someString";
y = "someString";
x === y; // true
即使是空物也不相等;

x = {};
y = {};
x === y; //false
1
2
3
4
5
6
7
test("toEqual和toBe", async () => {
const x = { a: { b: 3 } };
const y = { a: { b: 3 } };

expect(x).toEqual(y);
// expect(x).toBe(y);
});

选择器

很多方法的参数中都包含选择器。一个选择器可以是一个 CSS 选择器、一个 Vue 组件或是一个查找选项对象。

标签选择器 (div、foo、bar)
类选择器 (.foo、.bar)
特性选择器 ([foo]、[foo=“bar”])
id 选择器 (#foo、#bar)
伪选择器 (div:first-of-type)
近邻兄弟选择器 (div + .foo)
一般兄弟选择器 (div ~ .foo)

1
2
const buttonr = wrapper.find(".button");
const content = wrapper.find("#content");

查找选项对象

Name:可以根据一个组件的 name 选择元素。

1
wrapper.find({ name: ‘my-button’ })

Ref:可以根据$ref 选择元素。

1
wrapper.find({ ref: "myButton" });

findAll 返回的是一个数组,在选择有多个元素的情况下是不可以使用 find 的,在使用 findAll 后需要使用 at()来选择具体序列的元素。

在得到了我们的 DOM 元素之后我们就可以很方便地对属性以及内容进行断言判断。
这里提一句,有关于样式的测试我更偏向于在 E2E 测试中去断言而不是在单元测试,这显得会更为直观,当然在单元测试中也提供了抓取 class 的 API。
有关于 DOM 的 API 列出了以下几个

  • attributes: 属性
  • classes:wrapper.classes()返回一个字符串数组,wrapper.classes(‘bar’)返回一个布尔值
  • contains:返回包含元素或组件匹配选择器
  • html: 以字符串形式返回 DOM 节点的 HTML

示例

describe 块 称为“测试套件”,表示一组相关的测试。
它是一个函数,第一个参数是测试套件的名称
第二个参数是实际执行的函数,分组让测试用例代码结构化,易于维护

1
2
3
4
5
import { mount, shallowMount } from "@vue/test-utils";
describe("vue 单元测试", () => {
const wrapper = shallowMount(HelloWorld);
// ...
});

单元测试基本操作

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
<!-- src/components/child.vue -->
<template>
<div>
<span @click="update">点击时间发送父组件</span>
</div>
</template>
<script>
export default {
methods: {
update() {
this.$emit("custom");
}
}
};
</script>

<!-- src/components/hello.vue -->
<template>
<div>
<child @custom="custom"></child>
<h1 v-show="isShow">{{ msg }}</h1>
<input type="text" v-model="quantity" @keydown.prevent="onKeydown" />
</div>
</template>

<script>
import child from "@/components/child";
export default {
name: "HelloWorld",
data() {
return {
msg: "Hello Jest",
isShow: false,
quantity: 0
};
},
components: {
child
},
methods: {
custom() {
this.isShow = true;
},
onKeydown(e) {
if (e.key === "a") {
this.quantity = 13;
}
}
}
};
</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
// src/tests/unit/test.spec.js
import { mount, shallowMount } from "@vue/test-utils";
import HelloWorld from "@/components/HelloWorld.vue";
import child from "@/components/child.vue";
// import Vue from "vue";

describe("vue 单元测试", () => {
const wrapper = shallowMount(HelloWorld);

it("判断元素是否显示", async () => {
expect(wrapper.find("h1").isVisible()).toBe(false);
});

it("当子组件emit事件触发时父组件变化", () => {
wrapper.findComponent(child).vm.$emit("custom");
expect(wrapper.html()).toContain("Hello Jest");
});

it("改变data的值", () => {
wrapper.vm.msg = "我是改变的值";
expect(wrapper.vm.msg).toBe("我是改变的值");
});
it("当键盘事件触发时值改变", async () => {
await wrapper.find("input").trigger("keydown", { key: "a" });
// ==> 等价于
// wrapper.find("input").trigger("keydown", { key: "a" });
// await Vue.nextTick();
expect(wrapper.vm.quantity).toBe(13);
});
it("await 测试", async () => {
await wrapper.setData({ msg: "我是改变的值" });
expect(wrapper.find("h1").html()).toContain("我是改变的值");
});
});

测试 Props

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- src/components/submitButton.vue -->
<template>
<div>
<span v-if="isAdmin">Admin Privileges</span>
<span v-else>Not Authorized</span>
<button>{{ msg }}</button>
</div>
</template>

<script>
export default {
name: "submitButton",
props: {
msg: { type: String, required: true },
isAdmin: { type: Boolean, default: false }
}
};
</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
// src/tests/unit/submitButton.spec.js
import { shallowMount } from "@vue/test-utils";
import SubmitButton from "@/src/components/submitButton";

const msg = "submit";
const factory = propsData => {
return shallowMount(SubmitButton, {
//测试从父组件中接受属性(props)的组件
propsData: { msg, ...propsData }
});
};

describe("HelloWorld测试套件", () => {
// 测试查看功能的用例
it("测试查看功能", () => {
// 通过mount将组件渲染出来
const wrapper = mount(HelloWorld);
console.log(wrapper.find(".header"));
});
});

describe("没有管理权限", () => {
it("呈现一条消息", () => {
const wrapper = factory();

expect(wrapper.find("span").text()).toBe("Not Authorized");
expect(wrapper.find("button").text()).toBe("submit");
});
});

describe("有管理权限", () => {
it("呈现一条消息", () => {
const wrapper = factory({ isAdmin: true });

expect(wrapper.find("span").text()).toBe("Admin Privileges");
expect(wrapper.find("button").text()).toBe("submit");
});
});

测试请求

组件发起了 API 请求,我只想知道它发没发,不想让它真实发出去。
有一个组件在会在 created 时候发起一个 http 请求

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
<!-- src/components/user-info.vue -->
<template>
<div class="user-info">
<div class="name">{{user.name}}</div>
<div id="desc">{{user.desc}}</div>
</div>
</template>

<script>
import UserApi from "@/apis/user";
export default {
name: "UserInfo",
data() {
return {
user: {}
};
},
created() {
UserApi.getUserInfo().then(user => {
this.user = user;
});
UserApi.getUserInfo();
}
};
</script>

API 接口如下

1
2
3
4
5
6
7
8
// apis/users.js
function getUserInfo() {
return $http.get("/user");
}

export default {
getUserInfo
};

对该组件进行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/tests/unit/user-info.spec.js
import { shallowMount } from "@vue/test-utils";
import UserInfo from "@/components/user-info";
import UserApi from "@/apis/user.js";

// mock 掉 user 模块
jest.mock("@/apis/user");

// 指定 getUserInfo 方法返回假数据
UserApi.getUserInfo.mockResolvedValue({
name: "olive",
desc: "software engineer"
});

describe("<user-info/>", () => {
const wrapper = shallowMount(UserInfo);
it("getUserInfo调用次数", () => {
expect(UserApi.getUserInfo.mock.calls.length).toBe(2);
});
it("用户信息渲染正确", () => {
expect(wrapper.find(".name").text()).toEqual("olive");
expect(wrapper.find("#desc").text()).toEqual("software engineer");
});
});

测试 vuex

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- src/components/vuex-action.vue -->
<template>
<div class="text-align-center">
<input type="text" @input="actionInputIfTrue" />
<button @click="actionClick()">Click</button>
</div>
</template>

<script>
import { mapActions } from "vuex";

export default {
methods: {
...mapActions(["actionClick"]),
actionInputIfTrue: function actionInputIfTrue(event) {
const inputValue = event.target.value;
if (inputValue === "input") {
this.$store.dispatch("actionInput", { inputValue });
}
}
}
};
</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
// src/tests/unit/vuex-action.spec.js
import { shallowMount, createLocalVue } from "@vue/test-utils";
import Vuex from "vuex";
import Actions from "@/components/vuex-action.vue";

const localVue = createLocalVue();

localVue.use(Vuex);

describe("vue-action", () => {
let actions;
let store;

beforeEach(() => {
// 伪造 Action
actions = {
actionClick: jest.fn(),
actionInput: jest.fn()
};
store = new Vuex.Store({
state: {},
actions
});
});

it("当输入事件值为“input”时分派“actionInput”", () => {
const wrapper = shallowMount(Actions, { store, localVue });
const input = wrapper.find("input");
input.element.value = "input";
input.trigger("input");
expect(actions.actionInput).toHaveBeenCalled();
});

it("当事件值不是“input”时,不分派“actionInput”", () => {
const wrapper = shallowMount(Actions, { store, localVue });
const input = wrapper.find("input");
input.element.value = "not input";
input.trigger("input");
expect(actions.actionInput).not.toHaveBeenCalled();
});

it("当按钮被点击时,调用存储操作“actionClick”", () => {
const wrapper = shallowMount(Actions, { store, localVue });
wrapper.find("button").trigger("click");
expect(actions.actionClick).toHaveBeenCalled();
});
});

比较测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- src/components/list.vue -->
<template>
<div>
<h1>My To Do List</h1>
<ul>
<li v-for="item in listItems" :key="item">{{ item }}</li>
</ul>
</div>
</template>
<script>
export default {
name: "list",
data() {
return {
listItems: ["buy food", "play games", "sleep"]
};
}
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/tests/unit/list.spec.js
import { mount } from "@vue/test-utils";
import List from "@/components/list";

describe("列表", () => {
const list = mount(List);
it("列表是否正常显示包含关系", () => {
expect(list.html()).toContain("play games");
});
it("判断列表有几条", () => {
expect(list.vm.listItems.length).toBe(3);
});
it("判断大小关系", () => {
expect(list.vm.listItems.length).toBeGreaterThan(1);
});
});