使用Sinon和Rewire對JavaScript中的私有方法進行單元測試

  我們曾經試圖遵循良好的編程習慣,在創建和定義方法時儘可能按照“職責單一”和“開放-封閉”原則將那些沒有必要暴露出來的方法定義爲私有方法,但是在編寫測試用例時又往往對這些設計原則嗤之以鼻,因爲你會爲無法編寫測試這些私有方法的測試用例而感到苦惱。

  從互聯網上找到的許多方法都不是最優解決方案。在本文中,我會首先介紹其中的一些解決方案,並指出它們存在的一些問題。然後,我會提供一些生產級別的代碼,它們包含了能夠通過單元測試併成功實現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結構,邏輯如下:

  1. 如果文件不存在,返回true
  2. 否則,如果文件是隻讀的,返回false
  3. 否則,如果文件存在並且是可寫的:
    1. 嘗試刪除文件,然後再次檢查文件
    2. 如果文件不存在(刪除成功),返回true
    3. 否則(某些原因文件沒有刪除成功),返回false
  4. 否則(未知問題),返回false

  根據以上幾個分支,我們可以確定需要編寫下面幾個單元測試:

  1. it('should return true for "temppdf.md" not existing')
  2. it('should return false for "temppdf,md" being readonly')
  3. it('should return true for "temppdf.md" existing, being writable and being deletred successfully')
  4. it('should return false for "temppdf.md" existing, being writable, but not deleted successfully')
  5. 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%。

  接下來我們將實現這些單元測試的具體代碼。

 

先決條件

  我們將使用MochaChai對單元測試進行斷言。所以,首先需要將它們添加到項目中:

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;

前三個測試

  前三個測試非常簡單,你可以在下面的代碼中看到。但是我們的測試實際上還沒有通過,因爲我們還沒有對fsprintTempPdsMessage方法進行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

  爲了實現這一功能,我們需要引入兩個得力助手——SinonRewire。稍後我會介紹它們的用途,首先讓我們將它們添加到項目中:

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

  我們已經添加了所有的全局變量,現在開始添加beforeEachafterEach鉤子函數,它們將在每個單元測試運行前和運行後自動執行。

    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/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章