使用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结构,逻辑如下:
- 如果文件不存在,返回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/