It's common to write code in JavaScript as follows:
const foo = require('foo')
module.exports = function bar(){
foo.doSomething()
}
Let's say we'd like to test this. How do we go about that since we can't control and observe foo
from our tests?
Most solutions will focus on manipulating the require cache. If you've tried doing this, you'll know this is a world of pain and gets messy quickly. This is largely because we are dealing with global state. Even if you nuked the cache before every test, you still can't be sure there aren't any stale references. You may also not want to auto-mock it for absolutely everyone.
Other attempts include using some DI tool. Essentially you turn it into:
module.exports = function bar(foo){
foo.doSomething()
}
And put this with the information that bar
needs foo
, in some format, in some other file (often using special syntax too). The downside to this is that your function is no longer stand alone. You can't just run it directly or require('bar')
and invoke it. It's now coupled to some tool and out-of-band information.
A better approach is to simply use ES6 parameter defaults:
module.exports = function bar(foo = require('foo')){
foo.doSomething()
}
This way we have the best of all worlds: you've co-located the information that the module needs to run with the module itself, it's also easy to inject a test double and it doesn't require any library/framework.
Moreover, the example above is a little contrived. You probably have multiple dependencies, so instead of doing this:
module.exports = function bar(
baz = require('baz')
, foo = require('foo')
){
foo.doSomething()
}
It's better to do this:
module.exports = function bar({
foo = require('foo')
, baz = require('baz')
}){
foo.doSomething()
}
This way the invocation is order-insensitive and you can more easily choose what you want to inject and let the module use defaults for the others: bar(undefined, fakeFoo)
vs bar({ foo: fakeFoo })
.
The final step is that you probably have some other arguments which you may not want to specify in an object for ergonomic reasons. Again, you can get the best of both worlds by always keeping the dependencies as the last parameter:
function bar(some, common, params, {
foo = require('foo')
, baz = require('baz')
})
Note that you should not be tempted to move all your dependencies to work like this: there are some things like pure helper functions that you can keep as top-level imports. Whilst anything that creates side-effects, like databases, emailers, credit card processors, etc you will probably want to use this pattern. But more importantly, since the zero overhead makes mocking/stubbing really easy, don't hesitate to move things if you do need to: for example you can do today = new Date()
to control the date/time in your tests, or log = console.log.bind(console)
to silence or observe the logging statements in tests, etc.
This is also a good read: Singletons are pathological liars