Vue3 jest mock 篇

前言

为了保证项目的代码高质量发布,一般都是需要写单元测试的。而单测的达标,涉及到行覆盖率、分支覆盖率、函数覆盖率等关键指标。然后我发现,很多时候测试 vue 组件中函数的正确性不难,而 mock 组件中使用的第三方库比较蛋疼。因为不 mock 的话,它会导致报错,使单测执行失败,同时也会影响函数和行覆盖率。下面是在使用 jest mock 的一般思路。

项目中使用的组件库、vuex 和 vue-router 的 mock

vant 全局函数的 mock

项目是移动端的,所以使用的组件库是 vant。而在 vue 组件 methods 中使用的 vant 的全局函数主要有两个:toast 和 dialog。

$toast 的 mock

使用 toast 的方法

const message = 'message';
this.$toast(message);
this.$toast.loading({ message });
this.$toast.fail({ message });
this.$toast.success({ message });
this.$toast.clear();
this.$toast.open(); // 使用 toast 时内部会进行调用的方法
复制代码

观察不难发现,我们 mock 的 $toast 需要满足两个条件:
(1)可以当作函数进行调用
(2)可以指定函数上特定的属性当作函数进行调用

那么 mock 的函数也是很明了了

function MockToast() {};
MockToast.loading = jest.fn();
MockToast.open = jest.fn();
MockToast.clear = jest.fn();
MockToast.fail = jest.fn();
MockToast.success = jest.fn();
复制代码

$dialog 的 mock

使用 dialog 的方法

const title = 'title';
const message = 'message';
this.$dialog({ title, message });
this.$dialog.alert({ title, message })
  .then(() => {})
  .catch(() => {});
this.$dialog.confirm({ title, message })
  .then(() => {})
  .catch(() => {});
复制代码

mock 的 $dialog 需要满足两个条件:
(1)可以当作函数进行调用
(2)可以指定函数上特定的属性当作函数进行调用,返回的是一个 Promise

实现:

function MockDialog() {};
MockDialog.comfirm = jest.fn(() => Promise.resolve());
MockDialog.alert = jest.fn(() => Promise.resolve());
复制代码

如果觉得麻烦也可以挂载 comfirm 和 alert 到 MockToast 上,两个公用一个 mock 函数。

使用

把 mock 函数在 shallowMount 或者 mount 时的 global 的 mocks 属性上即可。

const wrapper = shallowMount(Component, {
  props: {},
  global: {
    plugins: [Vant],
    mocks: {
      $toast: MockToast,
      $dialog: MockDialog,
    },
  },
});
复制代码

vue-router 的 mock

vue-router 的 mock 相对比较简单,只需要把 r o u t e r router 和 route 对应的方法和属性 mock 一下就 ok 了。同样是写在global 的 mocks 属性上。

const wrapper = shallowMount(Component, {
  props: {},
  global: {
    mocks: {
      $router: {
        push: jest.fn(),
        replace: jest.fn(),
        go: jest.fn(),
      },
      $route: {
        query: {},
      },
    },
  },
});
复制代码

vuex 的 mock

假的 $store mock

如果我们只考虑使用了 commit 和 dispatch 方法的情况下。可以直接在 global 的 mocks 的上 mock $store 。

const wrapper = shallowMount(Component, {
  global: {
    mocks: {
      $store: {
        commit: jest.fn(),
        dispatch: jest.fn(),
      },
    },
  },
});
复制代码

真的 $store 的 mock

很多时候假的 store 的 mock 是没有办法满足我们的需求的。因为会使用到 mapState 、mapGetter 获取 store 上的值。这个时候使用真实的 store 会方便很多。当然,store 的属性值可以是使用 mock 保留的自己想要的,去除和测试不相关的。

const mockModule = () => ({
  namespaced: true,
  state: {
    mockState: {},
  },
  getters: {
    mockGetter: () => true,
  },
});
const store = createStore({
  modules: {
    mockModule: mockModule(),
  },
});
const wrapper = shallowMount(Component, {
  props: {},
  global: {
    plugins: [store], // 在 plugins 上使用创建的 stroe
  },
});
复制代码

注意

上述两种 mock store 方式不能并存,shallowMount 只能选择其中一种方式进行 mock。

原生属性和第三方库的 mock

这部分将介绍 FileReader 、compressorjs 和 clipboard 的 mock。

FileReader

因为 FileReader 是浏览器环境下的全局属性,但是在 mock 环境下是没有这个属性的,使用会导致报错。同时, FileReader 的 mock 能够提高函数覆盖率。

// 使用场景 
async readFile(file) {
  return new Promise((resolve) => {
    const fileReader = new FileReader();
    fileReader.onload = (e) => {
      resolve(e.target.result);
    };

    fileReader.readAsDataURL(file);
  });
},
复制代码

如果不进行 mock ,onload 挂载的方法是没有办法执行到的。

mock 需要满足两个条件:
(1)可以当作构造器进行调用
(2)可以指定函数上特定的属性当作函数进行调用,会执行挂在上的特定函数

实现

// 这里没有写
Object.defineProperty(global, 'FileReader', {
  writable: true,
  value: jest.fn().mockImplementation(() => ({
    readAsDataURL: () => {
      this.onload && this.onload();
      this.onerror && this.onerror();
    },
  })),
});
复制代码

compressorjs

// 使用场景
import ImageCompression from 'compressorjs';

new ImageCompression(file, {
  quality: 0.5,
  success: async (result) => {
    const base64Url = await this.readFile(result);
    this.pictureUrl = base64Url;
    this.$emit('handleChange', base64Url, file);
  },
  error: (err) => {
    console.error(err.message);
  },
});
复制代码

compressorjs 是对图片进行压缩的一个库。不进行mock,success 和 error 函数是没有办法被调用的。跟 FileReader 的 mock 需要满足条件一样。

实现

jest.mock('compressorjs', () => function (_, config = {}) {
  const { success, error } = config;
  success && success();
  error && error({ message: '' });
});
复制代码

Clipboard

// 使用场景
import Clipboard from 'clipboard';

handleCopy(str = '') {
  const btn = document.createElement('button');
  const id = `copy-btn-${Date.now()}`;
  const removeBtn = () => {
    document.body.removeChild(btn);
  };
  btn.id = id;
  btn.style = 'position: absolute; top: -100px;';
  document.body.appendChild(btn);
  const clipboard = new Clipboard(`#${id}`, {
    text: () => str,
  });
  clipboard.on('success', () => {
    this.$toast.success('复制成功');
    removeBtn();
  });
  clipboard.on('error', (e) => {
    console.error(e);
    this.$toast('复制失败');
    removeBtn();
  });
  document.getElementById(id).click();
}
复制代码

这个方法会自己创建一个 div 实现 Clipboard 的复制功能。不进行 mock 的话,success 和 error 的回调函数都是不会被执行的。

mock 需要满足两个条件:
(1)可以当作构造器进行调用
(2)on 注册回调时,会执行回调函数,以此增加函数覆盖率。

实现

jest.mock('clipboard', () => {
  const res = function (_, obj) {
    obj.text();
    return {
      on: (_, fn) => {
        fn();
      },
    };
  };
  return  res;
});
复制代码

上面的方法也调用了很多 document 上的方法,这些可能在 mock 环境下是不存在的,报错同样又可能导致单测用例执行失败。我们同样可以对其进行 mock 。

document.body.removeChild = jest.fn();
document.getElementById = () => ({
  addEventListener: (_, fn) => {
    fn.call({ 
      // 自定义需要传入的数据 
    });
  },
  click: jest.fn(),
});
复制代码

请求库

这里的请求库可以是第三方的(如 axios)也可以是自己封装的。这里展示的 axios 的 mock 。

// 使用场景
imprt axios from 'axios';

const url = 'url';
const method = 'method'; // method 是请求方法之一
const data = 'data';
axios({url, method, data });
axios[method](url, data);
复制代码

mock 需要满足两个条件:
(1)可以当作函数进行调用
(2)可以指定函数上特定的属性当作函数进行调用,返回的是一个 Promise

初版
jest.mock('@/lib/request', () => {
  const fn = function (data) {
  // resolve 的数据是根据项目的特性自我定义
    return Promise.resolve({
      head: { code: 0 },
    });
  };
  fn.post = fn;
  return fn;
});
复制代码

这个 mock 存在两个问题。
(1)axios.post 的入参和 axios 直接当函数使用时是不一样的。
(2)只能返回一种状态的 resolve 数据

第一个问题可以通过判断第一个参数是不是 string 类型来确定哪个参数是真正的 data。

第二个问题可以通过多次 mock axios 来实现。但是经过尝试我发现这个方法行不通,因为在一个测试 js 文件中,jest.mock 同一个 path 或者库只会使用最后进行 mock 的为准。这里很可能是我的姿势不对,但是一直没有找到可以 jest.mock 重复覆盖的方法。
那么现在有两条路:
(1)写多个测试文件,对不同的接口返回情况进行 mock
(2)想办法解决 mock 函数的兼容性。

姑且把请求结果分为三种:

  1. 状态码 code 为 0,请求成功;
  2. 状态码 code 不为 0,请求成功,但是操作存在错误;
  3. 因为其他原因如网络连接不上导致返回的是 Promise 的 reject

如果细想,不难发现,第一种方法上行不通的。因为加入 vue 组件中链式的使用请求。即使是在多个测试文件的情况下,请求返回的状态也是只有一种。没有办法满足有的请求成功,有的请求失败的情况。那么在分支覆盖率上,肯定会造成很大的影响。没有办法模拟出完整的情况。

那么,我们要如何同时兼顾三种情况的返回呢?先回到我们的实际应用场景中。在大多数情况下,前后端交互使用的是 post 请求,会上传相应的 data 数据。如下:

await axios({
  url: 'myUrl',
  method: 'post',
  data: {
    mockData: this.mockData,
  },
});
复制代码

那么我们其实可以把 mockData 的值当成判断条件,进行返回数据类型的判断依据。mockData 只是请求接口的一个数据,这个数据是非固定的,可以进行修改的。

最终的实现
jest.mock('axios', () => {
  const fn = function (url, data) {
    const curdata = typeof url === 'string' ? data : url;
    // mockData 和 mockData2 对应不同请求上的非固定参数
    const code = curdata.mockData || curdata.mockData2  || 0;
    if (code === 'error') {
      return Promise.reject();
    }
    return Promise.resolve({
      head: { code: Number(code) || 0 },
    });
  };
  fn.post = fn;
  return fn;
});

// 测试
it('test', async () => {
  const { vm } = wrapper;
  await wrapper.setData({ mockData: 0 });
  let res = await vm.mockMethod();
  expect(res).toBe(true);

  await wrapper.setData({ mockData: 1234 });
  res = await vm.mockMethod();
  expect(res).toBe(false);

  await wrapper.setData({ mockData: 'error' });
  res = await vm.mockMethod();
  expect(res).toBe(false);
 });
复制代码

这是一种比较投机的方式,会导致的问题:

(1)由于 mockData 是请求参数中的一个属性,因为这种强绑定关系,在以后对应接口的请求参数删除该参数时,会让单测执行失败。
(2)会让单测的复杂程度变高,让人看起来很疑惑。

更好的解决方法

不要直接使用 axios 发送请求,而是把请求封装成 Api 对象,在 Api 上挂在每个请求 url 的请求。这样就可以在测试中直接 import 进 Api 对上面的属性进行 mock。如下:

it('test', async () => {
  const { vm } = wrapper;
  Api.login = jest.fn(() => Promise.resolve(1234));
  let res = await vm.testApi();
  console.log(res); // 1234
  Api.login = jest.fn(() => Promise.resolve(5678));
  res = await vm.testApi();
  console.log(res); // 5678
});
复制代码

完整的 demo

import { shallowMount  } from '@vue/test-utils';
import { createStore } from 'vuex';
import Vant from 'vant';
import Component from '@/views/guild/index.vue';
import Api from '@/apis/login';

jest.mock('clipboard', () => {
  const res = function (_, obj) {
    obj.text();
    return {
      on: (_, fn) => {
        fn();
      },
    };
  };
  return  res;
});
jest.mock('compressorjs', () => function (_, config = {}) {
  const { success, error } = config;
  success && success();
  error && error({ message: '' });
});
Object.defineProperty(global, 'FileReader', {
  writable: true,
  value: jest.fn().mockImplementation(() => ({
    readAsDataURL: () => {
      this.onload && this.onload();
      this.onerror && this.onerror();
    },
  })),
});
jest.mock('@/lib/request', () => {
  const fn = function (url, data) {
    const curdata = typeof url === 'string' ? data : url;
    // mockData 和 mockData2 对应不同请求上的非固定参数
    const code = curdata.mockData || curdata.mockData2  || 0;
    if (code === 'error') {
      return Promise.reject();
    }
    return Promise.resolve({
      head: { code: Number(code) || 0 },
    });
  };
  fn.post = fn;
  return fn;
});
function MockToast() {};
MockToast.loading = jest.fn();
MockToast.open = jest.fn();
MockToast.clear = jest.fn();
MockToast.fail = jest.fn();
MockToast.success = jest.fn();
function MockDialog() {};
MockDialog.comfirm = jest.fn(() => Promise.resolve());
MockDialog.alert = jest.fn(() => Promise.resolve());
const mockModule = () => ({
  namespaced: true,
  state: {
    mockState: {},
  },
  getters: {
    mockGetter: () => true,
  },
});
document.body.removeChild = jest.fn();
document.getElementById = () => ({
  addEventListener: (_, fn) => {
    fn.call({
      // 自定义需要传入的数据
    });
  },
  click: jest.fn(),
});

describe('test demo', () => {
  let wrapper;
  const store = createStore({
    modules: {
      mockModule: mockModule(),
    },
  });
  beforeEach(() => {
    wrapper = shallowMount(Component, {
      props: {},
      global: {
        plugins: [Vant, store],
        mocks: {
          $router: {
            push: jest.fn(),
            replace: jest.fn(),
            go: jest.fn(),
          },
          $route: {
            query: {},
          },
          $toast: MockToast,
          $dialog: MockDialog,
        },
      },
    });
  });

  it('ui render', () => {
    expect(wrapper.find('.demo').exists()).toBe(true);
  });

  it('test', async () => {
    const { vm } = wrapper;
    Api.login = jest.fn(() => Promise.resolve(1234));
    let res = await vm.testApi();
    console.log(res);
    Api.login = jest.fn(() => Promise.resolve(5678));
    res = await vm.testApi();
    console.log(res);
  });
});
复制代码

结语

mock 的核心要素是了解需要 mock 对象的行为,而不需要关心其内部实现,只需要通过 mock 把行为模拟出来就能完成相应的测试。

猜你喜欢

转载自juejin.im/post/7035575633061085198