本文為來自飛書 aPaaS Growth 研發團隊成員的文章。
aPaaS Growth 團隊專注在用戶可感知的、宏觀的 aPaaS 應用的搭建流程,及租戶、應用治理等產品路徑,致力於打造 aPaaS 平台流暢的 「應用交付」 流程和體驗,完善應用構建相關的生態,加強應用搭建的便捷性和可靠性,提升應用的整體性能,從而助力 aPaaS 的用戶增長,與基礎團隊一起推進 aPaaS 在企業內外部的落地與提效。
前言什麼是自動化測試上述代碼使用了Object.prototype.toString來判斷了數據類型,我們針對上述代碼的測試用例(此處斷言使用node原生的assert方法,採用BDD的測試風格):
//test/getTag.spec.jsconstassert=require('assert');const{getTag}=require('../index');describe('檢查:getTag函數執行',function(){before(function(){console.log('😁before鈎子觸發');});describe('測試:正常流',function(){it('類型返回:[object JSON]',function(done){setTimeout(()=>{assert.equal(getTag(JSON),'[objectJSON]');done();},1000);});it('類型返回:[object Number]',function(){assert.equal(getTag(1),'[objectNumber]');});});describe('測試:異常流',function(){it('類型返回:[object Undefined]',function(){assert.equal(getTag(undefined),'[objectUndefined]');});});after(function(){console.log('😭after鈎子觸發');});});mocha提供的api語義還是比較強的,即使沒寫過單元測試代碼,單看這段代碼也不難理解這段代碼幹了啥,而這段測試代碼頁會作為我們最後驗證簡易Mocha的樣例,我們先來看下使用mocha運行該測試用例的執行結果:

如上圖所示,即我們前面測試代碼的執行結果,我們來拆分下當前mocha實現的一些功能點。
註:mocha更多使用方法可參考Mocha - the fun, simple, flexible JavaScript test framework[1]
核心函數afterEach:在每個測試單元執行結束後觸發該鈎子;
這種異步代碼在我們實際業務中也是十分常見的,比如某一部分代碼依賴接口數據的返回,或是對某些定時器進行單測用例的編寫。mocha支持兩種方式的異步代碼,一種是回調函數直接返回一個Promise,一種是支持在回調函數中傳參數done,手動調用done函數來結束用例。
執行結果和執行順序上面的mocha文件夾就是我們將要實現的簡易版mocha目錄,目錄結構參考的mocha源碼,但只採取了核心部分目錄結構。
總體流程設計入口文件更新為:
//mocha-demo/mocha/index.jsconstMocha=require('./src/mocha');constmocha=newMocha();mocha.run();測試用例的執行過程順序尤其重要,前面說過用例的執行遵循從外到里,從上到下的順序,對於describe和it的回調函數處理很容易讓我們想到這是一個樹形結構,而且是深度優先的遍歷順序。簡化下上面的用例代碼:
describe('檢查:getTag函數執行',function(){describe('測試:正常流',function(){it('類型返回:[object JSON]',function(done){setTimeout(()=>{assert.equal(getTag(JSON),'[objectJSON]');done();},1000);});it('類型返回:[object Number]',function(){assert.equal(getTag(1),'[objectNumber]');});});describe('測試:異常流',function(){it('類型返回:[object Undefined]',function(){assert.equal(getTag(undefined),'[objectUndefined]');});});});針對這段代碼結構如下:

整個樹的結構如上,而我們在處理具體的函數的時候則可以定義Suite/Test兩個類來分別描述describe/it兩個函數。可以看到describe函數是存在父子關係的,關於Suite類的屬性我們定義如下:
//mocha/src/suite.jsclassSuite{/****@param{*}parent父節點*@param{*}titleSuite名稱,即describe傳入的第一個參數*/constructor(parent,title){this.title=title;//Suite名稱,即describe傳入的第一個參數this.parent=parent//父suitethis.suites=[];//子級suitethis.tests=[];//包含的it測試用例方法this._beforeAll=[];//before鈎子this._afterAll=[];//after鈎子this._beforeEach=[];//beforeEach鈎子this._afterEach=[];//afterEach鈎子//將當前Suite實例push到父級的suties數組中if(parentinstanceofSuite){parent.suites.push(this);}}}module.exports=Suite;而Test類代表it就可以定義的較為簡單:
//mocha/src/test.jsclassTest{constructor(props){this.title=props.title;//Test名稱,it傳入的第一個參數this.fn=props.fn;//Test的執行函數,it傳入的第二個參數}}module.exports=Test;此時我們整個流程就出來了:
收集測試用例的執行結果。

OK,思路已經非常清晰,實現一下具體的代碼吧
實現創建根節點為方便支持各種測試風格接口我們進行統一的導出:
//mocha/interfaces/index.js'usestrict';exports.bdd=require('./bdd');然後在Mocha類中進行bdd接口的全局掛載:
//mocha/src/mocha.jsconstinterfaces=require('../interfaces');classMocha{constructor(){//this.rootSuite=...//注意第二個參數是我們的前面創建的根節點,此時interfaces['bdd'](global,this.rootSuite"'bdd'");}run(){}}module.exports=Mocha;此時我們已經完成了api的全局掛載,可以放心導入測試用例文件讓函數執行了。
導入測試用例文件上面函數簡單的實現了一個方法,用來遞歸的讀取本地所有的測試用例文件,然後在Mocha類中使用該方法加載我們當前的測試用例文件:
//mocha/src/mocha.jsconstpath=require('path');constinterfaces=require('../interfaces');constutils=require('./utils');classMocha{constructor(){//this.rootSuite=...//interfaces['bdd'](global,this.rootSuite"'bdd'");//寫死我們本地測試用例所在文件夾地址constspec=path.resolve(__dirname,'../../test');constfiles=utils.findCaseFile(spec);//加載測試用例文件files.forEach(file=>require(file));}run(){}}module.exports=Mocha;創建Suite-Test樹注意,上面的代碼我們僅僅是通過執行describe的回調函數將樹的結構創建了出來,裡面具體的測試用例代碼(it的回調函數)還未開始執行。基於以上代碼,我們整個Suite-Test樹就已經創建出來了,截止到目前的代碼我們收集用例的過程已經實現完成。此時我們的Sute-Test樹創建出來是這樣的結構:

我們改造下之前創建的Suite-Test樹,將it、before、after、beforeEach和afterEach的回調函數進行適配:
//mocha/interfaces/bdd.jsconstSuite=require('../src/suite');constTest=require('../src/test');const{adaptPromise}=require('../src/utils');module.exports=function(context,root){constsuites=[root];//context是describe的別名,主要目的是處於測試用例代碼的組織和可讀性的考慮//context.describe=context.context=...context.it=context.specify=function(title,fn){constcur=suites[0];consttest=newTest(title,adaptPromise(fn));cur.tests.push(test);}context.before=function(fn){constcur=suites[0];cur._beforeAll.push(adaptPromise(fn));}context.after=function(fn){constcur=suites[0];cur._afterAll.push(adaptPromise(fn));}context.beforeEach=function(fn){constcur=suites[0];cur._beforeEach.push(adaptPromise(fn));}context.afterEach=function(fn){constcur=suites[0];cur._afterEach.push(adaptPromise(fn));}}執行測試用例此時梳理下測試用例的執行邏輯,基於以上創建的Suite-Test樹,我們可以對樹進行一個遍歷從而執行所有的測試用例,而對於異步代碼的執行我們可以借用async/await來實現。此時我們的流程圖更新如下:

整個思路梳理下來就很簡單了,針對Suite-Test樹,從根節點開始遍歷這棵樹,將這棵樹中所有的Test節點所掛載的回調函數進行執行即可。相關代碼實現如下:
//mocha/src/runner.jsclassRunner{constructor(){super();//記錄suite根節點到當前節點的路徑this.suites=[];}/**主入口*/asyncrun(root){//開始處理Suite節點awaitthis.runSuite(root);}/**處理suite*/asyncrunSuite(suite){//1.執行before鈎子函數if(suite._beforeAll.length){for(constfnofsuite._beforeAll){constresult=awaitfn();}}//推入當前節點this.suites.unshift(suite);//2.執行testif(suite.tests.length){for(consttestofsuite.tests){//執行test回調函數awaitthis.runTest(test);}}//3.執行子級suiteif(suite.suites.length){for(constchildofsuite.suites){//遞歸處理Suiteawaitthis.runSuite(child);}}//路徑棧推出節點this.suites.shift();//4.執行after鈎子函數if(suite._afterAll.length){for(constfnofsuite._afterAll){//執行回調constresult=awaitfn();}}}/**處理Test*/asyncrunTest(test){//1.由suite根節點向當前suite節點,依次執行beforeEach鈎子函數const_beforeEach=[].concat(this.suites).reverse().reduce((list,suite)=>list.concat(suite._beforeEach),[]);if(_beforeEach.length){for(constfnof_beforeEach){constresult=awaitfn();}}//2.執行測試用例constresult=awaittest.fn();//3.由當前suite節點向suite根節點,依次執行afterEach鈎子函數const_afterEach=[].concat(this.suites).reduce((list,suite)=>list.concat(suite._afterEach),[]);if(_afterEach.length){for(constfnof_afterEach){constresult=awaitfn();}}}}module.exports=Runner;將Runner類注入到Mocha類中:
//mocha/src/mocha.jsconstRunner=require('./runner');classMocha{//constructor()..run(){construnner=newRunner();runner.run(this.rootSuite);}}module.exports=Mocha;簡單介紹下上面的代碼邏輯,Runner類包括兩個方法,一個方法用來處理Suite,一個方法用來處理Test,使用棧的結構遍歷Suite-Test樹,遞歸處理所有的Suite節點,從而找到所有的Test節點,將Test中的回調函數進行處理,測試用例執行結束。但到這裡我們會發現,只是執行了測試用例而已,測試用例的執行結果還沒獲取到,測試用例哪個通過了,哪個沒通過我們也無法得知。
收集測試用例執行結果我們需要一個中間人來記錄下執行的結果,輸出給我們,此時我們的流程圖更新如下:
修改Runner類,讓它繼承EventEmitter,來實現事件的傳遞工作:
//mocha/src/runner.jsconstEventEmitter=require('events').EventEmitter;//監聽事件的標識constconstants={EVENT_RUN_BEGIN:'EVENT_RUN_BEGIN',//執行流程開始EVENT_RUN_END:'EVENT_RUN_END',//執行流程結束EVENT_SUITE_BEGIN:'EVENT_SUITE_BEGIN',//執行suite開始EVENT_SUITE_END:'EVENT_SUITE_END',//執行suite結束EVENT_FAIL:'EVENT_FAIL',//執行用例失敗EVENT_PASS:'EVENT_PASS'//執行用例成功}classRunnerextendsEventEmitter{//.../**主入口*/asyncrun(root){this.emit(constants.EVENT_RUN_BEGIN);awaitthis.runSuite(root);this.emit(constants.EVENT_RUN_END);}/**執行suite*/asyncrunSuite(suite){//suite執行開始this.emit(constants.EVENT_SUITE_BEGIN,suite);//1.執行before鈎子函數if(suite._beforeAll.length){for(constfnofsuite._beforeAll){constresult=awaitfn();if(resultinstanceofError){this.emit(constants.EVENT_FAIL,`"beforeall"hookin${suite.title}:${result.message}`);//suite執行結束this.emit(constants.EVENT_SUITE_END);return;}}}//...//4.執行after鈎子函數if(suite._afterAll.length){for(constfnofsuite._afterAll){constresult=awaitfn();if(resultinstanceofError){this.emit(constants.EVENT_FAIL,`"afterall"hookin${suite.title}:${result.message}`);//suite執行結束this.emit(constants.EVENT_SUITE_END);return;}}}//suite結束this.emit(constants.EVENT_SUITE_END);}/**處理Test*/asyncrunTest(test){//1.由suite根節點向當前suite節點,依次執行beforeEach鈎子函數const_beforeEach=[].concat(this.suites).reverse().reduce((list,suite)=>list.concat(suite._beforeEach),[]);if(_beforeEach.length){for(constfnof_beforeEach){constresult=awaitfn();if(resultinstanceofError){returnthis.emit(constants.EVENT_FAIL,`"beforeeach"hookfor${test.title}:${result.message}`)}}}//2.執行測試用例constresult=awaittest.fn();if(resultinstanceofError){returnthis.emit(constants.EVENT_FAIL,`${test.title}`);}else{this.emit(constants.EVENT_PASS,`${test.title}`);}//3.由當前suite節點向suite根節點,依次執行afterEach鈎子函數const_afterEach=[].concat(this.suites).reduce((list,suite)=>list.concat(suite._afterEach),[]);if(_afterEach.length){for(constfnof_afterEach){constresult=awaitfn();if(resultinstanceofError){returnthis.emit(constants.EVENT_FAIL,`"aftereach"hookfor${test.title}:${result.message}`)}}}}}Runner.constants=constants;module.exports=Runner在測試結果的處理函數中監聽執行結果的回調進行統一處理:
//mocha/reporter/sped.jsconstconstants=require('../src/runner').constants;constcolors={pass:90,fail:31,green:32,}functioncolor(type,str){return'\u001b['+colors[type]+'m'+str+'\u001b[0m';}module.exports=function(runner){letindents=0;letpasses=0;letfailures=0;lettime=+newDate();functionindent(i=0){returnArray(indents+i).join('');}//執行開始runner.on(constants.EVENT_RUN_BEGIN,function(){});//suite執行開始runner.on(constants.EVENT_SUITE_BEGIN,function(suite){++indents;console.log(indent(),suite.title);});//suite執行結束runner.on(constants.EVENT_SUITE_END,function(){--indents;if(indents==1)console.log();});//用例通過runner.on(constants.EVENT_PASS,function(title){passes++;constfmt=indent(1)+color('green','✓')+color('pass','%s');console.log(fmt,title);});//用例失敗runner.on(constants.EVENT_FAIL,function(title){failures++;constfmt=indent(1)+color('fail','×%s');console.log(fmt,title);});//執行結束runner.once(constants.EVENT_RUN_END,function(){console.log(color('green','%dpassing'),passes,color('pass',`(${Date.now()-time}ms)`));console.log(color('fail','%dfailing'),failures);});}上面代碼的作用對代碼進行了收集。
驗證
我們再手動構造一個失敗用例:
constassert=require('assert');const{getTag}=require('../index');describe('檢查:getTag函數執行',function(){before(function(){console.log('😁before鈎子觸發');});describe('測試:正常流',function(){it('類型返回:[object JSON]',function(done){setTimeout(()=>{assert.equal(getTag(JSON),'[objectJSON]');done();},1000);});it('類型返回:[object Number]',function(){assert.equal(getTag(1),'[objectNumber]');});});describe('測試:異常流',function(){it('類型返回:[object Undefined]',function(){assert.equal(getTag(undefined),'[objectUndefined]');});it('類型返回:[object Object]',function(){assert.equal(getTag([]),'[objectObject]');});});after(function(){console.log('😭after鈎子觸發');});});執行下:

一個精簡版mocha就此完成!
後記https://github.com/mochajs/mocha
https://mochajs.org/
參考資料Mocha - the fun, simple, flexible JavaScript test framework: https://mochajs.org/
以上便是本次分享的全部內容,希望對你有所幫助^_^
喜歡的話別忘了分享、點讚、收藏三連哦~。
歡迎關注公眾號ELab團隊收貨大廠一手好文章~
aPaaS Growth 團隊專注在用戶可感知的、宏觀的 aPaaS 應用的搭建流程,及租戶、應用治理等產品路徑,致力於打造 aPaaS 平台流暢的 「應用交付」 流程和體驗,完善應用構建相關的生態,加強應用搭建的便捷性和可靠性,提升應用的整體性能,從而助力 aPaaS 的用戶增長,與基礎團隊一起推進 aPaaS 在企業內外部的落地與提效。