为什么需要测试
测试是完善的研发体系中不可或缺的一环。前端同样需要测试,你的 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
- Karmai 是一个基于 Node.js 的 Javascript 测试执行过程管理工具( Test Runner)。该工具在 Vue 中的主要作用是将项目运行在各种主流 Web 浏览器进行测试。
- 换句话说,它是一个测试工具,能让你的代码在浏览器环境下测试。需要它的原因在于,你的代码可能是设计在浏览器端执行的,在 node 环境下测试可能有些 bug 暴露不出来另外,浏览器有兼容问题, karma 提供了手段让你的代码自动在多个浏览器( chrome, firefox,ie 等)环境下运行。如果你的代码只会运行在 node 端,那么你不需要用 karma。
Mocha
- Mocha(发音摩卡)是一个测试框架,在 vue-ci 中配合 Mocha.本身不带断言库,所以必须先引入断言库,Chai 断言库实现单元测试。
- 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); expect(4 + 5).to.be.not.equal(10); expect({ bar: "baz" }).to.be.deep.equal({ bar: "baz" });
expect(true).to.be.ok; expect(false).to.not.be.ok;
expect('test').to be.a('string'); expect({ foo: bar }).to.be.an('object');
expect([1, 2, 3]).to.include(2);
|
vue 单元测试的三个步骤
Vue 组件单元测试的一般思路
渲染这个组件,然后断言这些标签是否匹配组件的状态
- 安排(Arrange):为测试做好设置。在我们的用例中,是渲染了组件
- 行动(Act):对系统执行操作
- 断言(Assert):确保真实的结果匹配你的期望,我们需要断言以确保组件运行正确(断言就是比较,判断正不正确,1+1 是不是等于 2,就是一个最简单的断言)
预装环境:
在 Node 环境下安装 vue-cli
通过 vue-cli 初始化项目文件,命令如下
1
| vue init webpack [projectName]
|
可以运行初始化的测试
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
| const path = require("path");
module.exports = { rootDir: path.resolve(__dirname, "../../"), moduleFileExtensions: [ "js", "json", "vue" ], moduleNameMapper: { "^@/(.*)$": "<rootDir>/src/$1" }, transform: { "^.+\\.js$": "<rootDir>/node_modules/babel-jest", ".*\\.(vue)$": "<rootDir>/node_modules/vue-jest" }, setupFiles: ["<rootDir>/test/unit/setup"], coverageDirectory: "<rootDir>/test/unit/coverage", collectCoverageFrom: [ "src/**/*.{js,vue}", "!src/main.js", "!src/router/index.js", "!**/node_modules/**" ] };
|
1 2 3 4
| 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 的方法有:
- setData
- setValue
- setChecked
- setSelected
- setProps
- 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 = 4; y = 4; x === y;
x = "someString"; y = "someString"; x === y; 即使是空物也不相等;
x = {}; y = {}; x === y;
|
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); });
|
选择器
很多方法的参数中都包含选择器。一个选择器可以是一个 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
| <template> <div> <span @click="update">点击时间发送父组件</span> </div> </template> <script> export default { methods: { update() { this.$emit("custom"); } } }; </script>
<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
| import { mount, shallowMount } from "@vue/test-utils"; import HelloWorld from "@/components/HelloWorld.vue"; import child from "@/components/child.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" }); 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
| <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
| import { shallowMount } from "@vue/test-utils"; import SubmitButton from "@/src/components/submitButton";
const msg = "submit"; const factory = propsData => { return shallowMount(SubmitButton, { propsData: { msg, ...propsData } }); };
describe("HelloWorld测试套件", () => { it("测试查看功能", () => { 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
| <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
| 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
| import { shallowMount } from "@vue/test-utils"; import UserInfo from "@/components/user-info"; import UserApi from "@/apis/user.js";
jest.mock("@/apis/user");
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
| <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
| 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(() => { 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
| <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
| 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); }); });
|