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

请注意上面我们 mock 了 Vuex 的 store 的数据,如果不 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 的运用是在本组件中的,并不能影响第三方库中的取值方法,所以我需要需要使和到 Jest 的 setupFiles 配置 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 就是在每个用例测试之前都会执行的文件内容。

提示

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

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

await wrapper.vm.$nextTick();

JSDOM 的 IntersectionObserver

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

1
ReferenceError: IntersectionObserver is not defined

这个错误表明在你的测试环境中缺少 IntersectionObserver,这通常是因为 JSDOM 环境不支持 IntersectionObserver。IntersectionObserver 是用来监听元素与视口交叉信息的 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 提供了 mount 和 shallowMount 两个方法用于挂载 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, "")
);
});

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