前端測試框架

孤舟蓑翁發表於2018-06-21

 

一.為什麼要進行測試?

一個 bug 被隱藏的時間越長,修復這個 bug 的代價就越大。大量的研究資料指出:最後才修改一個 bug 的代價是在 bug 產生時修改它的代價的10倍。所以要防患於未然。

從語言的角度講

JavaScript 作為 web 端使用最廣泛的程式語言,它是動態語言,缺乏靜態型別檢查,所以在程式碼編譯期間,很難發現像變數名寫錯呼叫不存在的方法, 賦值或傳值的型別錯誤等錯誤。

例如下面的例子, 這種型別不符的情況在程式碼中非常容易發生

function foo(x) {
  return x + 10
}

foo('Hello!') //'Hello!10'
在JavaScript語言中,除了作為數字的加運算外,也可以當作字串的連線運算子。這當然不是我們想要的結果。

 當開發完一個功能模組的時候,如何確定你的模組有沒有 bug 呢?通常的做法是根據具體的業務,執行 debug 模式,一點一點深入到程式碼中去檢視。如果你一直都是這樣,那麼你早就已經 OUT 了。現在更先進的做法是自動化測試, 寫好測試用例, 執行一個指令,就可快速知道程式碼有沒有缺陷,以及出錯的地方。

從工程的角度講

在日常的開發中,程式碼的完工其實並不等於開發的完工。如果沒有單元測試,不能保證程式碼能夠正常執行。

測試不可能保證一個程式是完全正確的,但是測試卻可以增強程式設計師對程式健壯性,穩定性的信心,測試可以讓我們相信程式做了我們期望它做的事情。測試能夠使我們儘早的發現程式的 bug 和不足。做完開發後,用測試框架轟擊系統,能夠經受住測試框架挑戰過的程式碼,才是健壯的程式碼。  單元測試能增強開發人員對程式碼的信心。

測試人員做的只是業務上的整合測試,也就是黑盒測試,測試出的 bug 的範圍相對而言比較廣,很難精確到單個方法, 不能夠精準地定位問題。

 

二. 測試分類

JavaScript程式碼測試有很多分類,比如單元測試(unit test)整合測試(integration test)功能測試(functional test)端到端測試(end to end test)迴歸測試(regression test)瀏覽器測試(browser test)

單元測試

單元測試指的是測試小的程式碼塊,通常指的是獨立測試單個函式如果某個測試依賴於一些外部資源,比如網路或者資料庫,那它就不是單元測試。單元測試是從程式設計師的角度編寫的,保證一些方法執行特定的任務,給出特定輸入,得到預期的結果。

單元測試一般很容易寫。一個單元測試通常是這樣的:為某個函式提供某些輸入值,然後驗證函式的返回值是否正確。然而,如果你的程式碼設計非常糟糕,則單元測試會很難寫。從另一個角度理解,單元測試可以幫助我們寫更好的程式碼。單元測試可以幫助我們避免一些常見的BUG。通常,程式設計師會在同一個細節上反覆犯錯,如果為這些Bug新增單元測試,則可以有效避免這種情況。當然,你也可以使用整合測試功能測試來解決這個問題,但是單元測試更加適合,因為單元測試更加細緻,可以幫助我們快速定位和解決問題。

整合測試

整合測試就是測試應用中不同模組如何整合,如何一起工作,這和它的名字一致。整合測試與單元測試相似,但是它們也有很大的不同:單元測試是測試每個獨立的模組,而整合測試恰好相反。比如,當測試需要訪問資料庫的程式碼時,單元測試不會真的去訪問資料庫,而整合測試則會

單元測試不夠時,這時就需要整合測試了。當你需要去驗證兩個獨立的模組,比如資料庫和應用,保證它們能夠正確的一起工作,這時就需要整合測試了。為了驗證測試結果,你就需要通過查詢資料庫驗證資料正確性。

整合測試通常比單元測試慢,因為它更加複雜。並且,整合測試還需要配置測試環境,比如配置測試資料庫或者其他依賴的元件。這就使得編寫和維護整合測試更加困難,因此,你應該專注於單元測試,除非你真的需要整合測試。

你需要的整合測試應該少於單元測試。除非你需要測試多個模組,或者你的程式碼太複雜時,你才需要整合測試。並且,當你的程式碼過於複雜時,建議優化程式碼以便進行單元測試,而不是直接寫整合測試。

通常,我們可以使用單元測試工具編寫整合測試。

功能測試

功能測試有時候也被稱作端到端測試,或者瀏覽器測試,它們指的是同一件事。功能測試是從使用者的角度編寫的,測試確保使用者執行它所期望的工作。

功能測試指的是測試應用的某個完整的功能,它從一個使用者的角度出發,認為整個系統都是一個黑箱,只有UI會暴露給使用者對於網頁應用,功能測試意味著使用工具模擬瀏覽器,然後通過點選頁面來測試應用。

單元測試可以測試單個函式,整合測試可以測試兩個模組一起工作。功能測試則完全是另外一個層次。你可以有上百個單元測試,但是通常你只有少量的功能測試。這是因為功能測試太複雜了,難於編寫和維護。功能測試很慢,因為它需要模擬真實使用者進行網頁互動。

事實上,你不需要編寫非常詳細的功能測試。功能測試並不意味著你需要測試每一個功能,其實,你只需要測試一些常見的使用者行為。如果你需要在瀏覽器中手動測試應用的某個流程,比如註冊賬號,這時你可以編寫一個功能測試。

對於單元測試,你會使用程式碼去驗證結果,在功能測試中也應該這樣做。以註冊賬號為例,你可以驗證瀏覽器是否跳轉到了”感謝註冊”頁面。

當有些測試你需要手動在瀏覽器下重複進行時,你應該編寫功能測試。注意不要寫得太細緻了,否則維護這些測試將是一個噩夢。

測試JavaScript程式碼時,應該著重於單元測試,它非常容易編寫和維護,除了可以減少BUG還有很多益處。而整合測試與功能測試應該作為補充。

 

三.單元測試的好處: 

  • 提高程式碼質量         

        程式碼有測試用例,雖不能說百分百無bug,但至少說明測試用例覆蓋到的場景是沒有問題的。有測試用例,釋出前跑一下,可以杜絕各種疏忽而引起的功能bug。如果能通過單元測試,那麼通過後續測試且軟體整體正常執行的概率大大提高                       

  • 快速反饋,減少除錯時間

       自動化測試另外一個重要特點就是快速反饋,反饋越迅速意味著開發效率越高。拿UI元件為例,開發過程都是開啟瀏覽器重新整理頁面點點點才能確定UI元件工作情況是否符合自己預期。接入自動化測試以後,通過指令碼代替這些手動點選,接入程式碼watch後每次儲存檔案都能快速得知自己的的改動是否影響功能,節省了很多時間,畢竟機器幹事情比人總是要快得多。如果程式有bug,我們執行一次全部單元測試,找到不通過的測試,可以很快地定位對應的執行程式碼。單元測試發現的問題定位到細節,容易修改,節省時間。修復程式碼後,執行對應的單元測試;如還不通過,繼續修改,執行測試.....直到測試通過。

  • 放心重構

       重構後把程式碼改壞了,對整體系統構成破壞的情況並不少見。由於大多數情況下,所有模組或業務功能不是孤立的,可謂牽一髮動全身,你改一個方法可能導致整個專案執行不起來

如果你有單元測試,情況大不相同。寫完一個類,把單元測試寫了,確保這個類邏輯正確;每個類保證邏輯正確,拼在一起肯定不出問題。可以放心一邊重構,一邊執行專案;而不是整體重構完,提心跳膽地run。

 

四.測試系統構成

        測試主要是測試框架、斷言庫,   程式碼覆蓋率工具,模擬工具 , 測試驅動(測試任務管理工具)組成:

  1. 測試框架: 如何組織測試,主要由Mocha、Jasmine,Jest ,AVA, Tape等,測試主要提供了清晰簡明的語法來描述測試用例,以及對測試用例分組,測試框架會抓取到程式碼丟擲的AssertionError,並增加一大堆附加資訊,比如那個用例掛了,為什麼掛等等。測試框架通常提供TDD(測試驅動開發)或BDD(行為驅動開發)的測試語法來編寫測試用例。不同的測試框架支援不同的測試語法,比如Mocha既支援TDD也支援BDD,而Jasmine只支援BDD。當前流行 BDD 的測試結構。

  2. 斷言庫:Should.jschaiexpect.js等等,斷言庫提供了很多語義化的方法來對值做各種各樣的判斷。當然也可以不用斷言庫,Node.js中也可以直接使用原生assert庫。

  3. 程式碼覆蓋率:istanbul等為程式碼在語法級分支上打點,執行了打點後的程式碼,根據執行結束後收集到的資訊和打點時的資訊來統計出當前測試用例對原始碼的覆蓋情況。

  4. 模擬工具  模擬方法,模組,甚至伺服器 , 獲取方法的呼叫資訊,先來說說為什麼需要模擬吧:需要測試的單元依賴於外部的模組,而這些依賴的模組具有一些特點,例如不能控制、實現成本較高、操作危險等原因,不能直接使用依賴的模組,這樣情況下就需要對其進行mock,要完整執行前端程式碼,通常並不需要完整的後端環境。能偽造出前端頁面渲染所需要的資料就行,這類工具我用過的有sinon,easy-mock,RAP, 甚至手工偽造一些假資料都可以。
  5.  測試驅動(測試任務管理工具)

    karma:   是一個基於 Node.js 的 JavaScript 測試執行過程管理工具(Test Runner)。設定測試需要的框架、環境、原始檔、測試檔案等,配置完後,就可以輕鬆地執行測試,該工具可用於測試所有主流 Web 瀏覽器,

                這個測試工具的一個強大特性就是,它可以監控 (Watch) 檔案的變化,然後自行執行,通過 console.log 顯示測試結果。


    buster.js: 另外一個工具,不過目前處於deta版本,不僅可以在瀏覽器端,還可以在node端

  6. 類瀏覽器測試環境   這類工具有Protractor, Nightwatch, Phantom, Casper 

五.選擇單元測試框架

測試框架做的事情:

  • 描述你要測試的東西
  • 對其進行測試
  • 判斷是否符合預期

單元測試應該:簡單,快速執行,有清晰的錯誤報告。

選擇框架要考慮下面這些方面:

  • 斷言:有些框架內建了斷言庫,有的框架可以自己選擇斷言庫。
  • 測試風格:支援的測試風格 測試驅動型 / 行為驅動型 是否喜歡。
  • 非同步測試支援:測試框架對非同步測試支援是否良好。
  • 使用的語言:測試框架使用的語言,前端測試框架選擇JS語言。
  • 社群是否活躍,  有沒有完整的API文件, 使用的公司多不多,有沒有大公司維護 。

注:測試驅動型和行為驅動型的區別

TDD:站在程式設計師的角度,寫測試程式碼。測試驅動型的開發方式,先寫測試程式碼,之後編寫能通過測試的業務程式碼,可以不斷的在能通過測試的情況下重構 。

BDD:站在使用者的角度,寫測試程式碼。 是測試驅動開發的進化,測試程式碼的風格是預期結果,更關注功能和設計,看起來像需求文件。定義系統的行為是主要工作,而對系統行為的描述則變成了測試標準

其實都是先寫測試程式碼,感覺BDD 風格更人性。

各框架特點

Mocha

  • 靈活,擴充套件性好,不包括斷言和模擬,測試報告,流行的選擇:chai,sinon,istanbul
  • 社群成熟用的人多,測試各種東西社群都有示例
  • 可以使用快照測試,需要額外配置
  • 功能非常豐富,支援執行在 Node.js 和瀏覽器中, 對非同步測試支援非常友好
  • Mocha效能更勝一籌
  • 終端顯示友好

Jasmine

  • 開箱即用(支援斷言和模擬)
  • 全域性環境,比如 describe 不需要引入直接用
  • 比較'老',坑基本都有人踩過了
  • 對低版本瀏覽器支援性比較好
  • 沒有自帶mockserver, 如果需要這功能的得另外配置

Jest

  • 基於 Jasmine 至今已經做了大量修改新增了很多特性
  • 開箱即用配置少,API簡單
  • 支援斷言和模擬
  • 較新,社群不十分成熟
  • 較多用於 React 專案(但廣泛支援各種專案)

AVA

  • 非同步,效能好
  • 簡約,清晰
  • 快照測試和斷言需要三方支援

Tape

  • 體積最小,只提供最關鍵的東西
  • 對比其他框架,只提供最底層的 API

總結一下,Mocha ,Jasmine用的人最多,社群最成熟,靈活,可配置性強易擴充,Jest 開箱即用,裡邊啥都有提供全面的方案,Tape 最精簡。

Mocha 跟 Jasmine 是目前最火的兩個單元測試框架,基本上目前前端單元測試就在這兩個庫之間選了。總的來說就是Jasmine功能齊全,配置方便,Mocha靈活自由,自由配置。 兩者功能覆蓋範圍粗略可以表示為:

Jasmine(2.x) === Mocha + Chai + Sinon - mockserver

實際使用後覺得jasmine由於各種功能內建,斷言方式或者非同步等風格相對比較固定,沒有自帶mockserver, 需要這功能的得另外配置,  Cha i和 Sinon(賽蘭)畢竟是專門做特定功能的框架,用 Mocha + Chai + Sinon 這種方式會想對舒爽一點。

 

六.斷言庫的風格

Assert

var assert = require('chai').assert , foo = 'bar' , beverages = { tea: [ 'chai', 'matcha', 'oolong' ] }; 
assert.typeOf(foo, 'string'); // without optional message 
assert.typeOf(foo, 'string', 'foo is a string'); // with optional message 
assert.equal(foo, 'bar', 'foo equal `bar`'); 
assert.lengthOf(foo, 3, 'foo`s value has a length of 3'); 
assert.lengthOf(beverages.tea, 3, 'beverages has 3 types of tea');

 

BBD風格的斷言庫

expect

var expect = require('chai').expect
  , foo = 'bar'
  , beverages = { tea: [ 'chai', 'matcha', 'oolong' ] };

expect(foo).to.be.a('string');
expect(foo).to.equal('bar');
expect(foo).to.have.lengthOf(3);
expect(beverages).to.have.property('tea').with.lengthOf(3);

should

var should = require('chai').should() //actually call the function
  , foo = 'bar'
  , beverages = { tea: [ 'chai', 'matcha', 'oolong' ] };

foo.should.be.a('string');
foo.should.equal('bar');
foo.should.have.lengthOf(3);
beverages.should.have.property('tea').with.lengthOf(3);

 建議使用expect,should不相容IE

expect斷言語法

// equal 相等或不相等
expect(4 + 5).to.be.equal(9);
expect(4 + 5).to.be.not.equal(10);
expect('hello').to.equal('hello');  
expect(42).to.equal(42);  
expect(1).to.not.equal(true);  
expect({ foo: 'bar' }).to.not.equal({ foo: 'bar' });  
expect({ foo: 'bar' }).to.deep.equal({ foo: 'bar' });

// above 斷言目標的值大於某個value,如果前面有length的鏈式標記,則可以用來判斷陣列長度或者字串長度
expect(10).to.be.above(5);
expect('foo').to.have.length.above(2);  
expect([ 1, 2, 3 ]).to.have.length.above(2); 
類似的還有least(value)表示大於等於;below(value)表示小於;most(value)表示小於等於

// 判斷目標是否為布林值true(隱式轉換)
expect('everthing').to.be.ok;
expect(1).to.be.ok;  
expect(false).to.not.be.ok;
expect(undefined).to.not.be.ok;  
expect(null).to.not.be.ok; 

// true/false 斷言目標是否為true或false
expect(true).to.be.true;  
expect(1).to.not.be.true;
expect(false).to.be.false;  
expect(0).to.not.be.false;

// null/undefined 斷言目標是否為null/undefined
expect(null).to.be.null;  
expect(undefined).not.to.be.null;
expect(undefined).to.be.undefined;  
expect(null).to.not.be.undefined;


// NaN  斷言目標值不是數值
expect('foo').to.be.NaN;
expect(4).not.to.be.NaN;

// 判斷型別大法(可以實現上面的一些例子):a/an
expect('test').to.be.a('string');
expect({ foo: 'bar' }).to.be.an('object');
expect(foo).to.be.an.instanceof(Foo);
expect(null).to.be.a('null');  
expect(undefined).to.be.an('undefined');
expect(new Error).to.be.an('error');
expect(new Promise).to.be.a('promise');

// 包含關係:用來斷言字串包含和陣列包含。如果用在鏈式呼叫中,可以用來測試物件是否包含某key 可以混著用。
expect([1,2,3]).to.include(2);
expect('foobar').to.contain('foo');
expect({ foo: 'bar', hello: 'universe' }).to.include.keys('foo');

// 判斷空值
expect([]).to.be.empty;
expect('').to.be.empty;
expect({}).to.be.empty;

// match
expect('foobar').to.match(/^foo/);
    
// exist 斷言目標既不是null也不是undefined
var foo = 'hi' , bar = null, baz;
expect(foo).to.exist;  
expect(bar).to.not.exist;  
expect(baz).to.not.exist;

// within斷言目標值在某個區間範圍內,可以與length連用
expect(7).to.be.within(5,10);  
expect('foo').to.have.length.within(2,4);  
expect([ 1, 2, 3 ]).to.have.length.within(2,4);

// instanceOf 斷言目標是某個構造器產生的事例
var Tea = function (name) { this.name = name; } , Chai = new Tea('chai');
expect(Chai).to.be.an.instanceof(Tea);  
expect([ 1, 2, 3 ]).to.be.instanceof(Array); 

// property(name, [value])  斷言目標有以name為key的屬性,並且可以指定value斷言屬性值是嚴格相等的,此[value]引數為可選,如果使用deep鏈式呼叫,可以在name中指定物件或陣列的引用表示方法
// simple referencing
var obj = { foo: 'bar' };  
expect(obj).to.have.property('foo');  
expect(obj).to.have.property('foo', 'bar');// 類似於expect(obj).to.contains.keys('foo')

// deep referencing
var deepObj = {  
  green: { tea: 'matcha' },
  teas: [ 'chai', 'matcha', { tea: 'konacha' } ]
};
expect(deepObj).to.have.deep.property('green.tea', 'matcha');  
expect(deepObj).to.have.deep.property('teas[1]', 'matcha');  
expect(deepObj).to.have.deep.property('teas[2].tea', 'konacha'); 

// ownproperty 斷言目標擁有自己的屬性,非原型鏈繼承
expect('test').to.have.ownProperty('length'); 

// throw 斷言目標丟擲特定的異常
var err = new ReferenceError('This is a bad function.');  
var fn = function () { throw err; }  
expect(fn).to.throw(ReferenceError);  
expect(fn).to.throw(Error);  
expect(fn).to.throw(/bad function/);  
expect(fn).to.not.throw('good function');  
expect(fn).to.throw(ReferenceError, /bad function/);  
expect(fn).to.throw(err);  
expect(fn).to.not.throw(new RangeError('Out of range.'));  

// satisfy(method) 斷言目標通過一個真值測試
expect(1).to.satisfy(function(num) { return num > 0; })

 

 

七. 測試覆蓋率

  • 行覆蓋率(line coverage):是否每一行都執行了

         可執行語句的每一行是否都被執行了,不包括註釋,空白行 行覆蓋常常被人指責為“最弱的覆蓋”,為什麼這麼說呢,舉一個例子

function foo(a, b)
{
   return  a / b;
}

TeseCase: a = 10, b = 5

測試人員的測試結果會告訴你,他的程式碼覆蓋率達到了100%,並且所有測試案例都通過了。我們的語句覆蓋率達到了所謂的100%,但是卻沒有發現最簡單的Bug,比如,當我讓b=0時,會丟擲一個除零異常。

  • 函式覆蓋率(function coverage):是否每個函式都呼叫了
  • 分支覆蓋率(branch coverage):是否每個if程式碼塊都執行了
  • 語句覆蓋率(statement coverage):是否每個語句都執行了

 

4個指標當中,行覆蓋率和語句覆蓋率很相近;在程式碼規範的情況下,規範要求一行寫一個語句 它們應該是一樣的

4個指標當中,分支覆蓋率是最重要的,它包括: !&&||?: ; if 和 else-if else switch - case 等等各種包含分支的情況

 

  •  覆蓋率資料只能代表你測試過哪些程式碼,不能代表你是否測試好這些程式碼。(比如上面第一個除零Bug)
  •  不要過於相信覆蓋率資料。
  •  分支覆蓋率 > 函式覆蓋 > 語句覆蓋
  • 測試人員不能盲目追求程式碼覆蓋率,而應該想辦法設計更多更好的案例,哪怕多設計出來的案例對覆蓋率一點影響也沒有。

 

八.利弊權衡

近幾年前端工程化的發展風起雲湧,但是前端自動化測試這塊內容大家卻似乎不太重視。雖然專案迭代過程中會有專門的測試人員進行測試,但等他們來進行測試時,程式碼已經開發完成的狀態。與之相比,如果我們在開發過程中就進行了測試會有如下的好處:

  • 保障程式碼質量,減少bug
  • 提升開發效率,在開發過程中進行測試能讓我們提前發現 bug ,此時進行問題定位和修復的速度自然比開發完再被叫去修 bug 要快許多
  • 便於專案維護,後續任何程式碼更新也必須跑通測試用例,即使進行重構或開發人員發生變化也能保障預期功能的實現

當然,凡事都有兩面性,好處雖然明顯,卻並不是所有的專案都值得引入測試框架,畢竟維護測試用例也是需要成本的。對於一些需求頻繁變更、複用性較低的內容,比如活動頁面,讓開發專門抽出人力來寫測試用例確實得不償失。

而適合引入測試場景大概有這麼幾個:

  • 需要長期維護的專案。它們需要測試來保障程式碼可維護性、功能的穩定性
  • 較為穩定的專案、或專案中較為穩定的部分。給它們寫測試用例,維護成本低
  • 被多次複用的部分,比如一些通用元件和庫函式。因為多處複用,更要保障質量
 
單元測試確實會帶給你相當多的好處,但不是立刻體驗出來。正如買重疾保險,交了很多保費,沒病沒痛,十幾年甚至幾十年都用不上,最好就是一輩子用不上理賠,身體健康最重要。單元測試也一樣,寫了可以買個放心,對程式碼的一種保障,有bug儘快測出來,沒bug就最好,總不能說“寫那麼多單元測試,結果測不出bug,浪費時間”吧。
 

 參考連結

1.https://www.jianshu.com/p/f200a75a15d2  Chai.js斷言庫API中文文件

2.http://www.ruanyifeng.com/blog/2015/06/istanbul.html    程式碼覆蓋率工具 Istanbul 入門教程

3.https://segmentfault.com/a/1190000012654035   Vue單元測試實戰教程(Mocha/Karma + Vue-Test-Utils + Chai)

4.http://www.ruanyifeng.com/blog/2015/12/a-mocha-tutorial-of-examples.html 測試框架 Mocha 例項教程

5.https://vue-test-utils.vuejs.org/zh/guides/#%E8%B5%B7%E6%AD%A5   Vue Test Utils教程

6.https://www.jianshu.com/p/c7c86b8f376c  mocha 的基本介紹&&expect風格斷言庫的基本語法

相關文章