我們曾經試圖遵循良好的編程習慣,在創建和定義方法時盡可能按照“職責單一”和“開放-封閉”原則將那些沒有必要暴露出來的方法定義為私有方法,但是在撰寫測驗用例時又往往對這些設計原則嗤之以鼻,因為你會為無法撰寫測驗這些私有方法的測驗用例而感到苦惱,
從互聯網上找到的許多方法都不是最優解決方案,在本文中,我會首先介紹其中的一些解決方案,并指出它們存在的一些問題,然后,我會提供一些生產級別的代碼,它們包含了能夠通過單元測驗并成功實作100%覆寫率的一些私有方法,這些示例都是Node.js的代碼,
首先,讓我們考察下面的代碼:
module.exports = { myPubFunc: async function() { let val = await myPrivFunc(); // do something (perhaps, call 'myPrivFunc' again, if necessary); return true; } } function myPrivFunc() { return new Promise((resolve, reject) => { // if something is successful or true, 'resolve', otherwise, 'reject' }); }
這里我們暫時忽略函式myPrivFunc的具體內容,因為這不是我們關心的重點,這里主要的問題是函式myPrivFunc有其自身的代碼邏輯,它實作了單一的功能并且可能在該模塊內部被多次使用——僅限于在模塊內部被呼叫,正如你所看到的,創建私有函式增加了代碼的可讀性和可管理性,同時減少了代碼的復雜性和重復代碼的出現,有關是否需要私有方法的爭論有很多,但至今還沒有人能給出一個充分的理由,
反私有方法模式
針對私有方法,有許多不太好的解決方案,這些解決方案大多都來自互聯網上的博客,在介紹如何正確地對私有函式進行單元測驗之前,我想先說明一下哪些應該做哪些不應該做,在你使用這些解決方案之前,你應該首先搞清楚為什么要將函式設定為私有的,使用訪問修飾符(如private,internal,protected等)而不使用public的原因有很多——即使修飾符本身在某些語言(如JavaScript)中可能沒有被顯式地使用,但是在代碼中也可以被隱式地實作,
結合上面給出的示例代碼,我將給出一些反私有方法的例子,并說明這些解決方法為什么不好,
沒有私有函式
誠然,有些人為了單元測驗撰寫方便將所有的函式都公開,我不太贊同這一點,如果你遵循設計原則使得每個函式都具有單一的功能,那么一個良好的、可控的集成測驗是足以覆寫到所有的私有方法的,
“隱藏”屬性
我們將上面的示例代碼改寫如下:
module.exports = { myPubFunc: async function() { let val = await myPrivFunc(); // do something (perhaps, call 'myPrivFunc' again, if necessary); return true; }, __private__ : { myPrivFunc: myPrivFunc } }
這樣一來,私有函式myPrivFunc將通過“私有”屬性在該模塊中被暴露出來,我們通過一個名為"__private__"的公共屬性將私有屬性設定為公共屬性,從而去掉了私有函式,然后,該屬性并非真正的私有屬性,因為編輯器的智能感知功能仍然會顯式該屬性(如下圖所示),

所以,這其實并沒有任何意義,因為你已經暴露了私有方法,這和你最初的設計背道而馳,
模塊依賴
我們將上面的示例代碼改寫如下:
var private = require('./private'); module.exports = { myPubFunc: async function() { let val = await private.myPrivFunc(); // do something (perhaps, call 'myPrivFunc' again, if necessary); return true; } }
同樣,你在這個模塊中去掉了私有函式,但是該函式卻成了另一個模塊中的公共函式,并且,你也不能阻止其他用戶在無意中直接呼叫你設定的這個所謂的私有函式,這種設計在生產環境中通常是危險的(因為,將函式設定為私有的總是有原因的),另外,這里的命名也會讓人覺得很詭異(如private.myPrivateFunc),
過載測驗
這可以是幾種反私有方法模式的混合,考慮下面的代碼:
module.exports = { myPubFunc: async function() { let val = await myPrivFunc(); // do something (perhaps, call 'myPrivFunc' again, if necessary); return true; } /* test-code */ ,testPrivFunc: function(fn) { myPrivFunc = fn; } /* end-test-code */ }
這個糟糕的設計提供了一個特殊的方法,用來將一個函式作為引數賦值給myPrivFunc,但是testPrivFunc只用于進行單元測驗,在生產環境中將會被忽略,因為它包含在特定的注釋塊中,開發人員會使用諸如gulp工具以及像gulp-strip-code這樣的插件來清楚這些特定注釋塊之間的代碼,以便在構建生產代碼時保持代碼的整潔,保持生產級別的代碼干凈是件好事,但為什么開發環境的代碼就一定要是臟的呢?為什么我們不能使開發環境和生產環境的代碼都是干凈的呢?這樣我們在除錯時也會方便些,
依賴注入
到目前為止,這個是最復雜的反私有函式模式,不過這僅僅只是為了在進行單元測驗時保證私有函式的安全,而且是多此一舉,查看下面的代碼(非ES6):
function PublicFuncs(privateService) { this.privateService = privateService; } PublicFuncs.prototype.myPubFunc = async function() { let val = await this.privateService.myPrivFunc(); // do something (perhaps, call 'myPrivFunc' again, if necessary); return true; } module.exports = { PublicFuncs: PublicFuncs }
注意,這里的myPrivFunc是一個服務提供者的屬性,它在實體化時通過某種依賴注入的方式來提供,如上面的代碼在實際使用場景中會像下面這樣:
var publicFuncs = require('./publicFuncs').PublicFuncs(privateService); var result = publicFuncs.myPubFunc();
從純技術的角度來說這是可行的,但這完全是多此一舉,依賴注入被用來在服務和消費者之間實作松耦合是一個不錯的設計,而在同一個類或模塊中的公共方法和私有方法之間通過依賴注入實作松耦合卻有點大材小用,
以上是幾種反私有方法模式的介紹,接下來我將介紹如何對私有函式進行單元測驗,
為了說明如何正確地對私有函式進行單元測驗,我將使用下面的代碼來嘗試洗掉一個臨時的markdown檔案,
注意:模塊messaging只是一個簡單的模塊,它的作用是將標準訊息寫入控制臺,為了提高可維護性,我將所有面向用戶的訊息都存盤在一個檔案中,對這個示例而言,訊息本身并不重要,在我們的單元測驗中它將會被stub掉,
var fs = require('fs'); var path = require('path'); var messaging = require('./messaging'); module.exports = { /** * Deletes the `temppdf.md` file. * * Deletes the temporary PDF markdown file. * * @returns {boolean} Returns true if the file is was successfully deleted (or non-existent). Returns false if the file cannot be deleted (e.g. file lock, etc.). */ deleteTempPdf: async function () { let tempPdf = path.resolve('temppdf.md'); let stat = await checkFileAccess(tempPdf); if (stat == 0) { messaging.printTempPdfMessage(0); return true; } else if (stat == 1) { messaging.printTempPdfMessage(1); return false; } else if (stat == 2) { // File is writable (e.g. no lock), attempt to delete fs.unlinkSync(tempPdf); // Check file status again stat = await checkFileAccess(tempPdf); if (stat == 0) { messaging.printTempPdfMessage(2); return true; } else { messaging.printTempPdfMessage(3); return false; } } else { messaging.printTempPdfMessage(4); return false; } } } /** * Checks file access. * * Checks a given file for access on the filesystem. * * @param {string} file Path of the file. * * @return {number} 0 if file doesn't exist; 1 if file is readonly; 2 if file is writable */ function checkFileAccess(file) { return new Promise((resolve) => { fs.access(file, fs.constants.F_OK | fs.constants.W_OK, (err) => { if (err) { if (err.code === 'ENOENT') { resolve(0); } else { resolve(1); } } else { resolve(2); } }); }); }
這里你看到有兩個方法,deleteTempPdf是一個公共方法,checkFileAccess是一個私有方法,checkFileAccess使用fs庫來檢查檔案的訪問條件,
注意:fs.exists方法已被棄用,因此這里我們使用fs.access,
如你所見,我們從JSDoc的描述中得知,'checkFileAccess'回傳三種可能的值:檔案不存在回傳'0';檔案只讀回傳'1'(如檔案被鎖定,或者當前權限不允許寫檔案);檔案存在并可寫回傳'2',
基于這些回傳值,函式deleteTempPdf將會進行相應的操作,如果檔案存在并洗掉成功,或者檔案不存在,deleteTempPdf將回傳true,否則,deleteTempPdf將回傳false,表示檔案不能洗掉,
查看deleteTempPdf函式中的if-then結構,邏輯如下:
- 如果檔案不存在,回傳true
- 否則,如果檔案是只讀的,回傳false
- 否則,如果檔案存在并且是可寫的:
- 嘗試洗掉檔案,然后再次檢查檔案
- 如果檔案不存在(洗掉成功),回傳true
- 否則(某些原因檔案沒有洗掉成功),回傳false
- 否則(未知問題),回傳false
根據以上幾個分支,我們可以確定需要撰寫下面幾個單元測驗:
- it('should return true for "temppdf.md" not existing')
- it('should return false for "temppdf,md" being readonly')
- it('should return true for "temppdf.md" existing, being writable and being deletred successfully')
- it('should return false for "temppdf.md" existing, being writable, but not deleted successfully')
- it('should return false for unknown error when checking access of "temppdf.md"')
下面是我們單元測驗檔案的第一個版本:
describe('tempFile', () => {
describe('deleteTempPdf', () => {
it('should return true for "temppdf.md" not existing', () => {
})
it('should return false for "temppdf.md" being readonly', () => {
})
it('should return true for "temppdf.md" existing, being writable and being deleted successfully', () => {
})
it('should return false for "temppdf.md" existing, being writable, but not deleted successfully', () => {
})
it('should return false for unknown error when checking access of "temppdf.md"', () => {
})
})
})
成功撰寫完這幾個單元測驗,我們的代碼率將達到100%,
接下來我們將實作這些單元測驗的具體代碼,
先決條件
我們將使用Mocha和Chai對單元測驗進行斷言,所以,首先需要將它們添加到專案中:
npm i mocha chai --save-dev
另外,由于我們的私有方法checkFileAccess使用了promise,Chai有一個額外的庫可以支持promise,我們將其一并添加到專案中:
npm i chai-as-promised --save-dev
最后,我們在測驗檔案的頭部添加以下代碼來匯入這些庫,以便在我們的單元測驗中使用它們:
var chai = require('chai'); var chaiAsPromised = require('chai-as-promised'); chai.use(chaiAsPromised).should();
額外說明
有許多Node modules可以"mock"檔案系統,以便對使用fs的方法進行單元測驗,它們實際上會創建一個物理存在的、臨時的檔案結構,但是在我看來,這并非是一個理想的測驗環境,因為1)從技術上來講,這是一個集成測驗;2)你的test runner有可能會并行執行測驗,并在檔案存在或者不應該存在的地方產生一些問題;其次3)如果你的test runner在測驗程序中出錯,檔案系統不會被清理,在測驗程序中產生的檔案需要在下次測驗之前手動清理(例如洗掉臨時檔案和檔案夾),基于這些原因,我更加傾向于對fs進行stub,并控制輸出結果,
為了對fs進行stub,我們需要對fs.access回傳的錯誤代碼進行stub,讓我們把這個變數添加到describe陳述句的前面:
var err;
前三個測驗
前三個測驗非常簡單,你可以在下面的代碼中看到,但是我們的測驗實際上還沒有通過,因為我們還沒有對fs或printTempPdsMessage方法進行stub,后面馬上就會講到,我們繼續在測驗用例中添加必要的代碼,
我們將分別介紹每個測驗用例,
it('should return true for "temppdf.md" not existing', () => {
err = {
code: 'ENOENT'
};
return tempFile.deleteTempPdf().should.eventually.be.true;
})
在第一個測驗用例中,fs.access應該回傳一個object,其中的code值ENOENT表示檔案temppdf.md不存在,所以,我們mock該object以確保fs.access回傳正確的結果,這樣的話,checkFileAccess將回傳'0'從而滿足我們的第一個條件,測驗代碼中的should.eventually.be斷言是chai-as-promised提供的Chai的擴展,允許我們可以測驗checkFileAccess方法的promise回傳值,最后需要注意的是,tempFile是通過模塊匯入到測驗檔案中的,我們會在后面匯入該檔案,
it('should return false for "temppdf.md" being readonly', () => {
err = {
code: 'SOMETHING_ELSE'
};
return tempFile.deleteTempPdf().should.eventually.be.false;
})
這個測驗和第一個測驗很相似,只是error code不同,在第一個測驗中,我們通過ENOENT來表示temppdf.md檔案不存在,但是在這個測驗中,我們希望檔案存在,但是是只讀的,為了進行測驗,我們只需要error code不是ENOENT就可以,所以這里我們隨便提供了一個code,
it('should return false for "temppdf.md" existing, being writable, but not deleted successfully', () => {
err = null;
return tempFile.deleteTempPdf().should.eventually.be.false;
})
第三個測驗的err是null,這將促使checkFileAccess回傳'2',并且在deleteTempPdf方法中兩次呼叫checkFileAccess并最侄訓傳false,
至此,我們已經完成了三個測驗,但是它們還不能運行,因為我們還需要對一些方法進行stub,接下來就是見證奇跡的時刻了,到目前為止,你的測驗代碼應該像下面這樣:
describe('tempFile', () => {
var err;
describe('deleteTempPdf', () => {
it('should return true for "temppdf.md" not existing', () => {
err = {
code: 'ENOENT'
};
return tempFile.deleteTempPdf().should.eventually.be.true;
})
it('should return false for "temppdf.md" being readonly', () => {
err = {
code: 'SOMETHING_ELSE'
};
return tempFile.deleteTempPdf().should.eventually.be.false;
})
it('should return true for "temppdf.md" existing, being writable and being deleted successfully', () => {
})
it('should return false for "temppdf.md" existing, being writable, but not deleted successfully', () => {
err = null;
return tempFile.deleteTempPdf().should.eventually.be.false;
})
it('should return false for unknown error when checking access of "temppdf.md"', () => {
})
})
})
好了,接下來讓我們進入到最激動人心的部分——在測驗用例中處理私有方法,
Sinon + Rewire
為了實作這一功能,我們需要引入兩個得力助手——Sinon和Rewire,稍后我會介紹它們的用途,首先讓我們將它們添加到專案中:
npm i sinon rewire --save-dev
當然,我們還需要在測驗檔案中添加對它們的參考:
var sinon = require('sinon'); var rewire = require('rewire');
Sinon是一個非常強大的庫,用于輔助進行單元測驗,它允許我們對單元測驗撰寫stubs、shims和mocks,有了Sinon,我們可以對stubs和shims進行控制,以驗證它們是否被呼叫了以及被呼叫了多少次,Sinon有非常多的功能,我無法在這里一一列出,有關更詳細的介紹可以查看它的官網,
Rewire提供了一個由Node.js撰寫的被稱之為模塊封裝的功能,Rewire可以重新封裝我們的模塊,從而允許我們"rewire"快取的版本并從記憶體中替換掉原有模塊中的屬性,這樣,我們就可以在記憶體中重寫checkFileAccess函式,而不必采用我們前面提到的反私有方法模式,我們可以反復呼叫rewire,而rewire每次都會創建一個新的快取版本,
Setup
除了我們前面添加的err變數外,我們還需要另外兩個變數——一個是用于測驗的sandbox(用于stubs),另一個用來快取我們的測驗模塊tempFile(對本例而言,我們的測驗模塊保存在'tempFile.js'檔案中),
describe('tempFile', () => {
var tempFile;
var sandbox;
var err;
...
有關這兩個變數的初始化和設定稍后我會講到,
我們還需要為Node.js的fs模塊添加一個stub,如果你仔細查看我們的測驗代碼,你會發現基本上我們需要對fs模塊的三個屬性進行stub或者mock:1)fs.access;2)fs.unlinkSync;3)被fs.access使用的constants物件(F_OK和W_OK屬性),
接著剛才的代碼,我們繼續添加對fs模塊的stub部分:
var fsStub = { constants: { F_OK : 0, W_OK: 0 }, access: function(path, mode, cb) { cb(err, []); }, unlinkSync: function(path) { } }
這里有幾個需要注意的地方,首先,我們不用太關心constants物件中各個屬性的值具體是什么,因為我們只是對fs.access進行stub,我們將constants物件的兩個屬性的值都設定為'0',不論是fs.access還是fs.unlinkSync,它們的stub都可以正常作業,我們唯一需要額外處理的一點是,在fs.access的stub中呼叫回呼函式cb,正如你所看到的,這個回呼函式會接收我們測驗中的err變數并進行處理,
Setup和Teardown
我們已經添加了所有的全域變數,現在開始添加beforeEach和afterEach鉤子函式,它們將在每個單元測驗運行前和運行后自動執行,
beforeEach((done) => { tempFile = rewire('../tempFile'); tempFile.__set__({ 'fs': fsStub, 'messaging': { printTempPdfMessage: function() {} } }); sandbox = sinon.createSandbox(); done(); }); afterEach((done) => { sandbox.restore(); done(); });
在beforeEach函式中,我們使用Rewire匯入tempFile.js檔案,這里之所以沒有使用require而用rewire,是因為每次通過rewire匯入檔案時都會快取一個新的副本,這樣每次測驗時都會獲得一個干凈的版本,
接下來,我們通過Rewire的__set__方法覆寫tempFile代碼中的fs方法和messaging,我們用上面創建的fsStub來替換fs,同時我們也替換了messaging,它其中的printTempPdfMessage是一個空函式,這樣做的一個好處是我們不需要通過其它的方式來阻止該函式中原本的Console.log陳述句的執行,這個被替換過的printTempPdfMessage函式仍然會被呼叫并執行,但它什么都不會做,
然后,我們通過Sinon的createSandbox方法來為我們的測驗程式創建一個stubs,
最后,在afterEach函式中,我們將sandbox進行恢復,以便其它的單元測驗繼續執行,
最后幾個單元測驗
現在,我們準備完成剩下的幾個單元測驗,我們還是一個一個來看,
it('should return true for "temppdf.md" existing, being writable and being deleted successfully', () => {
var checkFileAccess = sandbox.stub();
checkFileAccess.onCall(0).returns(2);
checkFileAccess.onCall(1).returns(0);
err = null;
tempFile.__set__({
'checkFileAccess': checkFileAccess
});
return tempFile.deleteTempPdf().should.eventually.be.true;
})
首先要做的是為私有方法checkFileAccess創建一個stub,這個sandbox是在beforeEach函式中初始化的,所以這里可以直接使用它來創建stub,這里我們不需要為這個方法提供任何實作邏輯,而只需要宣告它被呼叫時的回傳值,你應該已經注意到了,checkFileAccess第一次被呼叫時(索引為0)回傳值為'2',第二次被呼叫時我們規定回傳值為'0',這么做是為了驗證我們在deletedTempPdf方法中的if-then陳述句的邏輯,
同時這里我們還將err變數設定為null,
最后,結合在beforeEach函式中已經覆寫過的fs和messaging的printTempPdfMessage方法,我們又通過Rewire的__set__方法覆寫了tempFile中的checkFileAccess方法,同樣,這個覆寫過的方法不會做任何事情,它只會回傳我們設定的值,這就是Rewire神奇的地方,它允許我們通過stub覆寫私有方法,
接下來是最后一個單元測驗:
it('should return false for unknown error when checking access of "temppdf.md"', () => {
var checkFileAccess = sandbox.stub();
checkFileAccess.onCall(0).returns(3);
tempFile.__set__({
'checkFileAccess': checkFileAccess
});
return tempFile.deleteTempPdf().should.eventually.be.false;
})
它和前一個單元測驗的唯一區別是checkFileAccess方法被呼叫時的回傳值不同,因為這里我們將檢查deleteTempPdf方法如何根據checkFileAccess方法的回傳值來做出正確的回應,這里的回傳值是'3',而不是'0'、'1'或'2',這將促使deleteTempPdf方法進入到最后一個else分支中,
現在,我們已經完成了所有的作業,并且我們的單元測驗代碼覆寫率可以達到100%,
下面是完整的測驗代碼:
var chai = require('chai'); var chaiAsPromised = require('chai-as-promised'); chai.use(chaiAsPromised).should(); var sinon = require('sinon'); var rewire = require('rewire'); describe('tempFile', () => { var tempFile; var sandbox; var err; var fsStub = { constants: { F_OK : 0, W_OK: 0 }, access: function(path, mode, cb) { cb(err, []); }, unlinkSync: function(path) { } } beforeEach((done) => { tempFile = rewire('../tempFile'); tempFile.__set__({ 'fs': fsStub, 'messaging': { printTempPdfMessage: function() {} } }); sandbox = sinon.createSandbox(); done(); }); afterEach((done) => { sandbox.restore(); done(); }); describe('deleteTempPdf', () => { it('should return true for "temppdf.md" not existing', () => { err = { code: 'ENOENT' }; return tempFile.deleteTempPdf().should.eventually.be.true; }) it('should return false for "temppdf.md" being readonly', () => { err = { code: 'SOMETHING_ELSE' }; return tempFile.deleteTempPdf().should.eventually.be.false; }) it('should return true for "temppdf.md" existing, being writable and being deleted successfully', () => { var checkFileAccess = sandbox.stub(); checkFileAccess.onCall(0).returns(2); checkFileAccess.onCall(1).returns(0); err = null; tempFile.__set__({ 'checkFileAccess': checkFileAccess }); return tempFile.deleteTempPdf().should.eventually.be.true; }) it('should return false for "temppdf.md" existing, being writable, but not deleted successfully', () => { err = null; return tempFile.deleteTempPdf().should.eventually.be.false; }) it('should return false for unknown error when checking access of "temppdf.md"', () => { var checkFileAccess = sandbox.stub(); checkFileAccess.onCall(0).returns(3); tempFile.__set__({ 'checkFileAccess': checkFileAccess }); return tempFile.deleteTempPdf().should.eventually.be.false; }) }) })
值得注意的是,如果你在單元測驗中使用諸如nyc或者istanbuljs等第三方庫來輔助輸出測驗結果并給出代碼覆寫率,你可能需要小心使用Rewire來覆寫你代碼中的私有方法,因為這類別庫的作業原理是基于require參考的,對于在測驗中使用rewire參考可能會影響最終的代碼覆寫率的準確度,不過這也不是絕對的,需要根據最終的使用情況來定,
原文地址:https://jdav.is/2019/01/29/using-sinonrewire-for-unit-testing-with-private-methods/
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/375133.html
標籤:其他
