vue下使用jest进行单元测试

Posted on

在前端开发逐渐复杂的今天,当然也需要对前端进行测试。单元测试主要用于白盒测试,检测程序的最小单位的正确性,通常来说就是验证函数的输入和输出。

vue 集成 jest

vue-cli 已经提供现成的插件,我们可以很方便的进行集成。官方文档也有讲述如何 debug。在使用 jest 之前,我们需要知道 jest 的测试代码不是像前端代码一样运行在浏览器环境,而是使用 node 运行,并且浏览器的 DOM 环境使用jsdom来模拟,所以在集成测试的时候需要解决不少问题。

jest 与 webpack

当今的前端开发都离不开 webpack 进行打包,我们使用各种各样的 loader 来处理代码,所以可以使用例如import语法来引用 JS 文件,但是 node 环境目前还不支持原生的 es6 import文件。所以在使用 jest 的时候,我们需要用 babel 进行代码转换,好在@vue/cli-plugin-unit-jest 都已经给配置好了,使用预置的 preset 就行。我是基于 TS 开发,所以用的 preset 是 @vue/cli-plugin-unit-jest/presets/typescript-and-babel。但是我们在代码里使用的一些 webpack 的特殊语法就不太好对应了,例如我有使用inline loader来打包特殊的 SVG 文件到代码中,但是在测试的时候就会遇到无法 import 的尴尬问题。这时候的解决方案就是使用moduleNameMapper:

moduleNameMapper: {
    '^[email protected](.*)$': require.resolve('jest-transform-stub')
},

通过把 import 的资源文件映射为空,就能解决 jest 运行出错的问题,毕竟我们在做单元测试的时候不会检测资源文件。

jest mock vuex store

做单元测试的时候,mock 是非常常见的需求,例如我们在一个文件里引用了 store 的数据,但是要构造这个数据非常复杂,所以我们需要直接 mock 数据。这里需要注意的是,如果是 import 引入的话,我们需要先 mock 再使用动态引用的方式加载测试模块,保证是在 import 之前 mock 数据。

jest.doMock('@/store', () => ({
  state: {
    account: {
      hello: 'world'
    }
  }
}));
const { default: formatter } = await import('@/utils/formatter');

不过,mock 之后记得 reset:

beforeEach(() => {
  jest.resetModules();
});

jest mock navigator

有些测试用例中,我们需要 mock 用户的浏览器是不同语言,这个时候就需要使用spyOn来修改属性的返回值。示例代码如下:

describe('test i18n', () => {
  let languageGetter: jest.SpyInstance;
  beforeEach(() => {
    jest.resetModules();
    languageGetter = jest.spyOn(window.navigator, 'language', 'get');
  });

  it('set lang is zh-CN', () => {
    languageGetter.mockReturnValue('zh-CN');
    console.log(navigator.language); // zh-CN
  });
});

jest mock vue router

测试 Vue 组件主要依靠Vue Test Utils。官方文档也有介绍如何使用,这个仅给出一个例子来描述用法:

import { mount, createLocalVue } from '@vue/test-utils';
import VueRouter from 'vue-router';

import App from '@/App.vue';
import routes from '@/router/routes';
import HelloWorld from '@/views/HelloWorld.vue';

const localVue = createLocalVue();
localVue.use(VueRouter);

describe('Test index routes', () => {
  it('should find HelloWorld when router is HelloWorld', async () => {
    const router = new VueRouter({ routes, mode: 'history' });
    await router.push('/helloworld');

    const wrapper = mount(App, {
      localVue,
      router
    });

    expect(wrapper.find(HelloWorld).exists()).toBe(true);
  });
});

这里使用 localVue 的原因是项目中只有一个 Vue 实例,直接使用的话会污染原本的 Vue,所以要在测试代码里手动创建router。这里要注意的是,如果 find 是某个组件,需要在 Vue 组件里面显示声明name,否则在 Lazy Loading 的情况下是无法找到组件的。还有一个指得注意的是router.push 是一个 Promise 异步操作,所以如果写成同步的话,会一直找不到组件的。

总结

jest 集成了很多测试的工具,只装一个工具便可进行单元测试。但是由于不是运行在浏览器环境,导致jsdom不支持的特性都需要自己来处理。所以希望选择一个在浏览器运行单元测试的框架应该会少遇到很多坑吧。 特别是用了很多浏览器特性的前端项目,测试起来更加折腾。

引用

Using with webpack

Test Vue Router

Vue中如何设计面包屑

Posted on

最近做的项目层级结构比较深,需要使用面包屑来定位用户所在的位置,方便用户跳转层级。所以就遇到web app如何设计面包屑问题。

根据路由匹配设置面包屑

当路由是下面这种情况的时候:

let routes = [
  {
    path: '/users',
    name: 'users-index',
    meta: {
      text: 'Users'
    },
    children: [
      {
        path: 'settings',
        name: 'user-setting'
      },
      // ...
    ]
  }
]

我们可以使用$route.matched属性来生成面包屑。示例代码如下:

<ul>
  <li v-for="route in $route.matched">
    <router-link :to="{name: route.name}">
      {{ route.meta.text }}
    </router-link>
  </li>
</ul>

这种方法只适用于解决静态面包屑,当我们需要处理例如路径是users/1/settings这种情况,显然路由匹配就不是最佳选择了。并且路由层级的加深也并不意味着页面嵌套的加深。 当资源层级很深的时候,我们需要做很多空的父节点来处理。

每个页面设置面包屑

let routes = [
  {
    path: '/users/userId(\\d+)',
    component: userLayout
    children: [
      {
        path: 'settings',
        name: 'user-setting',
        component: userSettings
      },
      {
        path: '',
        name: 'user-dashboard'
      }
      // ...
    ]
  }
]

当URL路径是users/1/settings的时候,我们期望的面包屑应该是Users > Lee > Settings。但是我们要怎么达到这个效果呢,特别是姓名信息我们还需要异步从服务器获取。

解决办法是使用Vuex来存储面包屑的信息。有使用数组来存储面包屑的方案,但是需要注意面包屑加入的顺序问题。为了更灵活的控制面包屑,我选择了使用链表的方式来存储面包屑。

定义数据结构

下面来看看面包屑的结构。

interface BreadCrumb {
  key: symbol;
  text?: string;
  to?: Route;
}

interface BreadCrumbItem {
  prev: BreadCrumbList;
  breadCrumb: BreadCrumb;
  next: BreadCrumbList;
}
type BreadCrumbList = BreadCrumbItem | null;

使用双向链表可以更方便的查询节点,每个节点主要存储三个信息,key主要用来标识这个节点的唯一性,所以是Symbol类型,text是当前节点展示的文本,to是配合vue-router跳转到其他页面。 prev和next当然就是指向上一个节点或者下一个节点啦。

定义state和mutation

const state = {
  breadCrumbList: null,
  breadCrumbTail: null
};

state里存储的分别是面包屑的头指针和尾指针。这样方便使用。

const mutations = {
  set(state, breadCrumbItem) {
    state.breadCrumbList = breadCrumbItem;
    state.breadCrumbTail = state.breadCrumbList;
  },
  add(state, breadCrumbItem) {
    if(state.breadCrumbTail) {
      state.breadCrumbTail.next = breadCrumbItem;
      breadCrumbItem.prev = state.breadCrumbTail;
      state.breadCrumbTail = breadCrumbItem;
    }
  },
  replace(state, breadCrumbItem) {
    let point = state.breadCrumbList;
    while (point) {
      if (point.breadCrumb.key === breadCrumbItem.breadCrumb.key) {
        point.breadCrumb.text = breadCrumbItem.breadCrumb.text;
        point.breadCrumb.to = breadCrumbItem.breadCrumb.to;
        return;
      } else {
        point = point.next;
      }
    }
  },
  remove(state, breadCrumbItem) {
    let point = state.breadCrumbTail;
    while (point) {
      if (point.breadCrumb === breadCrumbItem.breadCrumb) {
        let before = point.prev;
        let after = point.next;
        if (before) {
          before.next = after;
        } else {
          state.breadCrumbList = after;
        }
        if (after) {
          after.prev = before;
        } else {
          point.next = null;
        }
        return;
      } else {
        point = point.prev;
      }
    }
  },
  empty(state, breadCrumbItem) {
    state.breadCrumbList = null;
    state.breadCrumbTail = null;
  }
};

面包屑提供的方法有,set, add, replace, remove, empty。 功能都顾名思义。

每个组件设置面包屑

根据上面的路由设计,我们需要在userLayout组件里面完成Users > Lee前两级面包屑的填充。然后在userSettings组件里完成最后一级面包屑的填充。 由于第二级面包屑需要异步请求数据去填充,所以我们需要先事先添加一个空面包屑,之后再replace。

// userLayout

mounted() {
  const breadCrumbIndex = {
    prev: null,
    breadCrumb: {
      key: Symbol('users'),
      text: 'Users',
      to: {
        name: 'user-index'
      }
    },
    next: null
  }
  const breadCrumbItem = {
    prev: null,
    breadCrumb: {
      key: Symbol('users')
    },
    next: null
  };
  store.commit('set', breadCrumbIndex)
  store.commit('add', breadCrumbItem);
  axios.get(`/api/user/${userId}`).then(res => {
    store.commit('replace', {
      ...breadCrumbItem,
      breadCrumb: {
        ...breadCrumbItem.breadCrumb,
        text: res.data.name,
        to: {
          name: 'user-dashboard'
        }
      }
    })
  })
  this.$once('hook:beforeDestroy', () => {
    store.commit('remove', breadCrumbIndex);
    store.commit('remove', breadCrumbItem);
  })
}

// userSettings

mounted() {
  const breadCrumbItem = {
    prev: null,
    breadCrumb: {
      key: Symbol('users'),
      name: 'Settings',
      to: this.$router.currentRoute
    },
    next: null
  };
  store.commit('add', breadCrumbItem);
  this.$once('hook:beforeDestroy', () => {
    store.commit('remove', breadCrumbItem);
  })
}

组件切换生命周期的执行顺序

当我们加载 userDashboard 并切换到 userSettings 页面的时候,组件执行生命周期执行顺序是。

beforeCreate userDashboard
created userDashboard
beforMount userDashboard
mounted userDashboard  // userDashboard show
beforeCreate userSettings // route to userSettings
created userSettings
beforMount userSettings
beforeDestory userDashboard
destroyed userDashboard
mounted userSettings

我们发现组件在beforeDestory后面之前的是mounted。所以如果把添加面包屑的代码放在beforeMount里的时候,其实是先添加面包屑再删除之前的面包屑的。不过好在我们用的是链表,所以顺序不影响面包屑的生成。

生成面包屑

breadCrumb() {
  const breadcrumb = [];
  let point = store.state.breadCrumbList;
  while (point && point.breadCrumb.name) {
    breadcrumb.push(point.breadCrumb);
    point = point.next;
  }

  return breadcrumb;
}

把面包屑的链表转换成数组,然后使用vue的v-for来渲染就可以了。

总结

面包屑的这两种实现方式我都有尝试过,个人感觉针对动态面包屑第二种方式比较优雅。如果你有更好的实现方式,欢迎讨论。

参考:

Handling breadcrumbs with VueX in a VueJS Single Page Application

tagged: vue

如何封装第三方vue组件

Posted on

终于算是正式接触SPA的前端工作了,最近在解决很多Vue组件的问题,其中就有一个针对第三方组件的定制化需求,花了不少时间来折腾。

解决v-model绑定

这次封装的就是element的前端vue框架,因为框架本身对IOS兼容性不好,所以需要二次封装。封装的是一个select组件。所以需要数据的双向绑定,官方教程也已经解释了。 v-model 本质上就是绑定一个值和监听相应的事件。 这次本质是封装的一个input元素,所以我们需要手动绑定value和监听事件。

<template>
  <el-select
    v-bind:value="value"
    v-on="$listeners">
  </el-select>
</template>

官方文档也解释了这个内置变量的用法vm-listeners

继承父元素的属性

父元素的属性继承可以使用v-bind="$attrs"来完成。所以完成组建的透明封装只需要加上三个指令。

<template>
  <el-select
    v-bind="$attrs"
    v-bind:value="value"
    v-on="$listeners">
  </el-select>
</template>

虽然知道答案之后很简单,但是探索的过程中还是比较花时间的。希望能帮助大家解决这个问题,如果有什么疏漏之处,也请大家指正。