Vue单元测试文档

  1. 我们单元测试主要是对Vue组件进行单测

    单测使用 Jest 框架, 方法库用集成jest的 Vue Test Utils

  2. 主要的单测配置文件
    jest.config.js

module.exports = {
  moduleFileExtensions: [
    'js',
    'jsx',
    'json',
    'vue'
  ],
  transform: {
    '^.+\\.vue$': 'vue-jest',
    '.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub',
    '^.+\\.jsx?$': 'babel-jest'
  },
  transformIgnorePatterns: [
    '/node_modules/'
  ],
  setupFiles: [
    '<rootDir>/tests/unit/config.js'
  ],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1'
  },
  snapshotSerializers: [
    'jest-serializer-vue'
  ],
  testMatch: [
    '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
  ],
  //覆盖率报告
  coverageReporters: ['text', 'text-summary', 'html'],
  testURL: 'http://localhost/'
};

package.json 进行启动配置

"scripts": {
 "unit": "npm run mock | vue-cli-service test:unit --coverage",
}

最终单元测试报告,会在'工程目录'/coverage/index.html目录下生成测试覆盖率的html格式报告。

  1. jest框架

    JestFacebook 开发的一款 JavaScript 测试框架, 在 Facebook 内部广泛用来测试各种 JavaScript 代码。
    其官网上主要列出了以下几个特点:

(1)使用简单。使用 create-react-app 或是 react-native init 创建的项目已经默认集成了 Jest
使用Vue 直接集成即可

npm install --save-dev jest //安装
yarn add --dev jest
// package.json 配置
{
  "scripts": {
    "test": "jest"
  }
}

(2)内置强大的断言与 mock 功能
(3)内置测试覆盖率统计功能
(4)内置 Snapshot 机制
你可以使用它测试任何 JavaScript 项目。

  1. 单个测试模块 describe ,更多案例可以查看 https://vue-test-utils.vuejs.org/zh/
describe("样例", () =>{
  it("deep用法",  ()  =>{
    expect({a: 1}).toEqual({a: 1});
    expect(1).toBe(1);
  });
});

//skip语法可以跳过测试,不用大规模注释代码
describe.skip('skip',  () => {
  let foo = false;
  it('slip是否执行', ()  =>{
    expect(foo).toBe(false);
  });
});
//异步测试
describe('异步测试', ()  =>{
  it('the data is peanut butter', async (done) => {
     const data = await fetchData();
     expect(data).toBe('peanut butter');
  });
});
//Vue也提供了一个异步机制
//异步测试
describe('异步测试', ()  =>{
  it('the data is peanut butter', async (done) => {
     wrapper.vm.$nextTick(() => {
        expect(wrapper.vm.value).toBe('value')
        done()
     })
  });
});


  1. 好吧,Let's do it!
    需要单测的 manager.vue
<ul class="dataClearList" :style="{height:mainContentHeight-68+'px'}">
      <li v-for="cur in list" 
           class="centerPicList"
           @click="managerType.type == 'managerList' && 
           enterInto(cur.ci.id,cur.attrs._DCTYPE_)">
        <div class="img">
          <img  :src="cur.attrs.PICURL|| defaultImg " :height="180" :width="306">
        </div>
        <div class="content">{{cur.attrs._NAME_}}</div>
        <div v-if="managerType.type == 'manager'" class="widget" >
          <p class="widget_edit" :title="tiModify" @click="editor(cur)"><span></span></p>
          <p class="widget_delete" :title="tiDelete" @click="deleteScene(cur)"><span></span></p>
        </div>
        <div 
              v-else-if="managerType.type == 'managerList'
              && cur.attrs._DCTYPE_ && cur.attrs._DCTYPE_.length > 5"
              class="widget">
          <p @click.stop="enterInto(cur.ci.id,cur.attrs._DCTYPE_.substr(0, 3))">
                <span class="T3D-model-a-glay">
                    <i class="icon ts ts-3d" style="color: #ffffff;font-size: 16px"></i>
                </span>
          </p>
          <p @click.stop="enterInto(cur.ci.id,cur.attrs._DCTYPE_.substr(3))">
                <span class="T3D-model-a-glay" >
                   <i class="icon ts ts-friend-circle" style="color: #ffffff;font-size: 16px"></i></span>
          </p>
        </div>
      </li>
      <li v-if="managerType.type == 'manager'" class="last" @click="editor()">
        <div class="add_data">
          <i class="ts ts-add"></i>
        </div>
      </li>
    </ul>

  1. 单测的文件
import {shallowMount,createLocalVue  } from '@vue/test-utils';
import Vue from 'vue';
import VueI18n from '../../src/js/i18n';
import Vuex from 'vuex'
import VueRouter from 'vue-router'
import Manager from '@/components/Manager.vue';
//组件化
import { Page } from 'iview';
//vuex
import fitView from '@/js/store/modules/fitView';
import getters from '@/js/store/getters';
const localVue = createLocalVue()
localVue.use(Vuex)
//router
localVue.use(VueRouter);
const router = new VueRouter()
Vue.use(VueI18n);
Vue.component('Page', Page);

describe('Manager', () => {
  const elType = {
    type:'manager'
  };
  window.IS_MOCK = true;
  const store = new Vuex.Store({
    state: {},
    mutations: {},
    modules: {
      fitView
    },
    getters
  });
  const wrapper = shallowMount(Manager, {
    propsData: {
      managerType: elType
    },
    localVue,
    store
  });
  store.commit('fitView/setMainHeight',500);

  it('init manager', () => {
    expect(wrapper.props().managerType).toEqual(elType);
    expect(wrapper.vm.list.length).toBe(0);
    expect(wrapper.vm.mainContentHeight).toBe(fitView.state.mainContent.height);
  });

  it('是否生成列表 模拟数据', async (done) => {
    interval(function () {
      expect(wrapper.vm.list.length).toBe(1)
    }, function () {
      return wrapper.vm.list.length>0
    }, done)
  })

  it('数据中心 点击进入场景', async(done) => {
    interval(function () {
      expect(wrapper.vm.list.length).toBe(1)
    }, function () {
      return wrapper.vm.list.length>0
    }, done);
    expect(wrapper.vm.list[0].attrs._DCTYPE_.length).toBe(5);


  })

  const elListType = {
    type:'managerList'
  };

  it('数据中心列表 编辑', async(done) => {
    let wrappers = shallowMount(Manager, {
      propsData: {
        managerType: elListType
      },
      localVue,
      store,
      router
    });
    interval(function () {
      expect(wrappers.vm.list.length).toBe(1)
    }, function () {
      return wrappers.vm.list.length>0
    }, done);
    wrapper.findAll('p').at(0).trigger('click');
    expect(wrapper.vm.$router.history.router.history.current.path).toBe('/manager/editor');
  })
});
  1. 单测的总结
    (1)Vuex的引入
    为了完成Vuex单测,我们需要在浅渲染组件时给 Vue 传递一个伪造的 store。
    为了不污染全局的Vue,我引入了localVue 因为这是Vue独立作用域。
import { shallowMount, createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
const localVue = createLocalVue()
localVue.use(Vuex)

(2)Router的引入
localVue 引入Router 只会暴露 route 和 router并且是只读属性

import { shallowMount, createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'
const localVue = createLocalVue()
localVue.use(VueRouter)

(3)mock接口的返回数据
通过mock.js的配置,将会在本地启动了一个服务端口为5002
const map = require('../tests/data'); 引入mock数据

const compression = require('compression');
const bodyParser = require('body-parser');
const map = require('../tests/data');
// 创建 application/x-www-form-urlencoded 编码解析
const urlencodedParser = bodyParser.urlencoded({extended: false});
const port = 5002;
const api = '/dcv-api/';

const app = express();
// app.use(bodyParser.json());
app.use(urlencodedParser);
app.use(compression());

app.all('*', function (req, res, next) {
  // res.header('Access-Control-Allow-Origin', 'http://localhost:5000');
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Headers', '*');
  res.header('Access-Control-Allow-Methods', '*');
  // res.header('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
  if (req.method === 'OPTIONS') {
    res.sendStatus(200);
  } else {
    next();
  }
});

/**
 * 修正格式
 */
const format = (result) => {
  if (typeof result !== 'object' || result.success === undefined) {
    result = {
      success: true,
      data: result
    };
  }
  return result;
};

const start = () => {
  for (let url in map) {
    let result = map[url];
    app.get(`${api}${url}`, (req, res) => {
      res.send(format(result));
    });
    app.post(`${api}${url}`, (req, res) => {
      console.log(url, result);
      if (typeof result === 'function') {
        res.send(result.call(null, req.body));
        return;
      }
      res.send(format(result));
    });
  }
  app.get('/', (req, res) => res.send('Hello mock!'));
};

start();
app.listen(port, () => {
  console.info(`server started in http://localhost:${port}`);
});

单个的mock数据 路径tests/data/login.mock.js

/**
 * 获取用户信息
 */
/**
 * 登陆
 */
const login = function () {
  return {
    'code': 'SUCCESS',
    'success': true,
    'message': '登录成功',
    'token': 'c6559a36b7bdb354e717ded1d559c39d3bf5a1f68498274e979d0505b98509d7ead1e9345e2b217c684bb521eec6c1b7d093acb2da81ca684c89cc0f7901be8f'
  };
};

/**
 * 登出
 */
const logout = function () {
  const result = {
    user: 'admin'
  };
  return result;
};

/**
 * 登陆
 */
const getPicture = function () {
  const result = {
    'loginLogo': '',
    'loginBdImg': '',
    'loginBdText': ''
  };
  return result;
};

module.exports = {
  'user/oauth/login': login(),
  'user/oauth/logout': logout(),
  'user/oauth/getPicture': getPicture()
};

通过tests/data/index.js 对全局的数据进行配置

let login = require('./login.mock');
let userInfo = require('./userInfo.mock');
let license = require('./license.mock');
let manager = require('./manager.mock');

var merge = function (map, map2) {
  for (var key in map2) {
    if (map[key]) {
      console.error(`the key ${key} is used!`);
      continue;
    }
    map[key] = map2[key];
  }
  return map;
};

merge(login, userInfo);
merge(login, license);
merge(login, manager);

module.exports = login;

通过对ajax进行全局数据接口拦截 window.IS_MOCK ,将调用mockUrl,是上面启动的5002端口模拟数据服务,正常是服务器请求地址

mockUrl: `http://localhost:5002${api}`,

(4)单元测试异步数据的处理

interval 异步方法,主要解决不同浏览器异步接口返回的时间不同

global.interval = function (fun,func2, done, time=10) {
let id = setInterval(function () {
 if(func2()){
   clearInterval(id);
   fun();
   done();
 }
}, time);
};

describe('Manager', () => {
it('是否生成列表 模拟数据', async (done) => {
 interval(function () {
   expect(wrapper.vm.list.length).toBe(2);
 }, function () {
   return wrapper.vm.list.length>0
 }, done)
expect(wrapper.vm.list[0].attrs._DCTYPE_.length).toBe(5);
})
})

(5)深浅拷贝(单元测试如果会用到i-view组件的方法)
shallowMount 浅渲染将不进行i-view继承
mount 深渲染是可以继承

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,406评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,732评论 3 393
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,711评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,380评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,432评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,301评论 1 301
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,145评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,008评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,443评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,649评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,795评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,501评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,119评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,731评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,865评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,899评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,724评论 2 354