vaccinate
v1.1.0
Published
An inversion-of-control utility to replace `require()` calls
Downloads
8
Maintainers
Readme
vaccinate
Vaccinate is an inversion of control (IoC) module that is probably too simple to qualify as actual IoC, but may require
the lowest learning curve or adjustments to existing code. It is a very simple design pattern in which, instead of
exporting the value that the developer would have for a particular module, he/she instead exports a function that
returns the value. The function accepts as parameters its various dependencies, which allows the developer to override
these dependencies in unit tests. Vaccinate only works in environments where modules can be loaded synchronously (i.e.
by require()
) and does not support circular dependencies by default.
The Problem
Consider this class that represents a User record. It features a save
method that either inserts or updates a user
record in the database, depending on its way of detecting if the user is new or existing.
// models/user.js
const dbConnection = require('../db-connection');
module.exports = class User {
constructor (formData) {
this.formData = formData;
}
save (callback) {
if (!this.id) {
dbConnection.insert(this.formData, callback);
} else {
dbConnection.update({id: this.id}, this.formData, callback);
}
}
};
// app.js
const User = require('./models/user');
var user = new User({email: 'some email'});
user.save((err) => {
if (err) throw err;
console.log('User created!');
});
You may want to write a unit test verifying that the save
function correctly chooses whether to insert or to update
the record, but you don't want your test runner to actually create or update a record in your real database! You want to
mock your database connection with a fake instance. With the way that this code is written, the only way to do that is
to clutter up db-connection.js
with some "if" condition checking to see if the current environment is the test
environment. But you want to keep your test-related code in your test files, not scattered throughout the app.
The Solution
One of the simplest ways to solve this problem is via inversion of control, where the module being tested (the User model) does not control its dependencies (the database connection), but rather the dependency is passed in by the module's user (the app). This is done by having the module export a function that returns what it normally would export, and this function accepts as arguments its dependencies.
// models/user.js
module.exports = (dbConnection) => {
return class User {
constructor (formData) {
this.formData = formData;
}
save (callback) {
if (!this.id) {
dbConnection.insert(this.formData, callback);
} else {
dbConnection.update({id: this.id}, this.formData, callback);
}
}
};
};
// app.js
const dbConnection = require('./db-connection');
const User = require('./models/user')(dbConnection);
var user = new User({email: 'some email'});
user.save((err) => {
if (err) throw err;
console.log('User created!');
});
With this setup, a unit test can easily mock the database connection in order to observe which method is being called by
save
, without performing any database interactions.
// tests/models/user.js
const User = require('../models/user')({
insert: (data, callback) => { ... },
update: (criteria, data, callback) => { ... }
});
describe('User', () => {
...
});
This is a solid design pattern; however, without any framework managing dependencies, it requires the developer to
constantly reference the module's list of dependencies and update the arguments passed in whenever that module is being
used (e.g. if the User
module later needs the fs
package to write to a filesystem log as well, app.js
needs to be
updated to pass in require('fs')
after dbConnection
).
Using Vaccinate
Simple Example
You can install Vaccinate in your app by running npm install vaccinate
.
Vaccinate uses this same pattern and adds just a little bit of flavor -- it simply injects the dependencies as they usually would resolve to be.
// models/user.js
module.exports = (dbConnection) => {
class User {
...
};
};
module.exports.$vaccinations = [__dirname + '../db-connection'];
// app.js
const vaccinate = require('vaccinate');
const User = vaccinate(require('./models/user'));
All that Vaccinate does is iterate through each value in the $vaccinations
array, require()
it, and invoke the
function, passing in the dependencies as arguments. The app calls vaccinate()
on the module to inject dependencies,
while the tests don't use Vaccinate at all, or they override how Vaccinate requires dependencies and pass different
values in.
vaccinate (func [, options])
The vaccinate()
function accepts two parameters:
- Function
func
: The IoC'd function to invoke with the dependencies - object
options
: A hash of options (optional)- string
dependenciesProperty
: The name of the array of dependency names. Default:'$vaccinations'
- string|[string]
moduleDir
: The absolute path to where Vaccinate should look in to find modules whose names begin with./
. For instance, if Vaccinate is used inapp.js
in your project root and this value is__dirname + '/modules'
, then regardless of the location ofuser.js
,./db-connection
will always resolve to[project root]/modules/db-connection.js
. If this value is an array, each directory is tried and the first to resolve with the module name is used. Default: None - Function
require
: The function that accepts a dependency name and the options hash and returns the dependency. Generally this function is useful to override in your unit test suite. Default: By default, this function will prepend the module name with the value ofoptions.moduleDir
if it begins with./
, then invokerequire()
on it. If the result ofrequire()
is a function with aoptions.dependenciesProperty
property on it, it will be recursively vaccinated. If the dependency passed in is not a string, it will simply be returned.
- string
vaccinate.defaults
The default options can be overridden by setting the options listed above on vaccinate.defaults
, e.g.
vaccinate.defaults.moduleDir = __dirname + '/modules';
Complete Example Usage
// app.js
const vaccinate = require('vaccinate');
vaccinate.defaults.moduleDir = __dirname + '/modules';
const User = vaccinate(require('./models/user'));
var user = new User({email: 'some email'});
user.save((err) => {
if (err) throw err;
console.log('User created!');
});
// models/user.js
module.exports = (dbConnection, fs) => {
class User {
constructor (formData) {
this.formData = formData;
}
getDbConnection () {
return dbConnection;
}
save (callback) {
if (!this.id) {
dbConnection.insert(this.formData, callback);
} else {
dbConnection.update({id: this.id}, this.formData, callback);
}
fs.appendFile('userlog.txt', '\nUser.save() called', (err) => { if (err) throw err; });
}
};
};
module.exports.$vaccinations = ['./dbConnection', 'fs'];
// modules/db-connection.js
module.exports = (mongodb, mongoConfig) => {
var dbReady = new Promise((resolve, reject) => {
mongodb.MongoClient.connect(mongoConfig.url, (err, db) => {
if (err) throw err;
resolve(db);
});
});
return {
insert: (record, callback) => {
dbReady.then(() => {
db.collection('users').insert(record, callback);
});
},
update: (criteria, record, callback) => {
dbReady.then(() => {
db.collection('users').update(criteria, record, callback);
});
}
};
};
module.exports.$vaccinations = ['mongodb', './mongo-config'];
// modules/mongo-config.js
module.exports = {
url: 'mongodb://localhost:27017/myproject'
};
// tests/models/user.js
const vaccinate = require('vaccinate');
vaccinate.defaults.moduleDir = [__dirname + '/../mocks', __dirname + '/../../modules'];
const User = vaccinate(require('./models/user'));
describe('User', () => {
describe('#save', () => {
it('calls `insert` on the dbCollection when the user does not have an id', () => {
var user = new User({});
var spy = sinon.spy(user.getDbConnection(), 'insert');
user.save(() => {});
spy.called.should.equal.true;
});
it('calls `update` on the dbCollection when the user does have an id', () => {
var user = new User({id: 1});
var spy = sinon.spy(user.getDbConnection(), 'update');
user.save(() => {});
spy.called.should.equal.true;
});
});
});
// tests/mocks/db-connection.js
var id = 0;
module.exports = {
insert: (data, callback) => {
setTimeout(() => {
data.id = ++id;
}, 0);
},
update: (data, callback) => {
setTimeout(callback, 0);
}
};
Testing
Vaccinate has a suite of Mocha tests in the test/
directory. Run the tests by installing Vaccinate with its dev
dependencies and then running npm test
.
Todos
- Remove dependency of Lodash -- we only need
_.defaults
and_.startsWith
- Rewrite ES6 in ES5 to be compatible with older versions of Node.js