vue2 使用 jest+@vue/test-utils 单元测试用例方法

这篇文章主要是说我们如何在 vue2 的环境下,使用 jest 配合 @vue/test-utils 来进行单元测试的内容。

创建项目

首先,我们使用 vue 的创建一个项目,在后面的选择单元测试功能的步骤时选择使用 jest 进行单元测试。

1
vue create demo

创建完成后,会有一些关键的依赖:

1
2
3
4
"@vue/cli-plugin-unit-jest": "~5.0.8",
"@vue/vue2-jest": "^27.0.0",
"babel-jest": "^27.5.1",
"jest": "^27.5.1",

为了配合我们的功能测试,例如 mock 接口数据、保证所有的 promise 都被处理完、浏览器的一内置函数使用等功能,需我们安装另外的库

安装 flush-promises,保证所有的 promise 都被处理完。

1
npm i flush-promises -D

测试用例

我们可以编写下面一个测试用例:

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
import { mount } from "@vue/test-utils";

// 因为在被测试组件中,使用了Vuex的功能,需要模拟其功能,避免在运行时报错
jest.mock("../../packages/store", () => {
const mockState = {
priv: { license: { MAXSESSION: 5 } },
};
const mockStore = { state: mockState, commit: jest.fn() };
return mockStore;
});

// 引入被测试的组件
import ProtocolProxyPolicy from "../../packages/views/policy.vue";

// 引入等待所有promise处理完的库
import flushPromises from "flush-promises";

// 引入定义的接口文件,同时进行模拟拦截
import * as ApiCommon from "../../packages/api/common";
jest.mock("../../packages/api/common");
// 自定义mock接口数据
ApiCommon.show.mockImplementation(() => {
return Promise.resolve({
total: 12,
rows: [
{ protocol: "HTTP", task_id: 1 },
{ protocol: "HTTP", task_id: 2 },
],
result: true,
});
});

describe("访问控制策略列表测试", () => {
// 加载组件
const wrapper = mount(ProtocolProxyPolicy, {
propsData: {
group: "web",
},
});
it("验证是否正常渲染策略组", async () => {
// 等待所有的promise执行完
await flushPromises();
expect(
wrapper.findAll(".el-table__body-wrapper tbody > tr").length
).toBe(2);

// 或其他测试角度
//expect(wrapper.vm.$data.tableData.length).toBe(4)
});
});

Vuex 的 mock

请注意上面我们 mockVuexstore 的数据,如果不 mock 的话,在我们的被测试组件中有如下代码:

1
2
import store from "@/store";
const maxSession = parseInt(store.state.priv.license["MAXSESSION"] ?? 1);

如果不 mock 的话,store.state.priv 一直不存在的。

如果被测试的组件,包含了第三方库,而第三方库中,会根据从 window.sessionStorage 中获取到的值进行渲染,这里我们进行测试组件时,需要预置 window.sessionStorage 对象值。

这里并不能像上面的那样去 mock Vuex,因为上面的 Vuex 的运用是在本组件中的,并不能影响第三方库中的取值方法,所以我需要需要使和到 JestsetupFiles 配置 setupFiles

该配置选项允许你在测试运行之前执行一个或多个模块,这些模块通常用于全局设置和初始化操作,比如这里我们可以统一设置 window 的一些操作对象

文件: /test/global.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 定义全局sessionStorage对象
Object.defineProperty(window, "sessionStorage", {
value: {
store: {},
setItem(key, value) {
this.store[key] = value;
},
getItem(key) {
return this.store[key];
},
removeItem(key) {
delete this.store[key];
},
getAll() {
return this.store;
},
clear() {
this.store = {};
},
},
configurable: true,
});
window.sessionStorage.setItem("priv", '{"proto_proxy":"rw"}');

然后在 package.json 中配置

1
2
3
4
5
6
7
8
9
10
11
12
13
"jest": {
"preset": "@vue/cli-plugin-unit-jest",
"moduleNameMapper": {
"^@tests/(.*)$": "<rootDir>/tests/$1",
"^@packages/(.*)$": "<rootDir>/packages/$1"
},
"transformIgnorePatterns": [
"/node_modules[/\\\\]/"
],
"setupFiles": [
"<rootDir>/tests/global.js"
]
},

其中 setupFiles 就是在每个用例测试之前都会执行的文件内容。

提示

如果要对组件中的 inputcheckbox 点击后进行测试,建议不要使用 wrapper.trigger('click'),有时候并不能生效,可以使用:

wrapper.find('input[type="checkbox"]').setChecked(true);

await wrapper.vm.$nextTick();

JSDOM 的 IntersectionObserver

这时如果我们执行单元测试用例,会报错:

1
ReferenceError: IntersectionObserver is not defined

这个错误表明在你的测试环境中缺少 IntersectionObserver,这通常是因为 JSDOM 环境不支持 IntersectionObserverIntersectionObserver 是用来监听元素与视口交叉信息的 API,在浏览器中提供支持,但在 Node.js 环境中并不原生支持。

为了解决这个问题,你可以模拟 IntersectionObserver,或者使用第三方库来模拟它,以便在测试环境中使用。一个常见的解决方案是使用 intersection-observer 这个 polyfill

你可以按照以下步骤来解决这个问题:

安装 intersection-observer

1
npm install intersection-observer

在你的测试文件顶部引入 polyfill,并在 beforeAll 中全局设置:

1
2
3
4
5
6
7
8
9
import "intersection-observer";

beforeAll(() => {
window.IntersectionObserver = jest.fn(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));
});

统一引入

这样就基本可以满足测试需要了,但我们还要注意的一点时,在测试用例的开头,需要统一引入我们的常在 main.js 中定义的内容,如:

1
2
3
4
5
6
import Vue from "vue";
import ElementUI from "element-ui";
Vue.use(ElementUI, { size: "small" });

import ProtocolProxyPolicy from "../packages";
Vue.use(ProtocolProxyPolicy);

我们可以将其定义在 /test/main.js 中,然后在测试用例文件中引用:

1
require("../main.js");

当然我们也可以把 intersection-observer 的内容也放到这个 main.js 文件中。

mount 与 shallowMount

在使用 @vue/test-utils 的过程中,我们需要挂载组件 @vue/test-utils 提供了 mountshallowMount 两个方法用于挂载 Vue 组件进行测试,它们之间的主要区别在于挂载的深度和性能方面。

  • mount:

mount 方法会挂载整个组件树,包括所有子组件,模拟了真实的渲染环境。这意味着 mount 会渲染组件及其所有子组件,可以用于测试组件及其子组件之间的交互。由于渲染了整个组件树,mount 的性能比较低,适合测试较复杂的组件。

  • shallowMount:

shallowMount 方法只挂载了被测试组件本身,不会渲染其子组件。这意味着 shallowMount 只测试被挂载组件本身的行为,而不涉及其子组件。由于不渲染子组件,shallowMount 的性能比较高,适合测试简单的组件或者只关注被测试组件自身逻辑的情况。

选择使用 mount 还是 shallowMount 取决于你的测试需求。如果需要测试整个组件树的交互和行为,可以选择 mount

如果只需要测试被挂载组件本身的行为,可以选择 shallowMount。通常建议优先使用 shallowMount,因为它能提供更快的测试速度,除非你需要测试整个组件树的交互。

Dom 挂载

当被测试组件中有使用 document 对象的内容时,如果不挂载到 document 中,就会出现不能获取 document 的变化的情况,仅用 wrapper 只能获取到当前组件的 html,当需要获取在组件外的 html 时,我们就需要支持 document 的获取。

我们可以像这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
describe("客户端策略列表测试", () => {
let wrapper;

beforeEach(() => {
wrapper = mount(ProtocolProxyPolicy, {
propsData: {
group: "file",
showTab: ["client"],
},
// 需要加上这个配置
attachTo: document.body,
});
});

afterEach(() => {
wrapper.destroy();
});
});

额外的工具库

当我们使用 wrapper,进行查找元素时,会有大量的重复、语义不太友好的代码存在,我们可以封装类似于像 jquery 一样的便捷 DOM 查询工具,来帮助我们简化单元测试用例。

例如下面就是两个针对表格和表单的工具类,更多逻辑可以自已封装。

form.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
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
68
class form {
constructor(wObj) {
this.wObj = wObj;
}

wrapper() {
return this.wObj;
}

in(index) {
this.wObj = this.wObj.at(index);
return this;
}

input() {
return {
value: (value) => {
const getVal =
this.wObj.find('input[type="text"]').element.value;
return value !== undefined ? getVal === String(value) : getVal;
},
disabled: () => {
return (
this.wObj
.find('input[type="text"]')
.attributes("disabled") === "disabled"
);
},
};
}

radio() {
return {
checked: (key) => {
return (
this.wObj
.findAll("label.el-radio")
.at(key)
.classes("is-checked") === true
);
},
disabled: (key) => {
return (
this.wObj
.findAll("label.el-radio")
.at(key)
.classes("is-disabled") === true
);
},
};
}

switch() {
return {
open: () => {
return (
this.wObj.find("div.el-switch").classes("is-checked") ===
true
);
},
};
}
}

export const $f = (wObj) => {
const obj = new form(wObj);
return obj;
};

像这样使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { $f } from "../class/form";

it("验证基本信息的回显", async () => {
await flushPromises();

const fDom = wrapper
.findAll(".policy-edit-form form.el-form")
.at(0)
.findAll(".el-form-item");

const data = listDataObj.data[0];

const protocolType = $f(fDom).in(0).input().value(data.protocol);
const taskId = $f(fDom).in(1).input().value(data.task_id);
const name = $f(fDom).in(3).input().value(data.name);
const desc = $f(fDom).in(4).input().value(data.description);
const family = $f(fDom).in(5).radio().checked(0);
const status = $f(fDom).in(6).switch().open();
expect(protocolType && taskId && name && desc && family && status).toBe(
true
);
});

table.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
class table {
constructor(wObj) {
this.wObj = wObj;
}

wrapper() {
return this.wObj;
}

tr(index) {
this.wObj = this.wObj.findAll("tr").at(index);
return this;
}

td(index) {
this.wObj = this.wObj.findAll("td").at(index);
return this;
}
}

export const $t = (wObj) => {
const obj = new table(wObj);
return obj;
};

像这样使用

1
2
3
4
5
6
7
8
9
import { $t } from "../class/table";

it("验证日志开关正常回显", async () => {
await flushPromises();
const tDom = wrapper.find(".el-table__body-wrapper tbody");
expect($t(tDom).tr(0).td(15).wrapper().text().replace(/\s+/g, "")).toBe(
"日志:已开启".replace(/\s+/g, "")
);
});

更多文档可参考官方网站: