Vue单元测试实战教程(Mocha/Karma + Vue-Test-Utils + Chai)
在《前端进阶之路: 前端架构设计(3) - 测试核心》这边文章中, 通过分析了"传统手工测试的局限性" 去引出了测试驱动开发的理念, 并介绍了一些测试工具. 这篇文章我将通过一个Vue的项目, 去讲解如何使用mocha & karma, 且结合vue官方推荐的vue-test-utils去进行单元测试的实战.
一. 安装
我为本教程写一个示例库, 您可以直接跳过所有安装过程, 安装依赖后运行该示例项目:
如果想一步步进行安装, 也可以跟着下面的步骤进行操作:
(一) 使用脚手架初始化vue项目(使用webpack模板)
//命令行中输入(默认阅读该文章的读者已经安装vue-cli和node环境) vue init webpack vueunittest
注意, 当询问到这一步Pick a test runner(Use arrow keys)
时, 请选择使用Karma and Mocha
接下来的操作进入项目npm install
安装相关依赖后(该步骤可能更会出现PhantomJS这个浏览器安装失败的报错, 不用理会, 因为 之后我们不使用这个浏览器), npm run build
即可.
(二) 安装Karma-chrome-launch
接下来安装karma-chrome-launcher
, 在命令行中输入
npm install karma-chrome-launcher --save-dev
然后在项目中找到test/unit/karma.conf.js
文件, 将PhantomJS
浏览器修改为Chrome
不要问我为什么不使用PhantomJS, 因为经常莫名的错误, 改成Chrome就不会!!!)
//karma.conf.js var webpackConfig = require('../../build/webpack.test.conf') module.exports = function (config) { config.set({ //browsers: ['PhantomJS'], browsers: ['Chrome'], ... }) }
(三) 安装Vue-test-utils
安装Vue.js 官方的单元测试实用工具库, 在命令行输入:
npm install --save-dev vue-test-utils
(四) 执行npm run unit
当你完成以上两步的时候, 你就可以在命令行执行npm run unit
尝鲜你的第一次单元测试了, Vue脚手架已经初始化了一个HelloWorld.spec.js
的测试文件去测试HelloWrold.vue
, 你可以在test/unit/specs/HelloWorld.spec.js
下找到这个测试文件.(提示: 将来所有的测试文件, 都将放specs
这个目录下, 并以测试脚本名.spec.js
结尾命名!)
在命令行输入npm run unit
, 当你看到下图所示的一篇绿的时候, 说明你的单元测试通过了!
二. 测试工具的使用方法
下面是一个Counter.vue
文件, 我将以该文件为基础讲解项目中测试工具的使用方法.
//Counter.vue <template> <div> <h3>Counter.vue</h3> {{ count }} <button @click="increment">自增</button> </div> </template> <script> export default { data () { return { count: 0 } }, methods: { increment () { this.count++ } } } </script>
(一) Mocha框架
1. Mocha测试脚本的写法
Mocha的作用是运行测试脚本, 要对上面Counter.vue
进行测试, 我们就要写测试脚本, 通常测试脚本应该与Vue组件名相同, 后缀为spec.js
. 比如, Counter.vue
组件的测试脚本名字就应该为Counter.spec.js
//Counter.spec.js import Vue from 'vue' import Counter from '@/components/Counter' describe('Counter.vue', () => { it('点击按钮后, count的值应该为1', () => { //获取组件实例 const Constructor = Vue.extend(Counter); //挂载组件 const vm = new Constructor().$mount(); //获取button const button = vm.$el.querySelector('button'); //新建点击事件 const clickEvent = new window.Event('click'); //触发点击事件 button.dispatchEvent(clickEvent); //监听点击事件 vm._watcher.run(); // 断言:count的值应该是数字1 expect(Number(vm.$el.querySelector('.num').textContent)).to.equal(1); }) })
上面这段代码就是一个测试脚本.测试脚本应该包含一个或多个describe
, 每个describe
块应该包括一个或多个it
块
describe
块称为"测试套件"(test suite), 表示一组相关的测试. 它是一个函数, 第一个参数是测试套件的名称(通常写测试组件的名称, 这里即为Counter.js
), 第二个参数是一个实际执行的函数.
it
块称为"测试用例"(test case), 表示一个单独的测试, 是测试的最小单位. 它也是一个函数, 第一个参数是测试用例的名称(通常描述你的断言结果, 这里即为"点击按钮后, count的值应该为1"
), 第二个参数是一个实际执行的函数.
2. Mocha进行异步测试
我们在Counter.vue
组件中添加一个按钮, 并添加一个异步自增的方法为incrementByAsync
, 该函数设置一个延时器, 1000ms后count
自增1.
<template> ... <button @click="increment">自增</button> <button @click="incrementByAsync">异步自增</button> ... <template> <script> ... methods: { ... incrementByAsync () { window.setTimeout(() => { this.count++; }, 1000) } } </script>
给测试脚本中新增一个测试用例, 也就是it()
it('count异步更新, count的值应该为1', (done) => { ///获取组件实例 const Constructor = Vue.extend(Counter); //挂载组件 const vm = new Constructor().$mount(); //获取button const button = vm.$el.querySelectorAll('button')[1]; //新建点击事件 const clickEvent = new window.Event('click'); //触发点击事件 button.dispatchEvent(clickEvent); //监听点击事件 vm._watcher.run(); //1s后进行断言 window.setTimeout(() => { // 断言:count的值应该是数字1 expect(Number(vm.$el.querySelector('.num').textContent)).to.equal(1); done(); }, 1000); })
Mocha中的异步测试, 需要给it()
内函数的参数中添加一个done
, 并在异步执行完后必须调用done()
, 如果不调用done()
, 那么Mocha会在2000ms后报错且本次单元测试测试失败(mocha默认的异步测试超时上线为2000ms), 错误信息如下:
3. Mocha的测试钩子
如果大家对于vue的mounted()
, created()
钩子能够理解的话, 对Mocha的钩子也很容易理解, Mocha在describe
块中提供了四个钩子: before()
, after()
, beforeEach()
, afterEach()
. 它们会在以下时间执行
describe('钩子说明', function() { before(function() { // 在本区块的所有测试用例之前执行 }); after(function() { // 在本区块的所有测试用例之后执行 }); beforeEach(function() { // 在本区块的每个测试用例之前执行 }); afterEach(function() { // 在本区块的每个测试用例之后执行 }); });
上述就是Mocha的基本使用介绍, 如果想了解Mocha的更多使用方法, 可以查看下面的文档和一篇阮一峰的Mocha教程:
- Mocha官方文档 : https://mochajs.org/
- Mocha官方文档翻译 : http://www.jianshu.com/p/9c78...
- 阮一峰 - 测试框架 Mocha 实例教程 : http://www.ruanyifeng.com/blo...
(二) Chai断言库
上面的测试用例中, 以expect()
方法开头的就是断言.
expect(Number(vm.$el.querySelector('.num').textContent)).to.equal(1);
所谓断言, 就是判断源码的实际执行结果与预期结果是否一致, 如果不一致, 就会抛出错误. 上面的断言的意思是指: 有.num
这类名的节点的内容应该为数字1. 断言库库有很多种, Mocha并不限制你需要使用哪一种断言库, Vue的脚手架提供的断言库是sino-chai
, 是一个基于Chai
的断言库, 并且我们指定使用的是它的expect
断言风格.
expect
断言风格的优点很接近于自然语言, 下面是一些例子
// 相等或不相等 expect(1 + 1).to.be.equal(2); expect(1 + 1).to.be.not.equal(3); // 布尔值为true expect('hello').to.be.ok; expect(false).to.not.be.ok; // typeof expect('test').to.be.a('string'); expect({ foo: 'bar' }).to.be.an('object'); expect(foo).to.be.an.instanceof(Foo); // include expect([1,2,3]).to.include(2); expect('foobar').to.contain('foo'); expect({ foo: 'bar', hello: 'universe' }).to.include.keys('foo'); // empty expect([]).to.be.empty; expect('').to.be.empty; expect({}).to.be.empty; // match expect('foobar').to.match(/^foo/);
每一个it()
所包裹的测试用例都应该有一句或多句断言,上面只是介绍了一部分的断言语法, 如果想要知道更多Chai
的断言语法, 请查看以下的官方文档.
- Chai官方文档: http://chaijs.com/
- Chai官方文档翻译: http://www.jianshu.com/p/f200...
(三) Vue-test-utils测试库
1. 在测试脚本中引入vue-test-utils
//Counter.spec.js import Vue from 'vue' import Counter from '@/components/Counter' //引入vue-test-utils import {mount} from 'vue-test-utils'
2. 测试文本内容
下面我将在Counter.spec.js
测试脚本中对Counter.vue
中<h3>
的文本内容进行测试, 大家可以直观的感受一下使用了Vue-test-utils后对.vue
单文件组件的测试变得多么简单.
- 未使用vue-test-utils的测试用例:
it('未使用Vue-test-utils: 正确渲染h3的文字为Counter.vue', () => { const Constructor = Vue.extend(Counter); const vm = new Constructor().$mount(); const H3 = vm.$el.querySelector('h3').textContent; expect(H3).to.equal('Counter.vue'); })
- 使用了vue-test-utils的测试用例:
it('使用Vue-test-Utils: 正确渲染h3的文字为Counter.vue', () => { const wrapper = mount(Counter); expect(wrapper.find('h3').text()).to.equal('Counter.vue'); })
从上面的代码可以看出, vue-test-utils工具将该测试用例的代码量减少了一半, 如果是更复杂的测试用例, 那么代码量的减少将更为突出. 它可以让我们更专注于去写文件的测试逻辑, 将获取组件实例和挂载的繁琐的操作交由vue-test-utils去完成.
3. vue-test-utils的常用API
find()
: 返回匹配选择器的第一个DOM节点或Vue组件的wrapper
, 可以使用任何有效的选择器text()
: 返回wrapper
的文本内容html()
: 返回wrapper DOM
的HTML字符串
it('find()/text()/html()方法', () => { const wrapper = mount(Counter); const h3 = wrapper.find('h3'); expect(h3.text()).to.equal('Counter.vue'); expect(h3.html()).to.equal('<h3>Counter.vue</h3>'); })
trigger()
: 在该wrapper DOM
节点上触发一个事件。
it('trigger()方法', () => { const wrapper = mount(Counter); const buttonOfSync = wrapper.find('.sync-button'); buttonOfSync.trigger('click'); buttonOfSync.trigger('click'); const count = Number(wrapper.find('.num').text()); expect(count).to.equal(2); })
setData()
: 设置data
的属性并强制更新
it('setData()方法',() => { const wrapper = mount(Counter); wrapper.setData({foo: 'bar'}); expect(wrapper.vm.foo).to.equal('bar'); })
上面介绍了几个vue-test-utils提供的方法, 如果想深入学习vue-test-utils, 请阅读下面的官方文档:
- vue-test-utils官方文档: https://vue-test-utils.vuejs....
三. 项目说明
该项目模仿了一个简单的微博, 在代码仓库下载后, 可直接通过npm run dev
运行.
(一) 项目效果图
(二) 项目中的交互逻辑和需求
- 在文本框中输入内容后点击"发布"按钮(1), 会新发布内容到微博列表中, 且个人头像等下的微博数量(6)会增加1个
- 当文本框中无内容时, 不能发布空微博到微博列表, 且弹出提示框, 叫用户输入内容
- 当点击"关注"(2), 个人头像下关注的数量(5)会增加1个, 且按钮内字体变成"取消关注"; 当点击"取消关注"(2), 个人头像下的数量(5)会减少1个, 且按钮内字体变成"关注"
- 当点击"收藏"(3)时, 我的收藏(7)会增加1个数量, 且按钮内文字变成"已收藏"; 点击"已收藏"(3)时, 我的收藏(7)会减少1个数量, 且按钮内文字变成"收藏"
- 当点击"赞"(4), 我的赞(8)会增加1个数量, 且按钮内文字变成"取消赞"; 点击"取消赞"(3)时, 我的赞(8)会减少1个数量, 且按钮内文字变成"赞"
(三) 项目源码
//SinaWeibo.vue <template> <div class="weibo-page"> <nav> <span class="weibo-logo"></span> <div class="search-wrapper"> <input type="text" placeholder="大家正在搜: 李棠辉的文章好赞!"> <img v-if="!iconActive" @mouseover="mouseOverToIcon" src="../../static/image/search.png" alt="搜索icon"> <img v-if="iconActive" @mouseout="mouseOutToIcon" src="../../static/image/search-active.png" alt="搜索icon"> </div> </nav> <div class="main-container"> <aside class="aside-nav"> <ul> <li :class="{ active: isActives[indexOfContent] }" v-for="(content, indexOfContent) in asideTab" :key="indexOfContent" @click="tabChange(indexOfContent)"> <span>{{content}}</span> <span class="count"> <span v-if="indexOfContent === 1">({{collectNum}})</span> <span v-if="indexOfContent === 2">({{likeNum}})</span> </span> </li> </ul> </aside> <main class="weibo-content"> <div class="weibo-publish-wrapper"> <img src="../../static/image/tell-people.png"></img> <textarea v-model="newWeiboContent.content"></textarea> <button @click="publishNewWeiboContent">发布</button> </div> <div class="weibo-news" v-for="(news, indexOfNews) in weiboNews" :key="indexOfNews"> <div class="content-wrapper"> <div class="news-title"> <div class="news-title__left"> <img :src="news.imgUrl"> <div class="title-text"> <div class="title-name">{{news.name}}</div> <div class="title-time">{{news.resource}}</div> </div> </div> <button class="news-title__right add" v-if="news.attention === false" @click="attention(indexOfNews)"> <i class="fa fa-plus"></i> 关注 </button> <button class="news-title__right cancel" v-if="news.attention === true" @click="unAttention(indexOfNews)"> <i class="fa fa-close"></i> 取消关注 </button> </div> <div class="news-content">{{news.content}}</div> <div class="news-image" v-if="news.images.length"> <img v-for="(img, indexOfImg) in news.images" :key="indexOfImg" :src="img"> </div> </div> <ul class="news-panel"> <li @click="handleCollect(indexOfNews)"> <i class="fa fa-star-o" :class="{collected: news.collect }"></i> {{news.collect ? "已收藏" : '收藏'}} </li> <li> <i class="fa fa-external-link"></i> 转发 </li> <li> <i class="fa fa-commenting-o"></i> 评论 </li> <li @click="handleLike(indexOfNews)"> <i class="fa fa-thumbs-o-up" :class="{liked: news.like}"></i> {{news.like ? '取消赞' : '赞'}} </li> </ul> </div> </main> <aside class="aside-right"> <div class="profile-wrapper"> <div class="profile-top"> <img src="../../static/image/profile.jpg"> </div> <div class="profile-bottom"> <div class="profile-name">Lee_tanghui</div> <ul class="profile-info"> <li v-for="(profile, indexOfProfile) in profileData" :key="indexOfProfile"> <div class="number">{{profile.num}}</div> <div class="text">{{profile.text}}</div> </li> </ul> </div> </div> </aside> </div> <footer> Wish you like my blog! --- LITANGHUI </footer> </div> </template> <script> //引入假数据 import * as mockData from '../mock-data.js' export default { mounted() { //模拟获取数据 this.profileData = mockData.profileData; this.weiboNews = mockData.weiboNews; this.collectNum = mockData.collectNum; this.likeNum = mockData.likeNum; }, data() { return { iconActive: false, asideTab: ["首页", "我的收藏", "我的赞"], isActives: [true, false, false], profileData: [], weiboNews: [], collectNum: 0, likeNum: 0, newWeiboContent: { imgUrl: '../../static/image/profile.jpg', name: 'Lee_tanghui', resource: '刚刚 来自 网页版微博', content: '', images: [] }, } }, methods: { mouseOverToIcon() { this.iconActive = true; }, mouseOutToIcon() { this.iconActive = false; }, tabChange(indexOfContent) { this.isActives.forEach((item, index) => { index === indexOfContent ? this.$set(this.isActives, index, true) : this.$set(this.isActives, index, false); }) }, publishNewWeiboContent() { if(!this.newWeiboContent.content) { alert('请输入内容!') return; } const newWeibo = JSON.parse(JSON.stringify(this.newWeiboContent)); this.weiboNews.unshift(newWeibo); this.newWeiboContent.content = ''; this.profileData[2].num++; }, attention(index) { this.weiboNews[index].attention = true; this.profileData[0].num++; }, unAttention(index) { this.weiboNews[index].attention = false; this.profileData[0].num--; }, handleCollect(index) { this.weiboNews[index].collect = !this.weiboNews[index].collect; this.weiboNews[index].collect ? this.collectNum++ : this.collectNum--; }, handleLike(index) { this.weiboNews[index].like = !this.weiboNews[index].like; this.weiboNews[index].like ? this.likeNum++ : this.likeNum--; } } } </script> <style lang="less"> //css部分略 </style>
四. 项目单元测试脚本实战
我们将以上文提到的"项目中的交互逻辑和需求"为基础, 为SinaWeibo.vue
编写测试脚本, 下面我将展示测试用例编写过程:
1.在文本框中输入内容后点击"发布"按钮(1), 会新发布内容到微博列表中, 且个人头像等下的微博数量(6)会增加1个
it('点击发布按钮,发布新内容&个人微博数量增加1个', () => { const wrapper = mount(SinaWeibo); const textArea = wrapper.find('.weibo-publish-wrapper textarea'); const buttonOfPublish = wrapper.find('.weibo-publish-wrapper button'); const lengthOfWeiboNews = wrapper.vm.weiboNews.length; const countOfMyWeibo = wrapper.vm.profileData[2].num; //设置textArea的绑定数据 wrapper.setData({newWeiboContent: { imgUrl: '../../static/image/profile.jpg', name: 'Lee_tanghui', resource: '刚刚 来自 网页版微博', content: '欢迎来到我的微博', images: [] }}); //触发点击事件 buttonOfPublish.trigger('click'); const lengthOfWeiboNewsAfterPublish = wrapper.vm.weiboNews.length; const countOfMyWeiboAfterPublish = wrapper.vm.profileData[2].num; //断言: 发布新内容 expect(lengthOfWeiboNewsAfterPublish).to.equal(lengthOfWeiboNews + 1); //断言: 个人微博数量增加1个 expect(countOfMyWeiboAfterPublish).to.equal(countOfMyWeibo + 1); })
测试结果:
2.当文本框中无内容时, 不能发布空微博到微博列表, 且弹出提示框, 叫用户输入内容
it('当文本框中无内容时, 不能发布空微博到微博列表, 且弹出提示框', () => { const wrapper = mount(SinaWeibo); const textArea = wrapper.find('.weibo-publish-wrapper textarea'); const buttonOfPublish = wrapper.find('.weibo-publish-wrapper button'); const lengthOfWeiboNews = wrapper.vm.weiboNews.length; const countOfMyWeibo = wrapper.vm.profileData[2].num; //设置textArea的绑定数据为空 wrapper.setData({newWeiboContent: { imgUrl: '../../static/image/profile.jpg', name: 'Lee_tanghui', resource: '刚刚 来自 网页版微博', content: '', images: [] }}); //触发点击事件 buttonOfPublish.trigger('click'); const lengthOfWeiboNewsAfterPublish = wrapper.vm.weiboNews.length; const countOfMyWeiboAfterPublish = wrapper.vm.profileData[2].num; //断言: 没有发布新内容 expect(lengthOfWeiboNewsAfterPublish).to.equal(lengthOfWeiboNews); //断言: 个人微博数量不变 expect(countOfMyWeiboAfterPublish).to.equal(countOfMyWeibo); })
测试结果:
3.当点击"关注"(2), 个人头像下关注的数量(5)会增加1个, 且按钮内字体变成"取消关注"; 当点击"取消关注"(2), 个人头像下的数量(5)会减少1个, 且按钮内字体变成"关注"
it('当点击"关注", 个人头像下关注的数量会增加1个, 且按钮内字体变成"取消关注"', () => { const wrapper = mount(SinaWeibo); const buttonOfAddAttendion = wrapper.find('.add'); const countOfMyAttention = wrapper.vm.profileData[0].num; //触发事件 buttonOfAddAttendion.trigger('click'); const countOfMyAttentionAfterClick = wrapper.vm.profileData[0].num; //断言: 个人头像下关注的数量会增加1个 expect(countOfMyAttentionAfterClick).to.equal(countOfMyAttention + 1); //断言: 按钮内字体变成"取消关注 expect(buttonOfAddAttendion.text()).to.equal('取消关注'); })
it('当点击"取消关注", 个人头像下关注的数量会减少1个, 且按钮内字体变成"关注"', () => { const wrapper = mount(SinaWeibo); const buttonOfUnAttendion = wrapper.find('.cancel'); const countOfMyAttention = wrapper.vm.profileData[0].num; //触发事件 buttonOfUnAttendion.trigger('click'); const countOfMyAttentionAfterClick = wrapper.vm.profileData[0].num; //断言: 个人头像下关注的数量会增加1个 expect(countOfMyAttentionAfterClick).to.equal(countOfMyAttention - 1); //断言: 按钮内字体变成"取消关注 expect(buttonOfUnAttendion.text()).to.equal('关注'); })
测试结果:
4.当点击"收藏"(3)时, 我的收藏(7)会增加1个数量, 且按钮内文字变成"已收藏"; 点击"已收藏"(3)时, 我的收藏(7)会减少1个数量, 且按钮内文字变成"收藏"
it('当点击"收藏"时, 我的收藏会增加1个数量, 且按钮内文字变成"已收藏"', () => { const wrapper = mount(SinaWeibo); const buttonOfCollect = wrapper.find('.collectWeibo'); const countOfMyCollect = Number(wrapper.find('.collect-num span').text()); //触发点击事件 buttonOfCollect.trigger('click'); const countOfMyCollectAfterClick = Number(wrapper.find('.collect-num span').text()); //断言: 我的收藏数量会加1 expect(countOfMyCollectAfterClick).to.equal(countOfMyCollect + 1); //断言: 按钮内文字变成已收藏 expect(buttonOfCollect.text()).to.equal('已收藏'); })
it('当点击"已收藏"时, 我的收藏会减少1个数量, 且按钮内文字变成"收藏"', () => { const wrapper = mount(SinaWeibo); const buttonOfUnCollect = wrapper.find('.uncollectWeibo'); const countOfMyCollect = Number(wrapper.find('.collect-num span').text()); //触发点击事件 buttonOfUnCollect.trigger('click'); const countOfMyCollectAfterClick = Number(wrapper.find('.collect-num span').text()); //断言: 我的收藏数量会减1 expect(countOfMyCollectAfterClick).to.equal(countOfMyCollect - 1 ); //断言: 按钮内文字变成已收藏 expect(buttonOfUnCollect.text()).to.equal('收藏'); })
测试结果:
5.当点击"赞"(4), 我的赞(8)会增加1个数量, 且按钮内文字变成"取消赞"; 点击"取消赞"(3)时, 我的赞(8)会减少1个数量, 且按钮内文字变成"赞"
it('当点击"赞", 我的赞会增加1个数量, 且按钮内文字变成"取消赞"', () => { const wrapper = mount(SinaWeibo); const buttonOfLike = wrapper.find('.dislikedWeibo'); const countOfMyLike = Number(wrapper.find('.like-num span').text()); //触发点击事件 buttonOfLike.trigger('click'); const countOfMyLikeAfterClick = Number(wrapper.find('.like-num span').text()); //断言: 我的赞会增加1个数量 expect(countOfMyLikeAfterClick).to.equal(countOfMyLike + 1); //断言: 按钮内文字变成取消赞 expect(buttonOfLike.text()).to.equal('取消赞'); });
it('当点击"取消赞", 我的赞会减少1个数量, 且按钮内文字变成"赞"', () => { const wrapper = mount(SinaWeibo); const buttonOfDislike = wrapper.find('.likedWeibo'); const countOfMyLike = Number(wrapper.find('.like-num span').text()); //触发点击事件 buttonOfDislike.trigger('click'); const countOfMyLikeAfterClick = Number(wrapper.find('.like-num span').text()); //断言: 我的赞会增加1个数量 expect(countOfMyLikeAfterClick).to.equal(countOfMyLike - 1); //断言: 按钮内文字变成取消赞 expect(buttonOfDislike.text()).to.equal('赞'); });
测试结果
项目地址:
Git仓库: https://github.com/Lee-Tanghu...