在《前端進階之路: 前端架構設計(3) - 測試核心》這邊文章中, 經過分析了"傳統手工測試的侷限性" 去引出了測試驅動開發的理念, 並介紹了一些測試工具. 這篇文章我將經過一個Vue的項目, 去講解如何使用mocha & karma, 且結合vue官方推薦的vue-test-utils去進行單元測試的實戰.javascript
我爲本教程寫一個示例庫, 您能夠直接跳過全部安裝過程, 安裝依賴後運行該示例項目: css
若是想一步步進行安裝, 也能夠跟着下面的步驟進行操做:html
//命令行中輸入(默認閱讀該文章的讀者已經安裝vue-cli和node環境) vue init webpack vueunittest
注意, 當詢問到這一步Pick a test runner(Use arrow keys)
時, 請選擇使用Karma and Mocha
前端
接下來的操做進入項目npm install
安裝相關依賴後(該步驟可能更會出現PhantomJS這個瀏覽器安裝失敗的報錯, 不用理會, 由於 以後咱們不使用這個瀏覽器), npm run build
便可.vue
接下來安裝karma-chrome-launcher
, 在命令行中輸入java
npm install karma-chrome-launcher --save-dev
而後在項目中找到test/unit/karma.conf.js
文件, 將PhantomJS
瀏覽器修改成Chrome
不要問我爲何不使用PhantomJS, 由於常常莫名的錯誤, 改爲Chrome就不會!!!)node
//karma.conf.js var webpackConfig = require('../../build/webpack.test.conf') module.exports = function (config) { config.set({ //browsers: ['PhantomJS'], browsers: ['Chrome'], ... }) }
安裝Vue.js 官方的單元測試實用工具庫, 在命令行輸入:webpack
npm install --save-dev vue-test-utils
當你完成以上兩步的時候, 你就能夠在命令行執行npm run unit
嚐鮮你的第一次單元測試了, Vue腳手架已經初始化了一個HelloWorld.spec.js
的測試文件去測試HelloWrold.vue
, 你能夠在test/unit/specs/HelloWorld.spec.js
下找到這個測試文件.(提示: 未來全部的測試文件, 都將放specs
這個目錄下, 並以測試腳本名.spec.js
結尾命名!)git
在命令行輸入npm run unit
, 當你看到下圖所示的一篇綠的時候, 說明你的單元測試經過了!github
下面是一個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的做用是運行測試腳本, 要對上面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"
), 第二個參數是一個實際執行的函數.
咱們在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), 錯誤信息以下:
若是你們對於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/
上面的測試用例中, 以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/
//Counter.spec.js import Vue from 'vue' import Counter from '@/components/Counter' //引入vue-test-utils import {mount} from 'vue-test-utils'
下面我將在Counter.spec.js
測試腳本中對Counter.vue
中<h3>
的文本內容進行測試, 你們能夠直觀的感覺一下使用了Vue-test-utils後對.vue
單文件組件的測試變得多麼簡單.
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'); })
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去完成.
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
運行.
//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...