讀古今文學網 > Vue2實踐揭秘 > 第5章 Vue的測試與調試技術 >

第5章 Vue的測試與調試技術

我從事軟件開發10多年來一直倡導敏捷開發,從測試驅動開發到行為驅動式開發,我始終都是一名踐行者。很多開發者都認為測試對於項目來說是可有可無的,甚至不將測試工作納入到開發進度中。

其實,測試是一個非常美妙的世界,一旦進入根本停不下來!

對於Vue這一類重度採用前端技術的項目而言,測試更加是一個開發的加速器。代碼寫完了我們要確認是否符合要求,始終得一邊運行一邊測試或者調試。一個功能點可能需要調試十幾次甚至上百次,每次都做相同的人工操作,再有耐心的人都會隨便填寫一些測試數據,在邏輯上跑通就算過關了。這也導致軟件發佈後,在生產環境中出現了各種「不可預知」的問題。而最壞的情況是,如果手工重現這些問題並且修復後,遇到項目迭代要重新檢測程序是否能正常運作,你還能記得這些問題的操作過程和輸入的數據嗎?

我經常看到不少的前端開發人員基本上都只進行人工測試,屏幕左邊開一個編碼窗口,右邊打開一個瀏覽器,左邊寫代碼右邊看效果。當然這種編碼場景我也用,但僅限於製作或者調試界面樣式和頁面佈局的時候,而不是從開始到最終都這樣做!支持「熱加載」是讓我喜歡上Vue開發的其中一個原因,因為有了這個功能,無論是編寫樣式又或者需要用視覺檢查功能時都將無比高效。我更願意讓程序幫我檢查代碼是否正確,雖然程序是自己寫的,但人是會出錯的,何況只是用視覺判斷程序的正確性,出錯幾乎是無可避免的。其次,我們總會遇到這樣一種情況,一個項目完成上線後就去忙其他項目了,突然某天收到一個緊急的命令要在原來那個程序上加點功能或者修改一個小bug,如果沒有測試,誰遇到這種情況都心慌,甚至連改程序的勇氣都沒有,誰說得準程序一改又會出現什麼錯誤呢?而且還得憑著記憶去將以前做過的人工測試重新做一次才能安心。

測試不單單是一個質檢員,它還是一個異常備忘錄,甚至可以說是一個為我們保駕護航的好助手。

你曾試遇到過修改代碼後,導致其他地方出現問題的時候嗎?相信絕大多數程序員都遇到過。因為這幾乎是不可避免的,特別是在規模龐大的代碼面前,代碼與代碼之間可能是環環相扣的,改變一處會影響另一處。

但如果這種情況不會發生呢?如果你有一種方法能知道改變後會出現的結果呢?這無疑是極好的。因為修改代碼後無須擔心會破壞什麼東西,從而程序出現bug的概率更低,在debug上花費的時間更少。

這就是單元測試的魅力,它能自動檢測代碼中的任何問題。在修改代碼後進行相應的測試,若有問題,能立刻知道問題是什麼,問題在哪和正確的做法是什麼。這可以完全消除任何猜測!

5.1 Mocha入門

在開始介紹Vue的單元測試之前,我們需要做一些基本知識的準備,第2章介紹了vue-cli腳手架建立的單元測試骨架的組成與運行原理。僅僅瞭解這些內容是不夠的,編寫單元測試之前必須瞭解測試框架的用法和與之配套的編程工具的使用方法。

基本的測試骨架

單元測試文件都放在test/unit/specs 目錄下,這是在第2章就已經定下的工程目錄使用約定,且每個測試文件要以*spec.js 文件名結尾。創建test/unit/specs/array.spec.js 文件,寫一個對JavaScript的Array 對象的基本功能的測試示例,只有實踐才是快速學習的捷徑。

每個測試案例文件都會遵循以下基本模式。首先,有個describe塊:

    describe('Array',  => {
      // 測試序列
    })  

describe用於把單獨的測試聚合在一起,在TDD中稱之為測試序列(Suite),也可以將它看作功能分組。第一個參數用於指示測試什麼,第二個參數是一個匿名函數。這裡我們先來舉一個最簡單的列子,在本例中,由於我們打算測試Array功能,那麼將以Array這個對象的名稱作為測試序列的名稱。

序列的嵌套

測試序列是一種分組方式,它也允許以樹狀結構對子序列進行嵌套,從而可以將一個大的測試序列劃分為更小的組成部分。例如:

    describe('User', => {
      describe('Address',=>{
        

// ...
      })
    })  
測試用例

測試序列內至少有一個it塊,否則Mocha會忽略測試序列內的內容,不會執行任何的動作。例如:

    describe('Array',  => {
      it('應該在初始化後長度為0',  => {
        // 這裡編寫測試代碼的實現
      })
    })  

it用於創建實際的測試,它在TDD中被稱為測試用例(Test-Case)。其第一個參數是對該測試的描述,且該描述的語言應該是我們日常用語的句式(而非編程語言)。測試用例用it作為函數名,其用意就是希望通過程序引導開發人員用書面語言去描述測試用例的作用,如:「It should be done with ...」,或者「It should be have some value」等。直接翻譯成我們中國人的句式就將變成:「應該…輸出XXX結果」,或者「應該…完成XXX操作」這樣的句式。

這是一種偏向於行為式的描述方式(雖然Mocha號稱是支持行為式驅動的測試框架,然而其只能屬於類行為式測試,而非真正的行為驅動式測試框架,關於行為式驅動開發在下文自有交待,在此暫且放下),對於單元測試來說可以將其歸類為一種「一般性的通用描述」。顧名思義,單元測試的對象是某個特定的類或者模板,因此describe內才直接用類名進行描述,那麼it內最應該用的描述方式是「方法名」或「屬性名」。仍然以Array為例(因為它的方法屬性JS程序員應該都懂):

    describe('Array',  => {
      it('#slice', => {
        // ...
      })

      it('#join', => {
        // ...
      })
    })  

為什麼用方法名或者屬性名是最好的呢?因為好的程序應該是能達到自描述的,如果從名稱上都看不出其用法,那麼是否就可以從使用上驗證這個方法或屬性的命名有問題而需要重構呢?這不正是寫測試的其中一種目的所在嗎?

所有Mocha測試都以同樣的骨架編寫,雖然它還有其他的寫法,但上述寫法是一種推薦用法,所以我們都應該遵循這個相同的基本模式:

(1)測試序列用類名命名。

(2)當測試用例用於測試指定方法或屬性默認效果時用「#+成員名」方式命名。

(3)當測試用例的測試內容不能歸屬於某個方法或屬成員時用「應該…輸出XXX或應該…完成XXX操作」的句式陳述。

在某些情況下我們希望Mocha跳過功能測試,那麼我們可以使用xdescribe函數取代describe,這樣Mocha將不會執行它們,而單純將其視為「跳過」的狀態,同理it也可以用xit來表達忽略執行。

這是寫好單元測試的一個重點,「思路決定行為」,文字性的內容表達準確清楚才能得到正確的測試結果。文字性表述就是測試的架構設計,這也是為何要耗費這麼多文字來論述這個文字性描述規則的緣由。

斷言

現在我們已經知道如何組織與編寫測試用例了,接下來就需要對測試的結果進行判斷,我們稱這一過程為「斷言」(Assertion)。Mocha自身並沒有配置斷言庫,在第2章中我們也瞭解到,vue-cli的webpack模板已經通過Karma為我們的測試框架配置了Sinon-Chai這個庫了,它基於Sinon和Chai兩個庫的合成優化版本,所以我們並不需要引入其他的斷言庫了。當前的測試用例的上下文this內被注入了Chai,所以可以直接使用Chai提供的標準斷言語法。其他的斷言庫還有很多,也可以按自己的喜好定制,在此就不做過多的表述,畢竟以一書之篇幅也難以盡述。

Chai斷言庫有兩種語法,一種是expect語法,另一種是should語法,兩種語法只是在表達順序上略有不同,expect語法則更加通用,畢竟這些都是仿照「Rspec」做出來的,我們就採用正統的最佳實踐。

具體的句式是expect([實際被檢測值]).to語法,其表達的語法都是期待一個「實際」(向expect傳遞的參數)值等於一個「期待」值。

上例中測試Array的初始值應為空,即我們需要創建一個數組並確保它為空:

    describe('Array',  => {
      it('應該初始化為空數組',  => {
        var arr = 
        expect(arr).to.be.lengthOf(0)
      

})
    })  

實際值是測試代碼的結果,期待值是預想的結果。由於數組的初始值應為空,因此,在該案例中的期待值是0。

上述的to是一個陳述式的鏈式接口,它們只是為了讓斷言更加容易理解,將它們添加進斷言中使句子變得囉唆但是增加了易讀性,它們並不會提供任何測試功能:

● to;

● be;

● been;

● is;

● that;

● and;

● have;

● with;

● at;

● of;

● same;

● a;

● an。

緊跟在上述這些鏈式接口後的才是斷言方法,Chai中的斷言方法非常多,下文中還補充了Sinon-Chai為仿真測試而加入的其他的一些基於Sinon的斷言。為了保持閱讀的一致性,Chai的斷言API放在了本書的「附錄A——Chai斷言參考」內,當你開始使用斷言時它們就在那裡等著你。

鉤子

在功能測試內,每個測試場景運行在一個獨立的進程內,測試之間是不應該存在依賴關係的。但是經常會出現這樣的情況,多個場景之間可能會執行同樣的初始化操作,或者測試完成後的變量或數據清理工作。面對這些情況,Mocha提供了4個鉤子函數在describe內進行統一的調用。

● beforeEach——在每個場景測試執行之前執行;

● afterEach——在每個場景執行完成之後執行;

● before——在所有場景執行之前執行(僅執行一次);

● after——在所有場景執行之後執行(僅執行一次)。

    

describe("Array",  => {
        var expectTarget = 

        beforeEach( => {
            expectTarget.push(1)
        });

        afterEach( => {
            expectTarget = 
        });

        it("應該存有一個為1的整數",  => {
            expect(expectTarget[0]).to.eqls(1)
        });

        it("可以有多個的期望值檢測",  => {
            expect(expectTarget[0]).to.eqls(1)
            expect(true).to.eqls(true)
        });
    });  

例如,我們可以創建一個Vue實例,在各個測試用例中共享:

    describe("UkButton",  => {
        let vm = undefined

        before( => {
            vm = new UkButton({propsData:{
              color:'primary'
            }}).$mount
        })

        after( => {
            vm.destroy
        })

        it("設置Button的顏色",  => {
            expect(vm.$el.getAttribute('class')).to.eqls('uk-button
uk-button-primary')
            vm.componentOptions.propsData.color = 'success'
        })

        it("Button的顏色應該被改成了success",  => {
            expect(vm.$el.getAttribute('class')).to.eqls('uk-button
uk-button-success')
        

})
    })  
異步測試

Vue代碼中會在很多情況下出現異步調用,例如上傳、API調用、加載一個外部資源等,對這類代碼進行測試時就需要異步測試用例的支持。Mocha的異步測試用法與Jasmine一模一樣,就是在it內加入一個done函數,在所有的斷言執行完成後調用done就可以釋放測試用例並告知Mocha測試的執行結果。

例如,我們有一個User對象,這個對象具有一個save方法,這個方法通過AJAX將數據保存到服務端。如果服務端沒有返回錯誤,那麼我們就認為這個save方法是成功的,具體的代碼如下所示。

    describe('User',  => {
      describe('#save',  => {
        it('應該成功保存到服務端且不會返回任何錯誤信息', done => {
          const user = new User('Luna')
          user.save(err => {
            if (err) done(err) // 如果返回錯誤碼直接將錯誤碼輸出至控制台
            else done
          })
        })
      })
    })  

將上述代碼寫得更簡潔一點,可以將done作為回調參數使用:

    describe('User',  => {
      describe('#save',  => {
        it('應該成功保存到服務端且不會返回任何錯誤信息', done => {
          const user = new User('Luna')
          user.save(done)
        })
      })
    })  

使用Promises的另一種替代方案就是將Chai斷言作為it的返回值,將Chai斷言作為一個Promises對像返回讓Mocha進行鏈式處理,但要實現這樣的效果,你需要一個叫chai-as-promised的庫支持(https://www.npmjs.com/package/chai-as-promised)。在Mocha v3.0.0以後的版本中,如果直接構造ES6上的Promise對像則會被Mocha認為是非法的:

       it('應該完成此測試', (done) => {
         return new Promise(resolve => {
          

assert.ok(true);
          resolve
        })
          .then(done)
      })  

這樣做的結果將得到如下的異常信息:"Resolution method is overspecified. Specify a callback or return a Promise; not both.. In versions older than v3.0.0, the call to done is effectively ignored."。

待定的測試

「待定測試」是實際開發過程中用得很多的一種方式,測試驅動的開發簡單來說就是:編寫失敗的測試→實現代碼→使測試通過→重構。然而,在實現過程中你會發現很難分清楚哪些「失敗」的測試是要實現的,哪些是因為代碼有問題無法通過測試而要重構的。為了將它們區分開來,我們可以將第一步中的「編寫失敗的測試」調整為「編寫待定的測試」,這樣就能很清楚地將它們區分開來。

在Mocha中只要我們不向it函數傳入實現測試的函數(第二個參數),Mocha就會默認這個測試是待定的。

    describe('Array', => {

      describe('#indexOf', => {
        // pending test below
        it('should return -1 when the value is not present');
      })

    })  

在輸出時待定的測試顯示的字體顏色是黃色的(失敗是紅色,通過是綠色),這樣我們一眼就能看出哪些測試的功能是要去實現的了。

重試

對於端到端測試,由於要使用Selenium向外部或者其他服務發起請求,而且Selenium的運行性能並不高,很可能導致我們的測試由於超時或者運行過快而出現我們並不希望看到的失敗結果。Mocha提供了一個retries的方法,在指定的次數內如果出現失敗,Mocha並不會直接報告測試錯誤,而是在指定次數內重新嘗試運行測試直至運行成功為止:

    describe('重試',  => {

      // 指定最大的重試次數為4次
      this.retries(4)
      

beforeEach(=>{
        browser.get('http://www.yahoo.com')
      })

      it('應該在第三次重試後成功',  => {
        this.retries(2)
        expect($('.foo').isDisplayed).to.eventually.be.true
      })
    })  

5.2 組件的單元測試方法

這是一項在Vue開發中必須掌握的技能,掌握了組件的單元測試就能獨立地運行一個組件,並且測試你編寫的所有的方法、屬性和事件是否與你的設計相符。而且這些是自動化運行的,運行一個指令就能知道所有被測組件是否正常。如果沒有組件單元測試而只是在頁面運行時觀察組件是否正常,一旦出現錯誤就很難判斷這些錯誤是由頁面引發的還是組件本身所引起的。「保障一台汽車的生產質量得從一顆螺絲釘開始。」

如何對 Vue 組件進行測試

假設我們有以下的一個組件:

    // ~/src/components/my-component.js
    export default {
        template: '<span>{{msg}}</span>',
        props: ['msg'],
        created  => {
            console.log("Created");
        }
    }  

問題

我們應如何測試my-component在頁面中的實際運行效果?

分析

(1)只運行<my-component>組件,在它通過測試前不需要放到真正的頁面上運行。

(2)只需要檢測這個組件最終輸出的HTML內容就可判定是否通過測試。

    <my-component msg='你好'></my-component>  

正確輸出的HTML應為:

    <span>你好</span>  

這就是我們對這個組件的最基本測試需求,先來建立一個單元測試程序的基本結構:

    // test/unit/spec/my-component.spec.js
    import Vue from 'vue/dist/vue'
    import MyComponent from 'components/my-component'

    describe('my-component',  => {
        it('$mount',  => {
          // 此處填寫測試代碼
        })
    })  

注意: Karma配置了只加載具有*.spec.js後綴的文件,所以我們的測試文件都必須以*.spec.js結尾,否則會被Karma忽略。

接下來通過Vue.extend方法構建一個繼承至VComponent的測試容器組件,在template屬性中直接寫<my-component>在Vue組件內的真實用法,然後實例化這個容器組件,最後從$el變量中獲取Vue最終生成的內容,具體代碼如下:

    const expectedMsg = '你好'

    // 構造測試容器組件
    const HtmlContainer = Vue.extend({
        data  {
            return {
              text:expectedMsg
            }
        },
        template:`<my-component :msg="text"></my-component>`,
    })

    const vm = new HtmlContainer
    epxect(vm.$el.querySelector('span').textContent).to.be.eq(expectedMsg)  

運行這個測試:

    $ npm run unit  

輸出結果如下圖所示。

在項目代碼中我們要追求代碼的簡潔,其實寫測試更需要嚴格地遵守這一原則,我們應該不惜一切地用更少的代碼來完成同樣的事情。用這種眼光來看,上述代碼就顯得有點冗余了。對於這種只讀型的組件我們其實可以寫得更加簡單,根本不需要測試容器,直接構造MyComponent實例就夠了:

    const vm = new MyComponent({
        propsData : {
            msg: expectedMsg
        }
    })
    expect(vm.$el.textContent).to.be.eq(expectedMsg)  

這樣是不是更直接?這裡需要注意的是,Vue組件用props定義公共屬性,但實例化時傳入的卻是propsData,如果你不仔細地閱讀Vue的官方API,很可能就會錯用這個構造函數了(https://vuejs.org/v2/api/#propsData)。

在測試時通過程序直接構造Vue實例,引用Vue實例時一定要引用/vue/dist/vue.js而不能採用vue.common.js,否則會出現「You are using the runtime-only build of Vue where the template option is not available. Either pre-compile the templates into render functions, or use the compiler-included build. (found in root instance)」的警告提示。

創建幫助方法

我們在很多的單元測試中都需要手工創建一個Vue的實例入口作為測試容器,這種代碼非常冗余,因此我們可以創建一個幫助方法來去除這種重複性。

    import Vue from 'vue'

    export const getVM = (render, components) => {
      return new Vue({
        el: document.createElement('p'),
        render,
        components
      }).$mount
    }  

有了這個getVM的幫助方法,我們就可以在單元測試中直接寫入渲染的調用邏輯和使用的依賴組件,這樣一來就能在很大程度上減輕單元測試的代碼量:

    import {getVM} from '../helper'
    import Hello from 'src/Hello.vue'

    describe("Render",=>{

        it("#mount",=> {

            const vm = getVM(<hello>
            </hello>)

            expect(vm.$el.textContent).to.eq('Hello')
        })
    })  

5.3 單元測試中的仿真技術

仿真技術就是用代碼工具模擬出實際運行環境中存在的支撐服務,最常用的就是後端服務仿真,在完全沒有運行任何後端的情況下「偽造」出一個給測試專用的後端。

那為什麼要在測試中運用仿真技術呢?

我們都知道一個完整的Web程序必然由後端與前端組成,採用Vue這種富前端框架我們可以將前後端的開發獨立進行。在這種開發模式下,前後端的開發進度未必是對稱的,那就有可能出現前端的某個組件開發完成,它所依賴的後端API可能還沒有開發出來,此 時前端程序該如何測試呢?還有另一種情況就是後端服務已經存在了並且已投入實際生產運行中,此時的前端開發可能只是一種應用擴展或者升級,一旦需要運行前端進行測試,有可能會向後端發送一些對實際運行毫無用處的數據,甚至會導致數據的混亂。這些情況下測試前端程序就一定要與後端程序脫離,讓前端從開發到測試的整個過程完全獨立,擺脫所有的外部依賴,此時就需要用仿真技術去模擬這些必要的支持服務了。

為JS世界提供仿真技術的最優秀的庫就離不開Sinon,Sinon(http://sinonjs.org)提供了一系列的代碼工具幫助你很容易地創建「測試替身」來消除複雜性,像它名字暗示的一樣,測試替身用來替換測試中的部分代碼。簡單來說,Sinon允許你把代碼中難以被測試的部分替換為更容易測試的內容。測試一段代碼時,你不希望它被測試以外的部分影響。如果有任何外部因素可以影響測試,那麼這個測試就會變得更複雜並且很容易失敗。

如果你想測試一段發送AJAX的代碼,該怎麼做呢?你需要運行一個服務器並且確保它返回了測試需要的數據。這種方式導致準備測試環境變得很複雜,同時也給編寫和執行單元測試帶來很大的不便。如果你的代碼依賴於時間(例如重複和超時)又會怎麼樣呢?假設一段代碼在執行操作之前要等待1秒鐘,該怎麼辦?你可能會通過setTimeout來把測試代碼的執行延遲1秒鐘,但這樣會導致測試變慢。如果這個等待間隔比1秒鐘更長呢?比如5分鐘。我猜你一定不想在每次執行測試代碼前都等上5分鐘。

通過使用Sinon,我們可以解決以上這些(還有很多其他的)問題,並降低測試的複雜性。

Sinon有很多功能,但是大部分都是建立在它自身之上的。在掌握了一部分之後,就自然而然地瞭解下一部分。因此當學習了Sinon的基礎知識並瞭解各個組件的功能之後,使用Sinon就會變得很容易。

為了讓關於這些被調用函數的討論變得更簡單,我會稱它們為依賴。我們要測試的方法依賴於另一個方法的返回值。

可以說,使用Sinon的基本模式就是使用測試替身替換掉不確定的依賴,例如:

● 當測試AJAX時,把XMLHttpRequest替換為一個模擬發送AJAX請求的測試替身。

● 當測試定時器時,把setTimeout替換為一個偽定時器。

● 當測試數據庫訪問時,把mongodb.findOne替換為一個可以立即返回偽數據的測試替身。

由於JavaScript是非常靈活的,我們可以把任何方法替換成其他內容。測試替身只不過是把這個想法更進一步罷了。使用Sinon,我們可以把任何JavaScript函數替換成一個測試替身。通過配置,測試替身可以完成各種各樣的任務來讓測試複雜代碼變得簡單。

Sinon將測試替身份為3種類型:

● Spies ——可以模擬一個函數的實現,檢測函數調用的信息。

● Stubs ——與Spies類似,但是會完全替換目標函數。這使得一個被stubbed的函數可以執行任何你想要的操作,例如拋出一個異常,返回某個特定值等。

● Mocks ——通過組合Spies和Stubs,使替換一個完整對像更容易。

此外,Sinon還提供了其他的輔助方法:

● Fake timers——可以用來穿越時間,例如觸發一個setTimeout;

● Fake XMLHttpRequest and server——用來偽造AJAX請求和響應。

有了這些功能,Sinon就可以解決外部依賴在測試時帶來的難題。如果掌握了有效利用Sinon的技巧,你就不再需要任何其他工具了。

揭秘 Sinon

Sinon功能強大,可能看上去很難理解它是如何工作的。為了更好地理解Sinon的工作原理,讓我們看一些和它工作原理有關的例子。這將有利於我們更好地理解Sinon究竟做了哪些工作並在不同的場景中更好地利用它。

我們也可以手工創建spy、stub或是mock。使用Sinon的原因在於它使得這個過程更簡單了——手工創建通常比較複雜。不過為了理解Sinon,還是讓我們看看如何進行手工創建。

首先,spy在本質上就是一個函數包裝器:

    // 一個簡單的spy輔助函數
    function createSpy(targetFunc) {
        const spy =  => {
          spy.args = arguments
          spy.returnValue = targetFunc.apply(this, arguments)
          return spy.returnValue
        };

        return spy
    }

    // 基於一個函數創建spy
    const sum =(a, b) => a + b

    const spiedSum = createSpy(sum)

    spiedSum(10, 5)

    

console.log(spiedSum.args) // 輸出: [10, 5]
    console.log(spiedSum.returnValue) // 輸出: 15  

使用一個像這樣的方法可以很容易地創建spy。但是要明白,Sinon的spy提供了包括斷言在內的豐富得多的功能,這使得使用Sinon相當容易。

那麼Stub呢?

要創建一個簡單的stub,只需把一個函數替換成另一個:

    const stub =  => { }

    const original = thing.otherFunction
    thing.otherFunction = stub

    // 現在開始,所有對thing.otherFunction的調用都會被stub的調用所取代  

但同樣需要指出的是,Sinon的stub有若干優勢:

● 包含了spy的所有功能;

● 可以使用stub.restore輕鬆地恢復原始函數;

● 可以針對Sinon stub使用斷言。

Mock只不過是把spy和stub組合在一起,使得可以靈活地使用它們的功能。

雖然Sinon某些時候看起來使用了很多「魔法」,但大多數情況下,你都可以使用自己的代碼實現相同的功能。與自己開發一個庫比起來,使用Sinon只不過是更方便罷了。

5.3.1 調用偵測(Spies)

Spies是Sinon中最簡單的功能,其他功能都是建立在它之上的。Spies的主要用途是收集函數調用的信息,也可以用它來驗證諸如某個函數是否被調用過。

    const handler = sinon.spy

    // 我們可以像調用函數一樣調用一個spy
    handler('Hello', 'World')

    // 現在我們可以獲取關於這次調用的信息
    console.log(handler.firstCall.args);

    // 輸出: ['Hello', 'World']  

sinon.spy返回一個spy對象。該對像不僅可以像函數一樣被調用,還可以收集每次被調用時的信息。在上邊的例子中,firstCall屬性包含了關於第一次調用的信息,比如firstCall.args包含了這次調用傳遞的參數。

雖然可以像上例中一樣利用sinon.spy創建一個匿名spy,但更常見的做法是把一個現有函數替換成一個spy。

    let user = {
      // ...
      setName (name) {
        this.name = name
      }
    }

    // 用setNameSpy替換掉原有的setName方法
    const setNameSpy = sinon.spy(user, 'setName')

    // 現在開始,每次調用這個方法時,相關信息都會被記錄下來
    user.setName('Darth Vader')

    // 通過spy對象可以查看這些記錄的信息
    console.log(setNameSpy.callCount)
    // 輸出: 1

    // 重要的最後一步,移除spy
    setNameSpy.restore  

把一個現有函數替換成一個spy與前一個例子相比並沒有什麼特殊之處,除了一個關鍵步驟:當spy使用完成後,切記把它恢復成原始函數,就像上邊例子中最後一步那樣。如果不這樣做,你的測試可能會出現不可預知的結果。

在實踐中,你可能不會經常用到spy,往往更多地用到stub。但需要驗證某個函數是否被調用過時,spy還是很方便的:

    const myFunction = (condition, callback) => {
      if (condition) {
        callback
      }
    }

    describe('myFunction',  => {
      it('should call the callback function',  => {
        let callback = sinon.spy
        

myFunction(true, callback)
        assert(callback,calledOnce)
      });
    });  

5.3.2 Sinon的斷言擴展

在繼續討論stubs之前,讓我們快速看一看Sinon的斷言,使用spies(和stubs)的大多數環境中,需要通過某種方式來驗證結果。

可以使用任何類型的斷言來驗證。在上一個關於callback的例子中,我們使用了Chai提供的assert方法來驗證值是否為真。

    assert(callback.calledOnce)  

這種斷言方式的問題在於測試失敗時的錯誤信息不夠明確。我們只會得到一條類似「false不是true」這樣的信息。你可能已經想到了,這樣的信息對於確定測試為何會失敗並沒有什麼幫助,我們還是不得不查看測試代碼來找到哪裡出錯了。這可不好玩。

為了解決這個問題,我們可以在斷言中加入一條自定義的錯誤信息。

    assert(callback.calledOnce, '回調函數尚沒有被調用')  

但是為何不用Sinon提供的斷言呢?

    describe('myFunction',  => {
      it('應該調用回調函數',  => {
        const callback = sinon.spy

        myFunction(true, callback)

        expect(callback).to.have.been.calledOnce
      });
    });  

像這樣使用Sinon的斷言可以為我們提供一種更加友好的錯誤信息。當你需要驗證更複雜的情況,比如某個函數的調用參數時,這將變得非常有用。

以下是另外一些Sinon提供的實用斷言:

● sinon.assert.calledWith可以用來驗證某個函數被調用時是否傳入了特定的參數(這很可能是我最常用的了);

● sinon.assert.callOrder用來驗證函數是否按照一定順序被調用。

和spies一樣,Sinon的斷言文檔列出了所有可用的選項。如果你習慣使用Chai,那麼有一個sinon-chai插件可供選擇,它可以讓你通過Chai的expect和should接口使用Sinon的斷言。

斷 言 說 明 spy.should.have.been.called 應該被調用 spy.should.have.callCount(n) 應該被調用n次 spy.should.have.been.calledOnce 應該被調用一次 spy.should.have.been.calledTwice 應該被調用二次 spy.should.have.been.calledThrice 應該被調用三次 spy1.should.have.been.calledBefore(spy2) 應該在spy2調用前先被調用 spy1.should.have.been.calledAfter(spy2) 應該在spy2調用後被調用 spy.should.have.been.calledWithNew 如果spy被new運算符調用則返回true spy.should.always.have.been.calledWithNew spy應該總被構造函數調用 spy.should.have.been.calledOn(context) spy至少採用this關鍵字被調用一次 spy.should.always.have.been.calledOn(context) spy總是被作為this關鍵字引用調用 spy.should.have.been.calledWith(...args) 函數調用時應該帶有指定的參數args(一個或多個) spy.should.always.have.been.calledWith(...args) 函數調用時應該總是帶有指定的參數args(一個或多個) spy.should.have.been.calledWithExactly(...args) 函數調用時應該明確地指定args參數(一個或多個) spy.should.always.have.been.calledWithExactly(...args) 函數調用時應該總是明確地指定args參數(一個或多個) spy.should.have.been.calledWithMatch(...args) 調用的函數參數必須與指定的args參數匹配 spy.should.always.have.been.calledWithMatch(...args) 調用的函數參數必須總是與指定的args參數匹配 spy.should.have.returned(returnVal) 應該返回returnVal的值 spy.should.have.always.returned(returnVal) 應該總是返回returnVal的值 spy.should.have.thrown(errorObjOrErrorTypeStringOrNothing) 應該拋出指定類型的異常 spy.should.have.always.thrown(errorObjOrErrorTypeStringOrNothing) 應該總是拋出指定類型異常

如果用expect式的語法則是用expect(value)取代spy.should,例如:

    const = hello(name, cb) => {
        cb(`hello ${name}`)
    }

    describe("hello",  => {
        it('應該在回調後輸出問候信息',  => {
            const cb = sinon.spy

            hello('world', cb)

            expect(cb).to.have.been.calledWith('hello world')
            

// 或者用Chai的should語法
            cb.should.have.been.calledWith('hello world')
        });
    });  

5.3.3 存根(stub)

由於其靈活和方便,stubs成為了Sinon中最常用的測試替身類型。它擁有spies提供的所有功能,區別在於它會完全替換掉目標函數,而不只是記錄函數的調用信息。換句話說,當使用spy時,原函數還會繼續執行,但使用stub時就不會。

這使得stubs非常適用於以下場景:

● 替換掉那些使測試變慢或是難以測試的外部調用;

● 根據函數返回值來觸發不同的代碼執行路徑;

● 測試異常情況,例如代碼拋出了一個異常。

我們可以用類似創建spies的方法創建stubs:

    const stub = sinon.stub
    stub('hello')
    console.log(stub.firstCall.args)

    // 輸出: ['hello']  

我們可以創建匿名stubs,和使用spies時一樣,但只有當你用stubs替換一個現有函數時它才開始真正地發揮作用。

舉例來說,如果有一段代碼使用了vue-resource的$http功能,那這段代碼就會很難被測試。這段代碼會向某台服務器發送請求,你不得不保證測試期間該服務器的可用性。或者你可能會想到在代碼裡增加一段特殊邏輯以便在測試環境下不會真正地發送請求——這可犯了大忌。在絕大多數情況下你應該保證代碼中不會出現針對測試環境的特殊邏輯。

我們可以通過Sinon把$http功能替換為一個stub,而不是尋求其他糟糕的實現方式。這會使得測試變得很簡單。

以下是一個我們要測試的函數。它接受一個對像作為參數,並通過$http把該對像發送給某個預定的URL。

    export default {
      

methods: {
        saveUser (user) {
         this.$http.post('/users', {
            first: user.firstname,
            last: user.lastname
          })
        }
      }
    }  

通常情況下,由於涉及AJAX調用和某個特定的URL,對它進行測試是比較困難的。但如果我們使用了stub,這就變得很簡單。

比方說我們要確保傳給saveUser的回調函數在請求結束後被正確執行。

    describe('saveUser',  => {
      it('應該在保存成功後進行回調',  => {

        // stub $.post,這樣就不用真正地發送請求
        const post = sinon.stub($, 'post')
        post.yields

        // 針對回調函數使用一個spy
        const callback = sinon.spy

        saveUser({
            firstname: 'Han',
            lastname: 'Solo'
        }, callback)

        post.restore

        expect(callback).to.have.been.calledOnce
      })
    })  

這裡我們把AJAX方法替換成了一個stub。這意味著代碼裡並不會真的發出請求,因此也就不需要相應的服務器了,這樣我們就對測試代碼裡的邏輯取得了完全控制。

由於我們要確保傳給saveUser的回調函數被執行了,我們指示stub要設置為yield。這意味著stub會自動執行作為參數傳入的第一個函數。這就模擬了$http.post的行為——請求一旦完成就執行回調函數。

除了stub,我們還在測試中創建了一個spy。也可以使用一個普通函數作為回調,但是 使用了spy後利用Sinon提供的sinon.assert.calledOnce斷言可以很容易地驗證結果。

在使用stub的大多數情況下,可以遵循以下模式:

(1)找到導致問題的函數,比如$http.post。

(2)觀察它是如何工作的以便在測試中模擬它的行為。

(3)創建一個stub。

(4)配置stub以便按照期望的方式工作。

Stub不必模擬目標對象的所有行為。只要模擬在測試中用到的行為就夠了,其他的都可以忽略。

Stub的另一個常見使用場景是驗證某個函數被調用時是否傳入了正確的參數。

例如,針對AJAX的功能,我們想驗證發送的數據是否正確:

    describe('saveUser',  => {
      it('應該向指定的URL發送正確的參數',  => {

        // 像之前一樣為$.post設置stub
        const post = sinon.stub(vm.$http, 'post');

        // 創建變量,保存我們期望看到的結果
        const expectedUrl = '/users'
        const expectedParams = {
          first: 'Expected first name',
          last: 'Expected last name'
        }

        // 創建將要作為參數的數據
        const user = {
          firstname: expectedParams.first,
          lastname: expectedParams.last
        }

        saveUser(user, =>{} )
        post.restore

        sinon.assert.calledWith(post, expectedUrl, expectedParams)
      })
    })  

同樣,我們又為$http.post創建了一個stub,但這次我們沒有設置它為yield。這是因為此次的測試我們並不關心回調函數,因此設置yield就沒有意義了。

我們創建了一些變量用來保存期望得到的數據——URL和參數。創建這樣的變量是一種不錯的做法,因為這樣就可以很容易看出這個測試要測哪些數據。我們還可以利用這些值創建user變量,從而避免重複輸入。

這次我們使用了sinon.assert.calledWith斷言。我們把stub作為第一個參數傳入,因為我們要驗證這個stub被調用時是否傳入了正確的參數。

5.3.4 接口仿真(Mocks)

Mocks是使用stub的另一種途徑。如果你曾經聽過「mock對像」這種說法,這其實是一碼事——Sinon的mock可以用來替換整個對象以改變其行為,就像函數stub一樣。

基本上只有需要針對一個對象的多個方法進行stub時才需要使用mock。如果只需要替換一個方法,使用stub更簡單。

使用mock時要很小心。由於mock強大的功能,它很容易導致你的測試過於具體——測試了太多、太細節的內容——這很容易在不經意間導致你的測試變得脆弱。

與spy和stub不同的是,mock有內置的斷言。你需要預先定義好mock對像期望的行為並在測試結束前執行驗證函數。

比方說我們代碼中使用了store.js來向localStorage中寫入數據,我們希望測試一個與這部分內容相關的函數。我們可以使用一個mock來協助測試:

    describe('incrementStoredData',  => {
      it('應該將存儲值遞增1',  => {
        const storeMock = sinon.mock(store)
        storeMock.expects('get').withArgs('data').returns(0)
        storeMock.expects('set').once.withArgs('data', 1)

        incrementStoredData

        storeMock.restore
        storeMock.verify
      });
    });  

使用mock時,我們使用鏈式調用的方式定義一系列方法以及相應的返回值。除了預 先定義好行為並在測試結束前調用storeMock.verify來驗證結果,這和使用斷言驗證測試結果沒什麼兩樣。

在Sinon的mock對像術語中,執行mock.expects('something')創建了一個預期。例如,函數mock.something期望被調用。每一個預期除了mock特殊的功能,還支持spy和stub的功能。

你可能會發現大多數時候使用stub比使用mock簡單得多,這很正常。mock應該被小心地使用。

最佳實踐:使用sinon.test

無論何時使用spy、stub還是mock,都有一條重要的最佳實踐需要牢記。

如果你使用測試替身替換了一個現有函數,記得使用sinon.test。

在前面的示例中,我們使用了stub.restore或mock.restore來執行清理操作。這個操作是必要的,否則測試替身會一直存在並給其他測試帶來負面影響或是導致錯誤。

但是直接使用restore方法是有問題的。有可能在restore執行之前測試代碼就因為錯誤提前結束執行了。

有兩種方法可以解決這個問題:把所有的代碼放在一個try...catch塊中,這樣就可以在finally塊中執行restore而不用擔心測試代碼是否報錯。

還有一種更好的方式,就是把測試代碼包裹在sinon.test中:

    it('應該用存根做點什麼', sinon.test( => {
      const stub = this.stub(vm.$http, 'post')

      doSomething

      sinon.assert.calledOnce(stub)
    })
    )  

在上邊的示例中,需要注意的是,傳遞給it的第二個參數被包裝在sinon.test中。另一點要注意的是我們使用的是this.stub而不是sinon.stub。

把測試代碼包裝在sinon.test中後,我們就可以使用Sinon的沙盒特性了。它允許我們通過this.spy、this.stub和this.mock來創建spy、stub和mock。任何使用沙盒特性創建的測試替身都會被自動清理。

注意上邊的例子中沒有stub.restore操作——因為在沙盒特性下的測試裡它變得不必要了。

如果在所有地方都使用了sinon.test,那麼就可以避免由於某個測試未能清理它內部的測試替身而導致後續測試隨機失敗的情況。

5.3.5 後端服務仿真

Sinon將這種技術稱為Faker(騙子),如果直接翻譯過來有點貶義,我更喜歡將之稱為仿真。

前置仿真也就是請求仿真,這個過程並不會真正地產生XMLHttpRequest對象,因為這個對象會被Sinon產生的FakeXMLHttpRequest所取代。

    describe("Home",  => {
        before  {
            this.xhr = sinon.useFakeXMLHttpRequest
            const requests = this.requests = 
            this.xhr.onCreate = xhr => {
                requests.push(xhr)
            }
        },

        after  {
            this.xhr.restore
        }

        it("應該從服務器中圖書數據",  => {
            const callback = sinon.spy

            expect(this.requests).to.lengthOf(1)

            this.requests[0].respond(200, { "Content-Type": "application/json" },
                                 '[{ "id": 12, "title": "Vue2實踐揭秘" }]')

            expect(callback).to.be.calledWith([{ id: 12, title: "Vue2實踐揭秘" }])
        })
    })  

前置仿真的檢測標誌在於對請求內容的正確性的檢測。

服務端仿真也就是後置仿真,不管前端發出什麼樣的請求,我們只仿真後端接收請求 的處理。

後置仿真與前置仿真最大的不同之處是它完全讓前端產生一個真實的XMLHTTPRequest對象,在這個對象真正向服務端發出之前進行截獲,然後進行結果仿真並返回。

    describe('Comments',=>{
        before  => {
            this.server = sinon.fakeServer.create
        },

        after  => {
            this.server.restore
        },

        it("應該從服務器中獲取評論" ,  => {
            // 模擬服務器的最終輸出效果
            this.server.respondWith("GET", "/some/article/comments.json",
                [200, { "Content-Type": "application/json" },
                 '[{ "id": 12, "comment": "Hey there" }]'])

            const callback = sinon.spy
            myLib.getCommentsFor("/some/article", callback)
            this.server.respond

            sinon.assert.calledWith(callback, [{ id: 12, comment: "Hey there" }])
        })
    })  

5.4 調試

對於調試相信每一位程序員都不會陌生,作為前端開發者瀏覽器的調試窗口就更是不可或缺了。

Vue-DevTools

Vue-DevTools是官方提供的實時調試工具,它是一個Chrome的應用插件,可嵌入到Chrome的調試器內使用。你需要在Chrome網上應用商店內安裝vue-devtools(https://chrome. google.com/webstore/search/vue-devtools?utm_source=chrome-ntp-icon):

只要當前打開的網頁有Vue實例,這個Chrome插件就會自動解釋實例結構以及Vue實例內的變量,以便我們觀測實例運行的情況。

Vue-DevTools對於初學者來說是一個很不錯的選擇,可以很好地輔助理解Vue的運行原理。

運行期調試

另一個更實用的選擇是設置調試的斷點,要知道Vue是被webpack進行實時編譯後加載到瀏覽器的,如果在瀏覽器內的調試窗口中直接設置斷點,經常會由於代碼刷新後Sourcemap的定位發生變化或者hash發生了改變而導致斷點無法成功啟動。

別忘了vue-cli webpack模板對我們構建的Vue環境可是有一個極為好用的功能的——「熱加載」。當啟動npm run dev後,所有代碼的改變會自動地進行局部的刷新與載入,而不需要人工刷新瀏覽器。我們可以利用這一功能在源代碼內直接設置斷點,在熱加載運行重新載入代碼後直接在瀏覽器中啟動斷點。這是一個通用方案,在Vue或React中都可以執行,那就是ES的debugger關鍵字。

它就相當於一個編碼式的斷點設置,只要ES解釋器一遇到debugger關鍵字就會自動啟用當前宿主環境中的調試斷點功能,打斷程序的運行。

如下圖所示,一旦在左側的代碼窗口內加入debugger,然後按Ctrl+S保存,右側的瀏覽器運行窗口就會自動載入斷點並跳轉到設置斷點的代碼上,這是一個極為方便也是常用的開發功能。

Debugger在Chrome的效果

測試期與 IDE 的集成調試

在瀏覽器中調試很明顯只適用於對界面的精細調整,或者更準確地說是一種手工模式。 但對於我們採用Karma測試加載器來執行的全自動化測試就不太適用了。作為專業的開發人員應該使用專業的開發工具,所以JetBrains的開發工具集可謂是開發必備的IDE,我們做前端開發當然也少不了WebStrom這一強大的助手。借助WebStrom,我們擺脫了使用瀏覽器自帶的JavaScript調試器這種傳統的手工式做法,這種做法最大的缺陷是難以正確定位我們的源碼,尤其是那些沒有頁面的組件測試!

首先將JetBrains IDE Support工具安裝到Chrome中:

回到WebStrom,在主菜單中選擇「Run/EditConfigurations」選項,彈出以下的配置對話框,點擊左上角的+號增加一個Karma的配置,然後在Configuration file輸入框中選擇當前項目下的karma.conf.js,如下圖所示。

然後在代碼中需要添加斷點的地方加入debugger關鍵字:

    export default {
        created  {
          // 斷點
          debugger
          console.log("Created")
        }
    }  

然後按Ctrl+D,WebStrom就會引導Karma啟動一個Chrome實例作為宿主程序,當程序執行到debugger處時就會自動調入WebStrom內,此時我們就可以用WebStrom自帶的調試器進行變量的觀察與單步的執行操作了。

這個方法無論是Vue1.0還是Vue2.0都可用。

5.5 Nightwatch入門

Nightwatch的開發非常容易學,具體的應用我會在下一章中詳細地嵌入到示例中。為了下文描述的方便,我們需要普及一些Nightwatch簡單的背景知識。如果你已經完全掌握了Nightwatch的開發,那麼可以跳過以下這一部分。

5.5.1 編寫端到端測試

從代碼結構上說,端到端測試與單元測試的寫法是基本一致的,但在測試的設計思路上卻有著巨大的差別。單元測試標注的是局部的代碼,即對某個(些)類或者某個(些)具體方法的調用方式及其輸出結果進行測試,必要時可以掛入調試器進行斷點調試,運行測試前只需要準備某些輸入數據或者變量即可。而端到端測試則是一種相對完整的 外部操作模擬過程,它要借助Selemium服務器和WebDriver,通過代碼模擬用戶的界面操作,然後檢測界面元素應該出現的變化,要確保測試的正常運行就需要模擬整個程序運行時所有需要配置的數據或者參數。

簡言之,端到端測試側重於檢測界面的交互效果與操作邏輯是否正確。

在第2章工程化JS開發中已經介紹過如何將Nightwatch的默認測試運行器配置為Mocha,這樣一來端到端測試的結構就可以與單元測試的寫法一致,以下就為第1章中的待辦事項示例來寫一個簡單的端到端測試:

    describe('TODO界面測試', => {
        it('應該正確TODO界面',(client,done) => {
            client.url('http://localhost:8080')
                 .waitForElementVisible('body', 1000)
                 .assert.elementPresent('input[type="text"]')
                 .getAttribute('input[type="text"]','placeholder',result => {
                   this.assert.equl(result.value,'快寫下您要我記住的事吧')
                 })
                 .end
        })
    })  

這個測試的作用是運行並在瀏覽器加載待辦事項示例,加載完成後檢測是否具有一個帶有「快寫下您要我記住的事吧」提示的文本輸入框。端到端測試完全取代了我們用眼睛判斷界面是否輸出正確這一動作,也就是說,只要將所有人工檢測的過程轉化為端到端測試,那麼我們就有了一套對業務邏輯進行全自動化檢測的機制!

作為一名前端開發者,進行運行測試是最平常不過的事了,這個過程大約就是執行以下的步驟:

(1)打開瀏覽器查看運行界面。

(2)輸入仿真數據(大多數開發者通常隨便敲入一些所謂的測試數據)。

(3)用眼睛 查看輸出結果,判斷界面是否正確。

相信沒有多少開發人員是喜歡做上述這些工作的!不少軟件企業僱傭一些剛入門的菜鳥們來做這種測試,企圖保證程序的業務正確性,這往往是事與願違的。只有通過端到端測試取代人們最討厭做的重複性工作,預先輸入最正確的仿真數據才是確保業務邏輯能正確運行的關鍵!

Nightwatch使用一個瀏覽器仿真對像(上述代碼中的client)的url函數打開開發服務器地址,此時開發服務器會自動引導webpack進行編譯輸出,waitForElementVisible這個函數將等待瀏覽器加載完成,這個過程是相當緩慢的。為了避免等待超時可以用retry(2)函數進行保護,或者將waitForElementVisible的第二個等待參數的時長增加到5秒左右,加載完成後用Nightwatch的斷言工具對目標元素的屬性或者文字內容進行檢驗。

XPath 與 CSS 選擇器

由於使用斷言判斷都是針對單個元素進行的,Nightwatch提供的方法的第一個參數一般都是一個選擇器參數,如assert.elementPresent('input[type="text"]'),這個選擇器可以是XPath選擇器也可以是CSS類選擇器,默認情況下採用CSS選擇器作為元素定位的方法。如果要切換為XPath的方式對元素進行定位,可以先調用useXpath函數,使用useCss就可以重新切換為CSS選擇器,具體做法如以下代碼所示。

如果要將XPath作為默認選擇器,可以在配置文件內將use_xpath設置為true。

    this.todoDemoTest = browser => {
      browser
        .useXpath // 使用XPath選擇器
        .click("//tr[@data-search]/span[text='Search Text']")
        .useCss // 切換回CSS選擇器
        .setValue('input[type=text]', 'Vue')
    }  
BDD 式斷言

Nightwatch在v0.7版本後加入了BDD(行為式驅動)式的斷言庫,我們能採用expect語法來使用代碼斷言:

    

describe('圖書視圖', => {
        it('快速搜索',client=> {
            client
              .url('http://localhost:8080')
              .pause(1000)

            // 期待元素將在1秒內顯示
            client.expect.element('body').to.be.present.before(1000)

            // 期待元素#app具有display的樣式類
            client.expect.element('#app').to.have.css('display')

            // 期待元素具有class屬性並且包含有示例文字
            client.expect.element('body').to.have.attribute('class').which.
contains('示例')

            // 期待#searchbox元素是一個input類型的標記
            client.expect.element('#searchbox').to.be.an('input')

            // 期待#searchbox元素是可見的
            client.expect.element('#searchbox').to.be.visible

            client.end;
        })
    })  

expect接口提供了一種更加靈活、流暢並且更接近自然語言的方式來定義斷言,比原有斷言接口有著顯著的改進。唯一的缺點是它不能進行鏈式斷言,這樣會產生大量的重複性代碼。

5.5.2 鉤子函數與異步測試

Nightwatch中可以完全兼容原有Macha語法提供的before/after和beforeEach/afterEach鉤子函數。每個鉤子函數都會傳入一個Nightwatch的瀏覽器實現參數:

    describe('測試示例', => {
      before(browser => {
        console.log('準備...')
      })

      after(browser => {
        console.log('清理...')
      

})

      beforeEach(browser => {

      })

      afterEach( => {

      })

      it('第一步',browser => {
        browser
         // ...
      })

      it('第二步', browser => {
        browser
        // ...
          .end
      })
    })  

在上面的例子中,方法調用的順序如下:before→beforeEach→「第一步」→afterEach→beforeEach→「第二步」→afterEach→after。

為了增加向後兼容性,afterEach鉤子內是不會傳有browser實例參數的,只有異步鉤子afterEach(browser,done)內才會傳入該參數。

所有的鉤子及測試函數都具有與Mocha一樣的異步調用能力,每個函數的第二個參數done作為異步調用結束的回調函數。

進行異步調用時切記一定要調用done通知Nightwatch完成調用,否則會導致測試執行超時。

    describe('示例', => {
      beforeEach((browser, done) => {
        // 執行異步操作
        setTimeout(=>{
          // 完成異步任務
          done
        }, 100)
      })

      afterEach((browser, done) => {
        

// 執行異步操作
        setTimeout( => {
          // 完成異步任務
          done
        }, 200)
      })
    })  

默認情況下Nightwatch會將超時控制在10秒內(測試單元為2秒)。在某些情況下,這可能不足以避免超時錯誤,可以通過在外部全局變量中定義一個asyncHookTimeout屬性(以毫秒為單位)來增加超時量。

另外,我們可以在鉤子函數中向done函數傳入Error對像通知Nightwatch捕獲到不明確的錯誤:

    describe('示例', => {
      afterEach((browser, done) => {
        performAsync(err=>{
          if (err) {
            done(err)
          }
          // ...
        })
      })
    })  

5.5.3 全局模塊與Nightwatch的調試

我在最初使用vue-cli腳手架來創建項目時遇到一個很大的困惑,就是Nightwatch無法在Webstorm中調試!多次翻閱Nightwatch的官方文檔,發現官方有一篇專門的文章講述如何在Webstorm中啟動Nightwatch的調試(https://github.com/nightwatchjs/nightwatch/wiki/Debugging-Nightwatch-tests-in-WebStorm),可惜的是按照Nightwatch提供的方法我一直無法成功開啟Nightwatch的調試模式,這也意味者Nightwatch在Vue項目中變得極為雞肋。經過反覆的實驗,最後我才發現了這並不是Nightwatch不能開啟調試,而是vue-cli初始化的Nightwatch存在問題!

vue-cli會為我們創建一個nightwatch.conf.js和一個runner.js文件,而問題就出在runner.js文件中,打開這個文件:

    // 1. start the dev server using production config
    process.env.NODE_ENV = 'testing'
    

var server = require('../../build/dev-server.js')

    // 2. run the nightwatch test suite against it
    // to run in additional browsers:
    //   1. add an entry in test/e2e/nightwatch.conf.json under "test_settings"
    //   2. add it to the --env flag below
    // or override the environment flag, for example: `npm run e2e -- --env
chrome,firefox`
    // For more information on Nightwatch's config file, see
    // http://nightwatchjs.org/guide#settings-file
    var opts = process.argv.slice(2)

    if (opts.indexOf('--config') === -1) {
      opts = opts.concat(['--config', 'test/e2e/nightwatch.conf.js'])
    }
    if (opts.indexOf('--env') === -1) {
      opts = opts.concat(['--env', 'phantom'])
    }

    var spawn = require('cross-spawn')
    var runner = spawn('./node_modules/.bin/nightwatch', opts,{stdio:
'inherit'})

    runner.on('exit', function (code) {
      server.close
      process.exit(code)
    })

    runner.on('error', function (err) {
      server.close
      throw err
    })  

會發現這個runner實際上是用子進程加載Nightwatch和啟動開發服務器,以避免開發服務器啟動後將線程獨佔。而WebStorm的調試器只能嵌入到啟動的主進程中,也就是說,如果用debug模式來運行runner的話,調試器就只能進入到開發服務器dev-server.js引導的後端模擬程序內,而被編譯後的Vue程序將失去調試的機會!這是問題的癥結所在。

也就是說,如果要啟動Nightwatch就必須用Nightwatch作為主進程來引導所有的測試文件,而不能是開發服務器。幸好,我們並不需要去獨立編寫一個runner來引導Nightwatch,因為Nightwatch有另一個機制讓我們在鉤子裡去執行開發服務器的啟動這一異步性的操作。

大多數時候,在globals_path的配置屬性中指定一個外部文件定義全局變量會比在 nightwatch.json中定義更好。你可以根據需要針對不同的測試環境定義全局變量。例如在本地運行的測試和針對遠程生產服務器上運行的測試可能存在著不同的全局變量配置策略(http://nightwatchjs.org/)。

以上是我在Nightwatch上找到的一段關於「全局鉤子」的解析,官網上還提供了一個示例。可能本人比較愚鈍,看完他們提供的這個解釋和示例源代碼後還是覺得一頭霧水,根本不知所云,只是憑著一個老程序員的直覺感到這可能是解決異步服務啟動的一個辦法。實踐才是檢驗真理的唯一標準,動手開干!果然,這個全局鉤子真的是一個可以進行異步配置的解決方案。

Nightwatch將其分為兩個部分:「全局鉤子」與「全局配置」,但實質上就是一個東西。Nightwatch允許聲明一個全局的模塊文件,通過globals_path配置項引入到nightwatch.conf.js內,這個模塊文件可以重寫nightwatch配置文件中的內容,最重要的是它可以提供全局性的begin、beginEach、after和afterEach這4個重要的鉤子函數。這意味著在執行所有的E2E測試文件之前我們可以引導開發服務器啟動,執行webpack編譯這一系列的動作而不至於導致線程的死鎖,因為上文已經提到過鉤子函數是異步的!

另外,全局配置模塊文件的存在意義有點像我們的環境配置,有了全局配置模塊,我們可以根據不同的環境策略來配置不同運行環境下的配置參數。例如,在生產環境下我們就完全不需要啟動開發服務,而是直接連接到生產服務器就可以了。

利用全局模塊這一強大的配置功能,只需要在nightwatch.conf.js中加入以下聲明:

    module.exports = {
      "globals_path":"test/e2e/globalsModule.js",
      "selenium": {
          // ... 省略
        }
      },
      // ... 省略
    }  

然後創建一個globalsModule.js文件,並加入以下的代碼:

    process.env.NODE_ENV = 'testing'
    const server = require('../../build/dev-server.js')
    const config = require('../../config');

    module.exports = {
      before: function (done) {
        

server.listen(config.dev.port, function (err) {
          if (err) {
            console.log(err);
            done(err);
          } else {
            console.log('開發服務器啟動偵聽...');
            done;
          }
        })
      },
      after: function (done) {
        server.close;
        done;
      }
    }  

這樣Nightwatch就會引導開發服務器啟動。最後還得對dev-server.js做一個小小的修改,因為dev-server.js一旦被引入就會自動啟動,我們需要對此進行調整,如果是「testing」環境就只導出express服務對象的實例而不是返回express應用對象的偵聽方法的返回值:

    // build/dev-server.js
    // ... 省略
    module.exports =process.env.NODE_ENV !== 'testing' ? app.listen(port,
function (err) {
      if (err) {
        console.log(err)
        return
      }
      var uri = 'http://localhost:' + port
      console.log('Listening at ' + uri + '\n')
      opn(uri)
    }) : app  

接下來只要按照Nightwatch的官方文檔在WebStorm中配置運行器就可以了,具體做法如下所示。

(1)在「Run」菜單內點擊「Edit Configurations...」。

(2)創建一個Node.js的運行配置項。

(3)在「JavaScript file」內填入node_modules/nightwatch/bin/nightwatch。

(4)在「Application parameters」內填入 --config test/e2e/nightwatch.conf.js --env phantom。

(5)點擊「OK」保存。

在測試文件內直接點擊代碼行斷點(無須debugger關鍵字),運行「Run→Debug Nightwatch」就可以啟動Nightwatch的調試模式了。

如果要在命令行運行E2E測試,可以在項目的根目錄執行以下的語句:

    $ nightwatch --conifg test/e2e/nightwatch.conf.js --env phantom  

5.5.4 Page Objects模式

Page Objects模式是由軟件大師Martin Fowler在2013年提出的一種專門用於端到端測試的設計模式(https://martinfowler.com/bliki/PageObject.html)。Page Objects模式是通過將Web應用程序的頁面或頁面片段包裝成一個可實例化的對象以供端到端測試調用的一種模式。它的目的是通過Page對像將端到端測試中大量用於查找、定位元素的操作抽像並封裝為一些方法,避免由於頁面的變更而導致大量代碼邏輯分散性地發生變化。

當我們試圖測試一個Web頁面時,不得不依賴頁面上的元素去進行交互並確認程序應用是否正常運行。然而當你的腳本試圖直接操作頁面上的HTML元素時,一旦有相關 UI的變更,那測試將會變得十分脆弱。Page object模式就是對HTML頁面以及元素細節的封裝,並對外提供應用級別的API,使你擺脫與HTML的糾纏。

——Martin Fowler

Page Objects模式已被廣泛應用在各種主流的端到端測試框架內,當然包括Nightwatch。

Nightwatch對Page Objects的支持做得相當之好,可以說是與測試實例已經融為一體了!

元素(Elements)

大多數時候,我們需要定義一些元素,並且在測試中通過命令和斷言與之交互。如果我們將元素定義放在一個地方,這樣就有利於我們集中維護或使用元素的具體屬性,特別是在較大的集成測試中,使用元素對像將大大有助於保持測試代碼的整潔。需要指出的一點是,這裡的元素 並不是指DOM元素,這是由Nightwatch抽像出來的一個元素概念,一個Page對像內的元素可以是單個的DOM元素,也可以是由多個DOM元素復合而成的結合體。

例如,可以用XPath和CSS混合式地定義元素中的成員:

    module.exports = {
      elements: {
        searchBar: {
          selector: 'input[type=text]'
        },
        submit: {
          selector: '//[@name="q"]',
          locateStrategy: 'xpath'
        }
      }
    };  

或者,可以更簡單地只用CSS選擇器來定義:

    module.exports = {
      elements: {
        searchBar: 'input[type=text]'
      }
    };  

不錯,Page對象的元素實際上就是一個選擇器而已!

還可以定義一個返回DOM集合的選擇器元素:

    var sharedElements = {
      mailLink: 'a[href*="[email protected]"]'
    

};

    module.exports = {
      elements: [
        sharedElements,
        {
          searchBar: 'input[type=text]'
        }
      ]
    };  

每個頁面當然要有一個URL地址:

    module.exports = {
      url: 'http://localhost:8080/search',
      elements: {
        searchBar: {
          selector: 'input[type=text]'
        },
        submit: {
          selector: '//[@name="q"]',
          locateStrategy: 'xpath'
        }
      }
    };  

在端到端測試中就可以這樣來使用它:

    describe('圖書視圖', => {
        it('快速搜索',client => {
            const searcher = client.page.searcher
            searcher.navigate
                .assert.title('圖書視圖')
                .assert.visible('@searchBar')
                .setValue('@searchBar','Vue')
                .click('@submit')
                .end
        })
    })  

所有的Page對像通過nightwatch.conf.js內的page_objects_path配置項指定並由Nightwatch在運行時自動加載並注入到client.page屬性內,所以我們無須用import去導入它們,只要放在一個統一的文件夾內就可以了。

分段(Sections)

很多時候將一個頁面定義為多個分段是非常有用的歸類手法,分段主要負責兩項工作:

(1)為頁面劃分出一個層級式的「命名空間」。

(2)從邏輯上將抽像的元素分佈於一個新的樹狀的對象結構內。

例如:

    export default {
      sections: {
        menu: {
          selector: '#gb',
          elements: {
            mail: {
              selector: 'a[href="mail"]'
            },
            images: {
              selector: 'a[href="imghp"]'
            }
          }
        }
      }
    }  

在測試文件中會這樣使用它:

    describe('圖書視圖', => {
        it('快速搜索',client => {
            const searcher = client.page.searcher
            searcher.navigate
            searcher.expect.section('@menu').to.be.visible
            const menuSection = searcher.section.menu
            menuSection.expect.element('@mail').to.be.visible
            menuSection.expect.element('@images').to.be.visible
            menuSection.click('@mail')
            client.end
        })
    })  

需要注意的是,分段對像上的命令與斷言將會返回分段對像本身,用作鏈式調用。有需要的話,還可以在分段內定義更小的分段用於分解複雜的頁面:

    export default {
      sections: {
        

app: {
          selector: '#app',
          elements: {
            searchbox: {
              selector: 'a[href="mail"]'
            }
          },
          sections: {
            view: {
              selector: 'p.dataview',
              elements: {
                headers: {
                  selector: 'th'
                },
                rows:{
                  selector: 'tbody>tr'
                },
                newbook_button: {
                  selector: 'button.uk-button'
                }
              }
            }
          }
        }
      }
    }  

在測試文件中引用嵌套式分段對像:

    describe('圖書視圖', => {
      it('', client => {
        var books = client.page.books
        books.expect.section('@app').to.be.visible

        var appSection = books.section.app
        var viewSection = appSection.section.view
        viewSection.click('@newbook_button')

        viewSection.expect.element('@rows').to.have.lengthOf(20)
        viewSection.expect.element('@headers').to.have.lengthOf(5)
        client.end
      })
    })  
命令(Commands)

Nightwatch的命令概念實質上是頁面對像上的鏈式方法,它可以有效地封裝與頁面相關的邏輯行為,每個命令方法執行完成後都將返回頁面對像、分段或者元素本身。

命令只是與普通的JavaScript對像定義有點差別,通過commands屬性指定到頁面對像內:

    const bookCommands = {
      search:  => {
        this.api.pause(1000)
        return this.waitForElementVisible('@searchButton', 1000)
                  .click('@searchButton')
      }
    }

    module.exports = {
      commands: [bookCommands],
      elements: {
        searchBar: {
          selector: 'input[type=text]'
        },
        searchButton: {
          selector: '#go_search'
        }
      }
    };  

那麼測試代碼中的使用示例就應該為:

    describe('圖書視圖',=> {
      it('快速搜索',(client)=> {
        const books = client.page.books
        books.setValue('@searchBar','Vue')
          .search
        client.end
      })
    })