Vue单元测试探索

为什么要单元测试?

项目的现状

当前我在公司里负责的项目,可以分为两类:

  • 一类是相似度很高的项目,比如管理后台,这类项目的页面通过各种公共组件搭建而成。公共组件的复用性很高,所以质量尤为重要。如果开发人员在修改了公共组件之后留下了bug,那么将会直接降低了整个项目的质量。我希望让程序去测试这些公共组件,保证每一个公共组件是可用的。

  • 另一类是公司的核心项目,这些项目特点是维护周期长,并且会不断加入新的功能。在项目版本迭代的过程中,当一些原来通过了测试的旧功能发生了bug,一般只能到了测试阶段才能被测试人员发现。我希望由程序去保证部分核心功能的正常运作,当核心功能发生了bug能快速的察觉到,而不是到了测试阶段才发现。

为了解决上面的问题,我尝试引入单元测试

单元测试的作用

  • 降低bug发生几率,快速定位bug,减少重复的手工测试。

  • 提高代码质量,为项目带来更高的代码可维护性。

  • 方便项目的交接工作,测试脚本就是最好的需求描述。

接下来谈谈如何进行单元测试。

搭建测试框架

测试工具一览

Mocha

image

Mocha(发音"摩卡")诞生于2011年,是现在最流行的JavaScript测试框架之一,在浏览器和Node环境都可以使用。

Karma

image

Karma是由Google团队开发的一个测试工具, 它不是一个测试框架, 只是一个跑测试的驱动. 你可以通过karma的配置文件集成你喜欢的框架, 断言库和浏览器.

Vue Test Utils

Vue的官方的单元测试框架,它提供了一系列非常方便的工具,使我们可以更轻松地为Vue应用编写单元测试。主流的 JavaScript 测试运行器有很多,但 Vue Test Utils 都能够支持。它是测试运行器无关的。

Chai断言库

image

Chai是一个断言库,用于Node和浏览器,它可以与任何JavaScript测试框架相结合

搭建方法:

本文选择的测试框架由Karma + Mocha + Chai + Vue Test Utils搭配,自己手动配置过程比较繁琐,在这里强烈推荐大家使用vue-cli,vue-cli有现成的模板可以生成项目,执行vue init webpack [项目名],'Pick a test runner'时选择'Karma + Mocha'
。vue-cli会自动生成Karma + Mocha + Chai的配置,我们只需要额外安装Vue Test Utils,执行npm install @vue/test-utils。

image

如果想自己动手配置的同学,可以参考这篇文章

配置完成以后,下图是项目目录结构:

image

test文件夹下是unit文件夹,里面放的是单元测试相关的文件。

image

specs里存放的是测试脚本,这部分是由开发人员编写的。
coverage文件夹里存放的是测试报告,打开里面的index.html可以直观地看到测试的代码覆盖率。
Karma.conf.js是karma的配置文件。

怎样写单元测试

举个例子

被测试的组件HelloWorld.vue(path:E:\study\demo\src\components)

代码如下:

 <template>
  <div class="hello">
    <h1>Welcome to Your Vue.js App</h1>
  </div>
</template>

测试脚本HelloWorld.spec.js(path:E:\study\demo\test\unit\specs)

代码如下:

import HelloWorld from '@/components/HelloWorld';
import { mount, createLocalVue, shallowMount } from '@vue/test-utils'

describe('HelloWorld.vue', () => {
  it('should render correct contents', () => {
    const wrapper = shallowMount(HelloWorld);
    let content = wrapper.vm.$el.querySelector('.hello h1').textContent;
    expect(content).to.equal('Welcome to Your Vue.js App');
  });
});

1.测试脚本的写法

describe是"测试套件"(test suite),表示一组相关的测试。它是一个函数,第一个参数是测试套件的名称("加法函数的测试"),第二个参数是一个实际执行的函数。

it是"测试用例"(test case),表示一个单独的测试,是测试的最小单位。它也是一个函数,第一个参数是测试用例的名称,第二个参数是一个实际执行的函数。

2.断言库的用法

上面的测试脚本里面,有一句断言:

expect(content).to.equal('Welcome to Your Vue.js App');

所谓"断言",就是判断源码的实际执行结果与预期结果是否一致,如果不一致就抛出一个错误。上面这句断言的意思是,变量content应等于'Welcome to Your Vue.js App'。

所有的测试用例(it块)都应该含有一句或多句的断言。它是编写测试用例的关键。

3.查看测试结果

最后运行一下npm run unit,来看结果:

image

结果显示测试通过。

打开coverage下的index.vue查看代码覆盖率:

image

因为这是一个刚新建的项目,代码非常简单,所以覆盖率是100%。代码覆盖率是一个客观的数据,不能完全真实表示项目的测试情况,但是具有不错的参考价值。在多人开发的团队中,覆盖率可以作为一个硬性的标准。

这就是一个简单的单元测试编写过程,是不是很简单呢?大家都动手自己试试吧。

友情提示

1.用createLocalVue安装插件

我们在给实际项目写单元测试的时候,项目代码会比上面的demo组件复杂很多。如果你要测试的单个组件里使用了vue-router或者Vuex的话,就要使用createLocalVue。
比如,有这样一段代码:

data() {
 return {
     brandId: this.$route.query.id,
 }
}

$route对象需要用createLocalVue注入router才能使用,否则执行测试脚本会出错。使用createLocalVue解决这个问题,具体代码:

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

const localVue = createLocalVue()
localVue.use(VueRouter)
const router = new VueRouter()

shallowMount(Component, {
  localVue,
  router
})

Vuex也是同理,关于createLocalVue详细用法就不做赘述了,大家可以去翻阅官方文档。

2.nextTick怎么办

如果你需要在自己的测试文件中使用 nextTick,注意任何在其内部被抛出的错误可能都不会被测试运行器捕获,因为其内部使用了 Promise。关于这个问题有两个建议:要么你可以在测试的一开始将 Vue 的全局错误处理器设置为 done 回调,要么你可以在调用 nextTick 时不带参数让其作为一个 Promise 返回:

// 这不会被捕获
it('will time out', (done) => {
  Vue.nextTick(() => {
    expect(true).toBe(false)
    done()
  })
})

// 接下来的两项测试都会如预期工作
it('will catch the error using done', (done) => {
  Vue.config.errorHandler = done
  Vue.nextTick(() => {
    expect(true).toBe(false)
    done()
  })
})

it('will catch the error using a promise', () => {
  return Vue.nextTick()
    .then(function () {
      expect(true).toBe(false)
    })
})

在下面的项目实战中,有使用到nextTick的例子,大家可以当做参考。

3. 修改默认测试浏览器

测试在配置文件karma.conf里,browsers默认是'PhantomJS'.

 module.exports = function karmaConfig (config) {
  config.set({
    // browsers: ['PhantomJS'],
    browsers: ['Chrome'],

但我在使用过程中发现PhantomJS环境的warning和error提示和平时在浏览器chrome看到的提示不太一样,有点难懂,如图:

Chrome:

image

PhantomJS:

image

browsers设置为'Chrome',得到的报错提示和真实Chrome浏览器上一致,并且可以使用console.log(),调试起来和真实开发的体验一样。唯一缺点是每次执行npm run unit都会弹出一个Chrome浏览器,PhantomJS则不会,推荐大家调试测试脚本时候使用Chrome,等脚本都跑通了不需要调试的时候可以换回PhantomJS。

4. 加上--auto-watch

默认下auto-watch是关闭的,每次修改了测试脚本,或者修改了项目代码之后都需要手动执行一次命令才能启动测试,非常麻烦。我们可以加上--auto-watch,这样在开发的过程中,如果某个功能没有通过测试用例,开发人员可以立刻发现并修复。

 "unit": "cross-env BABEL_ENV=test karma start test/unit/karma.conf.js --auto-watch",

项目实战

实例1

场景:页面上有一个textarea输入框和提交按钮,点击按钮发送请求。要求点击提交后前端先校验一下内容是否符合json格式,如果不符合则提示不能提交。

测试的目标:校验程序

测试用例:通过条件覆盖,输入数字,字符串,错误的json字符串,'null',正确的json字符串去验证所有的情况是否正常执行,期望只有最后一种情况才是返回结果才是通过的,其他都是不通过。

// form-setting.vue测试校验功能
describe('form-setting.vue测试校验功能', () => {
    const wrapper = shallowMount(formSetting, {
        localVue
    });

    let vm = wrapper.vm;

    it('test form填入数字是否会不通过', () => {
        vm.appType = 'ios'; // 选择系统ios
        vm.ios.schemeInfo = 1; // 输入数字
        expect(vm.isValid()).to.equal(false);
    });

    it('test form填入字符串格式是否会不通过', () => {
        vm.appType = 'ios';
        vm.ios.schemeInfo = '1'; // 输入字符串
        expect(vm.isValid()).to.equal(false);
    });

    it('test form填入错误json格式是否会不通过', () => {
        vm.appType = 'ios';
        vm.ios.schemeInfo = '{a:{a:}}'; // 输入非法的类似json格式的字符串
        expect(vm.isValid()).to.equal(false);
    });

    it('test form填入空对象是否会不通过', () => {
        vm.appType = 'ios';
        vm.ios.schemeInfo = 'null'; // 输入null对象字符串
        expect(vm.isValid()).to.equal(false);
    });

    it('test form填入正确JSON格式是否会通过', () => {
        vm.appType = 'ios';
        vm.ios.schemeInfo = '{"a": 111}'; // 输入正确的json字符串
        expect(vm.isValid()).to.equal(true);
    });
});
实例2

场景:团队开发了一个校验插件,其作用是校验输入框是否满足相应规则,若不满足在输入框下会出现一个提示错误的dom节点。

测试用例:通过列举所有的输入操作,然后判断是否存在类名为.error的错误提示节点。

在完成输入操作后,如果内容不通过校验,页面会生成错误提示的dom节点。这个过程是异步的,所以用到了nextTick。具体的用法是

return Vue.nextTick().then(() => {
    ...断言
}

关于这块详细的解释,Vue Test Utils有相关篇幅

import { mount, createLocalVue } from '@vue/test-utils'
import ValidateDemo from '@/components/validate-demo'
import validate from '@/directive/validate/1.0/validate'
import Vue from 'Vue'
const localVue = createLocalVue() // 创建一个Vue实例
localVue.use(validate) // 挂载校验插件
describe('测试validate-demo.vue', () => {
  it('没发生输入操作,[不显示error]', () => {
    const wrapper = mount(ValidateDemo, {
      localVue
    })
    return Vue.nextTick().then(() => {
      expect(wrapper.find('.error').exists()).to.equal(false)
    })
  })
  it('聚焦输入框然后失去焦点,[显示error]', () => {
    const wrapper = mount(ValidateDemo, {
      localVue
    })
    let input = wrapper.find('input')
    input.trigger('focus') // 聚焦
    input.trigger('blur') // 失去焦点

    return Vue.nextTick().then(() => {
      expect(wrapper.find('.error').exists()).to.equal(true)
    })
  })

  it('发生输入操作,然后清空,[显示error]', () => {
    const wrapper = mount(ValidateDemo, {
      localVue
    })
    let vm = wrapper.vm
    let input = wrapper.find('input')
    input.trigger('focus')
    vm.name = '不为空'
    vm.name = '' // 清空
    input.trigger('blur')
    return Vue.nextTick().then(() => {
      expect(wrapper.find('.error').exists()).to.equal(true)
    })
  })

  it('输入内容后,[不显示error]', () => {
    const wrapper = mount(ValidateDemo, {
      localVue
    })
    let vm = wrapper.vm
    vm.name = '不为空' // 输入内容
    return Vue.nextTick().then(() => {
      expect(wrapper.find('.error').exists()).to.equal(false)
    })
  })
})

单元测试的局限性

单元测试有许多优点,但不代表它就一定适合每个项目,在我看来它会有以下局限性:

1.额外的时间花费

即使你愿意花费开发的几分之一的时间去写单元测试,但是一旦功能有变更,就意味着测试逻辑也需要调整。对于一些经常变更的功能来说,这会导致很大的单元测试维护量。
所以我们要权衡好当中的利弊,可以考虑只针对稳定的功能(比如一些公用组件)和核心流程编写单元测试。

2.并非全部代码都能单元测试

如果项目里充斥着颗粒度低,方法间互相耦合的代码,你会发现无法进行单元测试。因为单元测试旨在从代码粒度上实现对应用质量的把握。面对这样的情况,要么重构已有代码,要么放弃单元测试寻求其他测试方法,比如人工测试,e2e测试。

虽然这算是单元测试的一个缺点,但我认为同时也是优点,习惯编写单元测试可以促使工程师提高代码的颗粒度,思维更加缜密。

3.无法保证一整个流程的运作

前端是一个非常复杂的测试环境,因为每个浏览器都有差异,需要的数据又依赖于后端。单元测试只能对功能每一个单元进行测试,对于一些依赖api的数据一般只能mock,无法真正的模拟用户实际的使用场景。对于这种情况,建议采用其他测试方法,比如人工测试、e2e测试。

总结

通过这次对单元测试的探索,我觉得做单元测试最大的阻力是——时间

手工测试最大的优势在于:当一个功能代码写好以后,只需要手动刷新浏览器去实际操作一下,便能判断程序是否正确。如果为此去编写单元测试则会花费额外的开发时间。

但人不是机器,无论多么简单的事都有可能出错。我们为系统加入了新功能的之后,一般不会去手动测试以前的旧功能。因为这耗费时间而又无趣,并且我们总会认为自己写的代码是不会影响旧功能的。

然而我们可以换个角度去想,如果在开发旧功能的时候写好了相应的单元测试,那么每次进入测试阶段之前,就可以用测试脚本把旧功能都跑一遍。这样既节省了测试旧功能的时间,自己也可以心安理得:无论怎么样,我都能确保我写的代码是通过测试的。

最后,感谢大家的阅读,本文是我关于对一个Vue项目做的比较浅显的单元测试的探索,属于抛砖引玉,如果有什么不合理的地方或建议,欢迎大家来指正!


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