这篇文章主要是说我们如何在 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, "")     ); });
  | 
 
更多文档可参考官方网站: