As developers, we are tasked with reducing complexity. Complexity is all around us, and good code organization reduces complexity while at the same time supporting increased flexibility, ease of change, quicker onboarding, faster debugging, and my favorite, better testing.
Reduced Complexity, Better Testing
In this article, I discuss one of the widespread strategies for code organization: Dependency Injection (DI). In doing so, we examine how DI reduces complexity, and makes testing easier in a JavaScript application. For an article on DI strategies for Ruby applications, also see this blog post.
What is Dependency Injection?
Imagine some module A that depends on another module B to do its work. How does A gain access to B?
One option is to import
or require
the dependency:
// A.js const dependency = require('./B'); // use `dependency` as needed
There is nothing illegal about this approach, but one downside is that A and B are now tightly coupled. A is bound to B, and if we ever wanted to use something other than B down the road, we may find ourselves touching a lot of code.
With dependency injection, however, instead of using import
or require
to access B at build time, A exposes a means for B to be injected at runtime, and the dependency injection mechanism takes over from there by providing A what it needs to do its work.
Example: No Dependency Injection
Suppose we are tasked with developing an asynchronous method that issues an HTTP request and returns a Promise to let callers track the status of this operation.
Here’s an initial implementation using Node.js:
// ResourceGetter.js const api = require('https'); class ResourceGetter { get(uri) { return new Promise((resolve, reject) => { api.get(uri, (response) => { const { statusCode } = response; if (statusCode !== 200) { reject(new Error(`status code not 200: ${ statusCode}`)); return; } const data = []; response.on('data', (chunk) => data.push(chunk)); response.on('end', resolve(data.join(''))); }) .on('error', reject); }); } } module.exports = ResourceGetter;
The getter uses the https
module to do its work, and that dependency is loaded in the beginning of the file, a common approach. Unfortunately, this means that in order to test this function, we must mock https
somehow, ensuring that mocking occurs before the ResourceGetter
script is ever loaded into memory.
Such an approach may look like this, where https
is mocked…
// https.mock.js
module.exports = {
get: function () {
// ...
}
};
…and ResourceGetter is loaded using proxyquire to ensure the mocked https is loaded instead:
// ResourceGetter.spec.js
const proxyquire = require('proxyquire');
const https = require('./https.mock');
const ResourceGetter = proxyquire('./ResourceGetter, { https });
describe('ResourceGetter', () => {
let instance;
beforeEach(() => (instance = new ResourceGetter()));
// ...
});
Here, we are using proxyquire
to stub https
. While this works, it leaves something to be desired as we've just added another package and written code that is unnecessary. After all, proxyquire
cannot be a hard requirement for Node.js unit testing. What can be done about this?
Example: Dependency Injection
This is where dependency injection can be useful. With DI, our implementation changes slightly:
// ResourceGetter.js class ResourceGetter { constructor({ api }) { this.api = api; } get(uri) { return new Promise((resolve, reject) => { this.api.get(uri, (response) => { const { statusCode } = response; if (statusCode !== 200) { reject(new Error(`status code not 200: ${ statusCode}`)); return; } const data = []; response.on('data', (chunk) => data.push(chunk)); response.on('end', resolve(data.join(''))); }) .on('error', reject); }); } } module.exports = ResourceGetter;
With this update, testing no longer requires proxyquire
, as we simply inject the mocked https
:
// ResourceGetter.spec.js const https = require('./https.mock'); const ResourceGetter = require('./ResourceGetter'); describe('ResourceGetter', () => { let instance; beforeEach(() => (instance = new ResourceGetter({ api: https }))); // ... });
While the changes needed to implement dependency injection were slight, the effect is profound. By decoupling code, we not only simplify testing but also the application in question. Furthermore, substituting one dependency for another has just become trivial, should the need arise, making our application more extensible and future-proof.
Ultimately, dependency injection yields well-defined interfaces for connecting application components to reduce complexity, which is a win in almost all cases. So, the next time you're developing a component, module, service, or the like, utilize DI and enjoy the fruits of your labor when you revisit the code months later.