聊一聊前端自動化測試(上)
前言
前天本來是想看看了解入門下前端單元測試的,當看完三個Demo之後突然想前端寫單元測試主要都測什麼內容。好奇之!今日早讀文章由天貓@LingyuCoder分享。
正文從這開始~
為何要測試
以前不喜歡寫測試,主要是覺得編寫和維護測試用例非常的浪費時間。在真正寫了一段時間的基礎組件和基礎工具後,才發現自動化測試有很多好處。測試最重要的自然是提升代碼質量。代碼有測試用例,雖不能說百分百無bug,但至少說明測試用例覆蓋到的場景是沒有問題的。有測試用例,發布前跑一下,可以杜絕各種疏忽而引起的功能bug。
自動化測試另外一個重要特點就是快速反饋,反饋越迅速意味著開發效率越高。拿UI組件為例,開發過程都是打開瀏覽器刷新頁面點點點才能確定UI組件工作情況是否符合自己預期。接入自動化測試以後,通過腳本代替這些手動點擊,接入代碼watch後每次保存文件都能快速得知自己的的改動是否影響功能,節省了很多時間,畢竟機器幹事情比人總是要快得多。
有了自動化測試,開發者會更加信任自己的代碼。開發者再也不會懼怕將代碼交給別人維護,不用擔心別的開發者在代碼里搞「破壞」。後人接手一段有測試用例的代碼,修改起來也會更加從容。測試用例里非常清楚的闡釋了開發者和使用者對於這端代碼的期望和要求,也非常有利於代碼的傳承。
考慮投入產出比來做測試
說了這麼多測試的好處,並不代表一上來就要寫出100%場景覆蓋的測試用例。個人一直堅持一個觀點:基於投入產出比來做測試。由於維護測試用例也是一大筆開銷(畢竟沒有多少測試會專門幫前端寫業務測試用例,而前端使用的流程自動化工具更是沒有測試參與了)。對於像基礎組件、基礎模型之類的不常變更且復用較多的部分,可以考慮去寫測試用例來保證質量。個人比較傾向於先寫少量的測試用例覆蓋到80%+的場景,保證覆蓋主要使用流程。一些極端場景出現的bug可以在迭代中形成測試用例沉澱,場景覆蓋也將逐漸趨近100%。但對於迭代較快的業務邏輯以及生存時間不長的活動頁面之類的就別花時間寫測試用例了,維護測試用例的時間大了去了,成本太高。
Node.js模塊的測試
對於Node.js的模塊,測試算是比較方便的,畢竟源碼和依賴都在本地,看得見摸得著。
測試工具
測試主要使用到的工具是測試框架、斷言庫以及代碼覆蓋率工具:
測試框架:Mocha、Jasmine等等,測試主要提供了清晰簡明的語法來描述測試用例,以及對測試用例分組,測試框架會抓取到代碼拋出的AssertionError,並增加一大堆附加信息,比如那個用例掛了,為什麼掛等等。測試框架通常提供TDD(測試驅動開發)或BDD(行為驅動開發)的測試語法來編寫測試用例,關於TDD和BDD的對比可以看一篇比較知名的文章The Difference Between TDD and BDD。不同的測試框架支持不同的測試語法,比如Mocha既支持TDD也支持BDD,而Jasmine只支持BDD。這裡後續以Mocha的BDD語法為例
斷言庫:Should.js、chai、expect.js等等,斷言庫提供了很多語義化的方法來對值做各種各樣的判斷。當然也可以不用斷言庫,Node.js中也可以直接使用原生assert庫。這裡後續以Should.js為例
代碼覆蓋率:istanbul等等為代碼在語法級分支上打點,運行了打點後的代碼,根據運行結束後收集到的信息和打點時的信息來統計出當前測試用例的對源碼的覆蓋情況。
一個煎蛋的栗子
以如下的Node.js項目結構為例
.
├── LICENSE
├── README.md
├── index.js
├── node_modules
├──package.json
└── test
└── test.js
首先自然是安裝工具,這裡先裝測試框架和斷言庫:npm install --save-dev mocha should。裝完後就可以開始測試之旅了。
比如當前有一段js代碼,放在index.js 里
use strict ;
module.exports=()=> Hello Tmall ;
那麼對於這麼一個函數,首先需要定一個測試用例,這裡很明顯,運行函數,得到字元串Hello Tmall就算測試通過。那麼就可以按照Mocha的寫法來寫一個測試用例,因此新建一個測試代碼在test/index.js
測試用例寫完了,那麼怎麼知道測試結果呢?
由於我們之前已經安裝了Mocha,可以在node_modules裡面找到它,Mocha提供了命令行工具_mocha,可以直接在./node_modules/.bin/_mocha找到它,運行它就可以執行測試了:
這樣就可以看到測試結果了。同樣我們可以故意讓測試不通過,修改test.js代碼為:
就可以看到下圖了:
Mocha實際上支持很多參數來提供很多靈活的控制,比如使用./node_modules/.bin/_mocha --require should,Mocha在啟動測試時就會自己去載入Should.js,這樣test/test.js里就不需要手動require( should );了。更多參數配置可以查閱Mocha官方文檔。
那麼這些測試代碼分別是啥意思呢?
這裡首先引入了斷言庫Should.js,然後引入了自己的代碼,這裡it()函數定義了一個測試用例,通過Should.js提供的api,可以非常語義化的描述測試用例。那麼describe又是幹什麼的呢?
describe乾的事情就是給測試用例分組。為了儘可能多的覆蓋各種情況,測試用例往往會有很多。這時候通過分組就可以比較方便的管理(這裡提一句,describe是可以嵌套的,也就是說外層分組了之後,內部還可以分子組)。另外還有一個非常重要的特性,就是每個分組都可以進行預處理(before、beforeEach)和後處理(after, afterEach)。
如果把index.js源碼改為:
use strict ;
module.exports=bu=>`Hello${bu}`;
為了測試不同的bu,測試用例也對應的改為:
use strict ;
require( should );
constmylib=require( ../index );
letbu= none ;
describe( My First Test ,()=>{
describe( Welcome to Tmall ,()=>{
before(()=>bu= Tmall );
after(()=>bu= none );
it( should get "Hello Tmall" ,()=>{
mylib(bu).should.be.eql( Hello Tmall );
});
});
describe( Welcome to Taobao ,()=>{
before(()=>bu= Taobao );
after(()=>bu= none );
it( should get "Hello Taobao" ,()=>{
mylib(bu).should.be.eql( Hello Taobao );
});
});
});
同樣運行一下./node_modules/.bin/_mocha就可以看到如下圖:
這裡before會在每個分組的所有測試用例運行前,相對的after則會在所有測試用例運行後執行,如果要以測試用例為粒度,可以使用beforeEach和afterEach,這兩個鉤子則會分別在該分組每個測試用例運行前和運行後執行。由於很多代碼都需要模擬環境,可以再這些before或beforeEach做這些準備工作,然後在after或afterEach里做回收操作。
非同步代碼的測試
回調
這裡很顯然代碼都是同步的,但很多情況下我們的代碼都是非同步執行的,那麼非同步的代碼要怎麼測試呢?
比如這裡index.js的代碼變成了一段非同步代碼:
use strict ;
module.exports=(bu,callback)=>process.nextTick(()=>callback(`Hello${bu}`));
由於源代碼變成非同步,所以測試用例就得做改造:
這裡傳入it的第二個參數的函數新增了一個done參數,當有這個參數時,這個測試用例會被認為是非同步測試,只有在done()執行時,才認為測試結束。那如果done()一直沒有執行呢?Mocha會觸發自己的超時機制,超過一定時間(默認是2s,時長可以通過--timeout參數設置)就會自動終止測試,並以測試失敗處理。
當然,before、beforeEach、after、afterEach這些鉤子,同樣支持非同步,使用方式和it一樣,在傳入的函數第一個參數加上done,然後在執行完成後執行即可。
Promise
平常我們直接寫回調會感覺自己很low,也容易出現回調金字塔,我們可以使用Promise來做非同步控制,那麼對於Promise控制下的非同步代碼,我們要怎麼測試呢?
首先把源碼做點改造,返回一個Promise 對象:
use strict ;
module.exports=bu=>newPromise(resolve=>resolve(`Hello${bu}`));
當然,如果是co黨也可以直接使用co 包裹:
use strict ;
constco=require( co );
module.exports=co.wrap(function*(bu){
return`Hello${bu}`;
});
對應的修改測試用例如下:
Should.js在8.x.x版本自帶了Promise支持,可以直接使用fullfilled()、rejected()、fullfilledWith()、rejectedWith()等等一系列API測試Promise 對象。
注意:使用should測試Promise對象時,請一定要return,一定要return,一定要return,否則斷言將無效
非同步運行測試
有時候,我們可能並不只是某個測試用例需要非同步,而是整個測試過程都需要非同步執行。比如測試Gulp插件的一個方案就是,首先運行Gulp任務,完成後測試生成的文件是否和預期的一致。那麼如何非同步執行整個測試過程呢?
其實Mocha提供了非同步啟動測試,只需要在啟動Mocha的命令後加上--delay參數,Mocha就會以非同步方式啟動。這種情況下我們需要告訴Mocha什麼時候開始跑測試用例,只需要執行run()方法即可。把剛才的test/test.js修改成下面這樣:
直接執行./node_modules/.bin/_mocha就會發生下面這樣的杯具:
那麼加上--delay 試試:
熟悉的綠色又回來了!
代碼覆蓋率
單元測試玩得差不多了,可以開始試試代碼覆蓋率了。首先需要安裝代碼覆蓋率工具istanbul:npm install --save-dev istanbul,istanbul同樣有命令行工具,在./node_modules/.bin/istanbul可以尋覓到它的身影。Node.js端做代碼覆蓋率測試很簡單,只需要用istanbul啟動Mocha即可,比如上面那個測試用例,運行./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha -- --delay,可以看到下圖:
這就是代碼覆蓋率結果了,因為index.js中的代碼比較簡單,所以直接就100%了,那麼修改一下源碼,加個 if 吧:
use strict ;
module.exports=bu=>newPromise(resolve=>{
if(bu=== Tmall )returnresolve(`Welcome to Tmall`);
resolve(`Hello${bu}`);
});
測試用例也跟著變一下:
換了姿勢,我們再來一次./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha -- --delay,可以得到下圖:
當使用istanbul運行Mocha時,istanbul命令自己的參數放在--之前,需要傳遞給Mocha的參數放在 -- 之後
如預期所想,覆蓋率不再是100%了,這時候我想看看哪些代碼被運行了,哪些沒有,怎麼辦呢?
運行完成後,項目下會多出一個coverage文件夾,這裡就是放代碼覆蓋率結果的地方,它的結構大致如下:
coverage.json和lcov.info:測試結果描述的json文件,這個文件可以被一些工具讀取,生成可視化的代碼覆蓋率結果,這個文件後面接入持續集成時還會提到。
lcov-report:通過上面兩個文件由工具處理後生成的覆蓋率結果頁面,打開可以非常直觀的看到代碼的覆蓋率
這裡open coverage/lcov-report/index.html可以看到文件目錄,點擊對應的文件進入到文件詳情,可以看到index.js的覆蓋率如圖所示:
這裡有四個指標,通過這些指標,可以量化代碼覆蓋情況:
statements:可執行語句執行情況
branches:分支執行情況,比如if就會產生兩個分支,我們只運行了其中的一個
Functions:函數執行情況
Lines:行執行情況
下面代碼部分,沒有被執行過得代碼會被標紅,這些標紅的代碼往往是bug滋生的土壤,我們要儘可能消除這些紅色。為此我們添加一個測試用例:
再來一次./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha -- --delay,重新打開覆蓋率頁面,可以看到紅色已經消失了,覆蓋率100%。目標完成,可以睡個安穩覺了
集成到package.json
好了,一個簡單的Node.js測試算是做完了,這些測試任務都可以集中寫到package.json的scripts欄位中,比如:
{
"scripts":{
"test":"NODE_ENV=test ./node_modules/.bin/_mocha --require should",
"cov":"NODE_ENV=test ./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha -- --delay"
},
}
這樣直接運行npm run test就可以跑單元測試,運行npm run cov就可以跑代碼覆蓋率測試了,方便快捷
對多個文件分別做測試
通常我們的項目都會有很多文件,比較推薦的方法是對每個文件單獨去做測試。比如代碼在./lib/下,那麼./lib/文件夾下的每個文件都應該對應一個./test/文件夾下的文件名_spec.js的測試文件
為什麼要這樣呢?不能直接運行index.js入口文件做測試嗎?
直接從入口文件來測其實是黑盒測試,我們並不知道代碼內部運行情況,只是看某個特定的輸入能否得到期望的輸出。這通常可以覆蓋到一些主要場景,但是在代碼內部的一些邊緣場景,就很難直接通過從入口輸入特定的數據來解決了。比如代碼里需要發送一個請求,入口只是傳入一個url,url本身正確與否只是一個方面,當時的網路狀況和伺服器狀況是無法預知的。傳入相同的url,可能由於伺服器掛了,也可能因為網路抖動,導致請求失敗而拋出錯誤,如果這個錯誤沒有得到處理,很可能導致故障。因此我們需要把黑盒打開,對其中的每個小塊做白盒測試。
當然,並不是所有的模塊測起來都這麼輕鬆,前端用Node.js常乾的事情就是寫構建插件和自動化工具,典型的就是Gulp插件和命令行工具,那麼這倆種特定的場景要怎麼測試呢?
Gulp插件的測試
現在前端構建使用最多的就是Gulp了,它簡明的API、流式構建理念、以及在內存中操作的性能,讓它備受追捧。雖然現在有像webpack這樣的後起之秀,但Gulp依舊憑藉著其繁榮的生態圈擔當著前端構建的絕對主力。目前天貓前端就是使用Gulp作為代碼構建工具。
用了Gulp作為構建工具,也就免不了要開發Gulp插件來滿足業務定製化的構建需求,構建過程本質上其實是對源代碼進行修改,如果修改過程中出現bug很可能直接導致線上故障。因此針對Gulp插件,尤其是會修改源代碼的Gulp插件一定要做仔細的測試來保證質量。
又一個煎蛋的栗子
use strict ;
const_=require( lodash );
constthrough=require( through2 );
constPluginError=require( gulp-util ).PluginError;
constDEFAULT_CONFIG={};
module.exports=config=>{
config=_.defaults(config||{},DEFAULT_CONFIG);
returnthrough.obj((file,encoding,callback)=>{
if(file.isStream())returncallback(newPluginError( gulp-welcome-to-tmall ,`Stream is not supported`));
file.contents=newBuffer(`// 天貓前端招人,有意向的請發送簡歷至lingyucoder@gmail.com
${file.contents.toString()}`);
callback(null,file);
});
};
對於這麼一段代碼,怎麼做測試呢?
一種方式就是直接偽造一個文件傳入,Gulp內部實際上是通過vinyl-fs從操作系統讀取文件並做成虛擬文件對象,然後將這個虛擬文件對象交由through2創造的Transform來改寫流中的內容,而外層任務之間通過orchestrator控制,保證執行順序(如果不了解可以看看這篇翻譯文章Gulp思維——Gulp高級技巧)。當然一個插件不需要關心Gulp的任務管理機制,只需要關心傳入一個vinyl對象能否正確處理。因此只需要偽造一個虛擬文件對象傳給我們的Gulp插件就可以了。
首先設計測試用例,考慮兩個主要場景:
對於第一個測試用例,我們需要創建一個流格式的vinyl對象。而對於各第二個測試用例,我們需要創建一個Buffer格式的vinyl對象。
當然,首先我們需要一個被加工的源文件,放到test/src/testfile.js 下吧:
use strict ;
console.log( hello world );
這個源文件非常簡單,接下來的任務就是把它分別封裝成流格式的vinyl對象和Buffer格式的vinyl對象。
構建Buffer格式的虛擬文件對象
構建一個Buffer格式的虛擬文件對象可以用vinyl-fs讀取操作系統里的文件生成vinyl對象,Gulp內部也是使用它,默認使用Buffer:
use strict ;
require( should );
constpath=require( path );
constvfs=require( vinyl-fs );
constwelcome=require( ../index );
describe( welcome to Tmall ,function(){
it( should work when buffer ,done=>{
vfs.src(path.join(__dirname, src , testfile.js ))
.pipe(welcome())
.on( data ,function(vf){
vf.contents.toString().should.be.eql(`// 天貓前端招人,有意向的請發送簡歷至lingyucoder@gmail.com
use strict ;
console.log( hello world );
`);
done();
});
});
});
這樣測了Buffer格式後算是完成了主要功能的測試,那麼要如何測試流格式呢?
構建流格式的虛擬文件對象
方案一和上面一樣直接使用vinyl-fs,增加一個參數buffer: false即可:
把代碼修改成這樣:
use strict ;
require( should );
constpath=require( path );
constvfs=require( vinyl-fs );
constPluginError=require( gulp-util ).PluginError;
constwelcome=require( ../index );
describe( welcome to Tmall ,function(){
it( should work when buffer ,done=>{
// blabla
});
it( should throw PluginError when stream ,done=>{
vfs.src(path.join(__dirname, src , testfile.js ),{
buffer:false
})
.pipe(welcome())
.on( error ,e=>{
e.should.be.instanceOf(PluginError);
done();
});
});
});
這樣vinyl-fs直接從文件系統讀取文件並生成流格式的vinyl對象。
如果內容並不來自於文件系統,而是來源於一個已經存在的可讀流,要怎麼把它封裝成一個流格式的vinyl對象呢?
這樣的需求可以藉助vinyl-source-stream:
use strict ;
require( should );
constfs=require( fs );
constpath=require( path );
constsource=require( vinyl-source-stream );
constvfs=require( vinyl-fs );
constPluginError=require( gulp-util ).PluginError;
constwelcome=require( ../index );
describe( welcome to Tmall ,function(){
it( should work when buffer ,done=>{
// blabla
});
it( should throw PluginError when stream ,done=>{
fs.createReadStream(path.join(__dirname, src , testfile.js ))
.pipe(source())
.pipe(welcome())
.on( error ,e=>{
e.should.be.instanceOf(PluginError);
done();
});
});
});
這裡首先通過fs.createReadStream創建了一個可讀流,然後通過vinyl-source-stream把這個可讀流包裝成流格式的vinyl對象,並交給我們的插件做處理
Gulp插件執行錯誤時請拋出PluginError,這樣能夠讓gulp-plumber這樣的插件進行錯誤管理,防止錯誤終止構建進程,這在gulp watch時非常有用
模擬Gulp運行
我們偽造的對象已經可以跑通功能測試了,但是這數據來源終究是自己偽造的,並不是用戶日常的使用方式。如果採用最接近用戶使用的方式來做測試,測試結果才更加可靠和真實。那麼問題來了,怎麼模擬真實的Gulp環境來做Gulp插件的測試呢?
首先模擬一下我們的項目結構:
test
├── build
│ └── testfile.js
├── gulpfile.js
└── src
└── testfile.js
一個簡易的項目結構,源碼放在src下,通過gulpfile來指定任務,構建結果放在build下。按照我們平常使用方式在test目錄下搭好架子,並且寫好gulpfile.js:
use strict ;
constgulp=require( gulp );
constwelcome=require( ../index );
constdel=require( del );
gulp.task( clean ,cb=>del( build ,cb));
gulp.task( default ,[ clean ],()=>{
returngulp.src( src/**/* )
.pipe(welcome())
.pipe(gulp.dest( build ));
});
接著在測試代碼里來模擬Gulp運行了,這裡有兩種方案:
使用child_process庫提供的spawn或exec開子進程直接跑gulp命令,然後測試build目錄下是否是想要的結果
直接在當前進程獲取gulpfile中的Gulp實例來運行Gulp任務,然後測試build目錄下是否是想要的結果
開子進程進行測試有一些坑,istanbul測試代碼覆蓋率時時無法跨進程的,因此開子進程測試,首先需要子進程執行命令時加上istanbul,然後還需要手動去收集覆蓋率數據,當開啟多個子進程時還需要自己做覆蓋率結果數據合併,相當麻煩。
那麼不開子進程怎麼做呢?可以藉助run-gulp-task這個工具來運行,其內部的機制就是首先獲取gulpfile文件內容,在文件尾部加上module.exports = gulp;後require gulpfile從而獲取Gulp實例,然後將Gulp實例遞交給run-sequence調用內部未開放的APIgulp.run來運行。
我們採用不開子進程的方式,把運行Gulp的過程放在before鉤子中,測試代碼變成下面這樣:
use strict ;
require( should );
constpath=require( path );
construn=require( run-gulp-task );
constCWD=process.cwd();
constfs=require( fs );
describe( welcome to Tmall ,()=>{
before(done=>{
process.chdir(__dirname);
run( default ,path.join(__dirname, gulpfile.js ))
.catch(e=>e)
.then(e=>{
process.chdir(CWD);
done(e);
});
});
it( should work ,function(){
fs.readFileSync(path.join(__dirname, build , testfile.js )).toString().should.be.eql(`// 天貓前端招人,有意向的請發送簡歷至lingyucoder@gmail.com
use strict ;
console.log( hello world );
`);
});
});
這樣由於不需要開子進程,代碼覆蓋率測試也可以和普通Node.js模塊一樣了
測試命令行輸出
雙一個煎蛋的栗子
當然前端寫工具並不只限於Gulp插件,偶爾還會寫一些輔助命令啥的,這些輔助命令直接在終端上運行,結果也會直接展示在終端上。比如一個簡單的使用commander實現的命令行工具:
// in index.js
use strict ;
constprogram=require( commander );
constpath=require( path );
constpkg=require(path.join(__dirname, package.json ));
program.version(pkg.version)
.usage( [options] )
.option( -t, --test , Run test )
.action((file,prog)=>{
if(prog.test)console.log( test );
});
module.exports=program;
// in bin/cli
#!/usr/bin/env node
use strict ;
constprogram=require( ../index.js );
program.parse(process.argv);
!program.args[]&&program.help();
// in package.json
{
"bin":{
"cli-test":"./bin/cli"
}
}
攔截輸出
要測試命令行工具,自然要模擬用戶輸入命令,這一次依舊選擇不開子進程,直接用偽造一個process.argv交給program.parse即可。命令輸入了問題也來了,數據是直接console.log的,要怎麼攔截呢?
這可以藉助sinon來攔截console.log,而且sinon非常貼心的提供了mocha-sinon方便測試用,這樣test.js大致就是這個樣子:
use strict ;
require( should );
require( mocha-sinon );
constprogram=require( ../index );
constuncolor=require( uncolor );
describe( cli-test ,()=>{
letrst;
beforeEach(function(){
this.sinon.stub(console, log ,function(){
rst=arguments[];
});
});
it( should print "test" ,()=>{
program.parse([
node ,
./bin/cli ,
-t ,
file.js
]);
returnuncolor(rst).trim().should.be.eql( test );
});
});
PS:由於命令行輸出時經常會使用colors這樣的庫來添加顏色,因此在測試時記得用uncolor把這些顏色移除
小結
Node.js相關的單元測試就扯這麼多了,還有很多場景像伺服器測試什麼的就不扯了,因為我不會。當然前端最主要的工作還是寫頁面,接下來扯一扯如何對頁面上的組件做測試。
關於本文
作者:@LingyuCoder
原文:https://github.com/tmallfe/tmallfe.github.io/issues/37
點擊展開全文
※聊一聊前端自動化測試(下)
※如果你的產品停止成長,你該怎麼做?
※流形-大數據浪潮下的前端工程師
※解剖react組件的多種寫法與演進
※漫談前端體系建設
TAG:前端早讀課 |
※聊一聊 網紅燈具們的前半生(上)
※聊一聊清明節上墳的講究
※聊一聊電腦散熱器進化
※聊一聊數字電路中時鐘抖動
※聊一聊動漫中那些末日下的人性!
※細細聊一聊坐洋的前世今生
※聊一聊戰術裝備上的激光切割技術
※今天聊一聊手錶的上手效果及簡單參數
※聊一聊智慧城市的前世今生
※聊一聊歷史上真實的武松
※「運動健康+通話」二合一,聊一聊華為B5手環
※聊一聊紫外線
※聊一聊:來聊聊你心中的電子產品鄙視鏈
※聊一聊機械錶上鏈的那些問題
※「文鴛綉履」聊一聊中華足下的文化演變
※來聊一聊《復仇者聯盟3》里的角色吧
※今天聊一聊「潛規則」
※今天聊一聊巨型萌貨大貓的進化史和共同點~
※今天聊一聊巨型萌貨大貓的進化史和共同點
※聊一聊桶裝水的真相