What are some ways to spy on exported module methods?
Assume we have a module resources.js. All of its methods are being exported for unittesting.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
module.exports = { | |
getData: getData, | |
buildRequest: buildRequest, | |
makeRequest: makeRequest | |
}; | |
function buildRequest(resource) { | |
return 'request'; | |
} | |
function getData(resource, callback) { | |
var request = buildRequest(resource); | |
makeRequest(request, callback); | |
} | |
function makeRequest(request, onCompleteFn) { | |
// do some IO get some data or receive an error | |
var someFakeData = {}; | |
onCompleteFn(someFakeData); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var assert = require('chai').assert; | |
var resources = require('./resources.js'); | |
var sinon = require('sinon'); | |
describe('resources.js', function() { | |
describe('getData()', function() { | |
it('calls makeRequest with a built request', function() { | |
resources.makeRequest = sinon.spy(); | |
var resource = 'a resource'; | |
var request = resources.buildRequest(resource); | |
var callback = sinon.spy(); | |
resources.getData(resource, callback); | |
assert.isTrue( | |
resources.makeRequest.withArgs(request, callback).calledOnce | |
); | |
}); | |
}); | |
}); | |
----- | |
resources.js | |
getData() | |
1) calls makeRequest with a built request | |
0 passing (14ms) | |
1 failing | |
1) resources.js getData() calls makeRequest with a built request: | |
AssertionError: expected false to be true | |
at Context.<anonymous> (test.spec.js:15:20) |
To illustrate this; if resources.js were to use the object it is exporting in its calls, then the spy would be created and used as expected!!!
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
module.exports = { | |
getData: getData, | |
buildRequest: buildRequest, | |
makeRequest: makeRequest | |
}; | |
function buildRequest(resource) { | |
return 'request'; | |
} | |
function getData(resource, callback) { | |
var request = buildRequest(resource); | |
module.exports.makeRequest(request, callback); | |
} | |
function makeRequest(request, onCompleteFn) { | |
// do some IO get some data or receive an error | |
var someFakeData = {}; | |
onCompleteFn(someFakeData); | |
} | |
---- | |
resources.js | |
getData() | |
✓ calls makeRequest with a built request | |
1 passing (12ms) |
While the above works, I personally don't think it is very clear, (and haven't really seen modules that use module.exports in its function implementations. I also think it is dangerous to reference module.exports internally to a module because, clients of that module can mutate it!!!!
A similar way to mock makeRequest is to export a reference to an object used internally.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var utils = { | |
buildRequest: buildRequest, | |
makeRequest: makeRequest | |
}; | |
module.exports = { | |
getData: getData, | |
buildRequest: buildRequest, | |
makeRequest: makeRequest, | |
utils: utils | |
}; | |
function buildRequest(resource) { | |
return 'request'; | |
} | |
function getData(resource, callback) { | |
var request = utils.buildRequest(resource); | |
utils.makeRequest(request, callback); | |
} | |
function makeRequest(request, onCompleteFn) { | |
// do some IO get some data or receive an error | |
var someFakeData = {}; | |
onCompleteFn(someFakeData); | |
} | |
------ | |
var assert = require('chai').assert; | |
var resources = require('./resources.js'); | |
var sinon = require('sinon'); | |
describe('resources.js', function() { | |
describe('getData()', function() { | |
it('calls makeRequest with a built request', function() { | |
resources.utils.makeRequest = sinon.spy(); | |
var resource = 'a resource'; | |
var request = resources.buildRequest(resource); | |
var callback = sinon.spy(); | |
resources.getData(resource, callback); | |
assert.isTrue( | |
resources.utils.makeRequest.withArgs( | |
request, callback).calledOnce | |
); | |
}); | |
}); | |
}); | |
----- | |
$ mocha *.spec.js | |
resources.js | |
getData() | |
✓ calls makeRequest with a built request | |
1 passing (13ms) |
I think this way is cleaner than using module.exports directly, but is still susceptible to being mutated by a client!
Dependency injection is a strong tool for creating node code that can be easily tested; by providing a clean way to mock IO dependencies. It requires that the caller provides (injects) a functions dependencies. In this case getData depends on functions that perform IO, makeRequest. Refactoring it would require the caller of getData to provide a makeRequest function. This would seamlessly allow a test to provide a mocked makeRequest method (that doesn't make IO calls), and the actual code to provide a different makeRequest method (which does make IO calls).
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var assert = require('chai').assert; | |
var resources = require('./resources.js'); | |
var sinon = require('sinon'); | |
describe('resources.js', function() { | |
describe('getData()', function() { | |
it('calls makeRequest with a built request', function() { | |
var makeRequest = sinon.spy(); | |
var resource = 'a resource'; | |
var request = resources.buildRequest(resource); | |
var callback = sinon.spy(); | |
resources.getData(resource, makeRequest, callback); | |
assert.isTrue( | |
makeRequest.withArgs(request, callback).calledOnce | |
); | |
}); | |
}); | |
}); | |
---- | |
module.exports = { | |
getData: getData, | |
buildRequest: buildRequest, | |
makeRequest: makeRequest | |
}; | |
function buildRequest(resource) { | |
return 'request'; | |
} | |
function getData(resource, requestor, callback) { | |
var request = buildRequest(resource); | |
requestor(request, callback); | |
} | |
function makeRequest(request, onCompleteFn) { | |
// do some IO get some data or receive an error | |
var someFakeData = {}; | |
onCompleteFn(someFakeData); | |
} | |
--- | |
$ mocha *.spec.js | |
resources.js | |
getData() | |
✓ calls makeRequest with a built request | |
1 passing (12ms) |
getData(resource, resources.makeRequest, callback);
For the above example, getData only has a single dependency, while frequently in real world code methods may have multiple IO dependencies. While functions can usually be decomposed and refactored to achieve making a single call or two, doing so after code is already in production is dangerous. Because of how easy it is to create extremely nested node.js code it is very beneficial to design testable code from the start. A powerful tool to do this is dependency injection, something I plan on writing a lot more about very soon.
Keep testing, happy noding!!!