这篇文章主要是说我们如何在 vue2
的环境下,使用 jest
配合 @vue/test-utils
来进行单元测试的内容。
创建项目
首先,我们使用 vue
的创建一个项目,在后面的选择单元测试功能的步骤时选择使用 jest
进行单元测试。
创建完成后,会有一些关键的依赖:
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 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";
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";
import flushPromises from "flush-promises";
import * as ApiCommon from "../../packages/api/common"; jest.mock("../../packages/api/common");
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 () => { await flushPromises(); expect( wrapper.findAll(".el-table__body-wrapper tbody > tr").length ).toBe(2);
}); });
|
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
| 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
中,然后在测试用例文件中引用:
当然我们也可以把 intersection-observer
的内容也放到这个 main.js
文件中。
mount 与 shallowMount
在使用 @vue/test-utils
的过程中,我们需要挂载组件 @vue/test-utils
提供了 mount
和 shallowMount
两个方法用于挂载 Vue
组件进行测试,它们之间的主要区别在于挂载的深度和性能方面。
mount
方法会挂载整个组件树,包括所有子组件,模拟了真实的渲染环境。这意味着 mount
会渲染组件及其所有子组件,可以用于测试组件及其子组件之间的交互。由于渲染了整个组件树,mount
的性能比较低,适合测试较复杂的组件。
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, "") ); });
|
更多文档可参考官方网站: