A few weeks ago I found myself needing to make some changes to a legacy system. I wanted to add some tests to check my changes, and because that system had very few tests written for it, I also wound up doing some work on the test tooling. A lot of what I needed to stub out for testing, things like MongoDB and external API calls, could be done fairly easily with existing modules. I couldn't find anything to help with mocking memcached, though, so I started with a few stubs for my tests. That effort grew into a whole new module called memcached-mock. In case anyone else finds it useful, I thought I'd share a few tips on how to set that module up and use it.
The code I wanted to test was a little bit like this:
models/user.js
var app = require('../app')
;
/**
* Find a user by session token
*/
module.exports.findByToken = function(sessionToken, callback) {
// get the session information
app.memcached().get(sessionToken, function(err, data) {
if (err) {
app.logger().error("Error accessing memcached: %s", err);
return callback(err);
}
if (!data) {
app.logger().warn("Session token '%s' not found", sessionToken);
return callback(new Error("Invalid session token"));
}
// load the user from mongo
app.dbModel('User').findOne({userId: data.userId}, function(err, user) {
if (err) {
app.logger().error("Error accessing mongo: %s", err);
return callback(err);
}
if (!user) {
app.logger().error("User id '%s' not found in database", data.userId);
return callback(new Error("Invalid session token"));
}
callback(undefined, user);
});
});
}
For this particular application, most dependencies are supplied by a single module that is required by nearly every other file. That made it really easy to substitute various mocks for testing. The relevant portions of that module are shown below.
app.js
var util = require('util')
, Memcached = require('memcached')
, mongoose = require('mongoose')
;
// store lazily-created resources
var resources = {};
/**
* Return the current timestamp as a string
*/
function timestamp() {
return new Date().toISOString();
}
/**
* Initialize loggers
*/
function initLoggers() {
resources.loggers = {};
['fatal', 'error', 'warn', 'info', 'debug', 'trace'].forEach(function(level) {
resources.loggers[level] = function() {
console.log("%s - %s - %s", timestamp(), level, util.format.apply(util, arguments));
}
});
return resources.loggers;
}
/**
* Logger. Supports fatal, error, warn, info, debug, and trace levels.
*/
module.exports.logger = function() {
return resources.loggers || initLoggers();
}
/**
* Initialize memcached
*/
function initMemcached() {
resources.memcached = new Memcached("127.0.0.1:11211");
return resources.memcached;
}
/**
* Return the memcached client
*/
module.exports.memcached = function() {
return resources.memcached || initMemcached();
}
/**
* Initialize the database connection and schemas
*/
function initDb() {
resources.schemas = {};
mongoose.connect("mongodb://localhost/app");
resources.schemas.User = mongoose.model('User', new mongoose.Schema({}, {strict: false}));
return resources.schemas;
}
/**
* Return the named mongoose model
*/
module.exports.dbModel = function(name) {
var schemas = resources.schemas || initDb();
return schemas[name];
}
/**
* Directly access initialized resources
*/
module.exports.resources = function() {
return resources;
}
This is a bit simplified. In my actual system, the initialization of resources like database connections was actually handled by separate initializer scripts. Those in turn relied on external configuration information. In the interest of brevity, however, I've consolidated the work into a single module to illustrate the concept.
Substituting in the stubs I wanted for various resources is pretty straightforward at this point. I didn't want to do this for just a single test, however. I wanted to able to easily add more tests later and quickly set up a stubbed environment without having to resort to copying and pasting. So, I created a separate script tests could use for getting things set up quickly.
test/util/init.js
var app = require('../../app')
, Memcached = require('memcached-mock')
, mongoose = require('mongoose-mock')
, _ = require('lodash')
, util = require('util')
;
// captured logs
var logs = [];
// replaced methods
var replaced = [];
// set up stubs
_.extend(app.resources(), {
memcached: new Memcached("127.0.0.1:11211"),
schemas: {
User: mongoose.model('User', new mongoose.Schema({}, {strict: false}))
},
loggers: initLoggers()
});
/**
* Set up log capturing
*/
function initLoggers() {
return ['fatal', 'error', 'warn', 'info', 'debug', 'trace'].reduce(function(obj, level) {
obj[level] = function() { logs.push(level + ' - ' + util.format.apply(util, arguments)); };
return obj;
}, {});
}
/**
* Replace a method
*/
module.exports.replaceMethod = function(object, methodName, withFunction) {
replaced.push({name: methodName, was: object[methodName], on: object});
object[methodName] = withFunction;
}
/**
* Restore all replaced methods
*/
function restoreAllReplaced() {
while (replaced.length > 0) {
var restore = replaced.pop();
restore.on[restore.name] = restore.was;
}
}
module.exports.restoreAllReplaced = restoreAllReplaced;
/**
* Set everything up for a test
*/
module.exports.setUp = function(done) {
logs = [];
app.memcached().flush(_.noop);
if (done) done();
}
/**
* Clean up after a test
*/
module.exports.tearDown = function(done) {
restoreAllReplaced();
if (done) done();
}
/**
* Tests the captured logs to ensure they match the expected set
*/
module.exports.logsMatch = function(expected) {
function reportMismatch(a, b) {
console.error("Log entries do not match: %s != %s", a, b);
return true;
}
if (!_.isArray(expected)) expected = [expected];
var notMatched = _.find(expected, function(entry, idx) {
if (idx >= logs.length) return reportMismatch(entry);
var matches = entry === logs[idx];
if (_.isRegExp(entry)) matches = entry.test(logs[idx]);
if (!matches) return reportMismatch(entry, logs[idx]);
return false;
});
if (notMatched === undefined && logs.length > expected.length)
return !reportMismatch(undefined, logs[entry.length]);
return (notMatched === undefined);
}
Here I've substituted mock objects for real resources. The memcached-mock
object is constructed identically to its memcached
counterpart. One key difference is that everything is in memory, so all operations are actually synchronous. That means I don't have to wait for the flush()
completion callback to fire in setUp
. I can proceed as soon as the function returns.
With my utility functions in place, it becomes much simpler to write a test. My changes were mostly around logging. I had added a number of logging statements to make sure error messages weren't being silently discarded. Although logging statements are hardly critical code, it's still important to write tests that exercise those branches. That makes sure I haven't introduced a reference error that will crash my app instead of logging information. This project used nodeunit for testing, so I added a suite of tests for that tool. The relevant tests are shown below.
test/unit/user.js
var init = require('../util/init')
, app = require('../../app')
, User = require('../../models/user')
, Sarah = require('../fixtures/sarah')
, _ = require('lodash')
;
module.exports.setUp = init.setUp;
module.exports.tearDown = init.tearDown;
/**
* findByToken - normal path
*/
module.exports.findByToken = function(test) {
app.memcached().set('unit-test', {userId: Sarah.userId}, 0, _.noop);
app.dbModel('User').findOne.callsArgWith(1, undefined, Sarah);
User.findByToken('unit-test', function(err, user) {
test.ifError(err);
test.strictEqual(user, Sarah);
test.done();
});
}
/**
* findByToken - memcached error should log an error and return an error
*/
module.exports.findByToken_memcachedError = function(test) {
init.replaceMethod(app.memcached(), 'get', function(key, callback) {
callback(new Error("Oh no, an error!"));
});
User.findByToken('unit-test', function(err, user) {
test.ok(err);
test.ok(init.logsMatch([/^error - Error accessing memcached: ./]));
test.done();
});
}
/**
* findByToken - token not found should log a warning and return an error
*/
module.exports.findByToken_invalidSession = function(test) {
User.findByToken('unit-test', function(err, user) {
test.ok(err);
test.ok(init.logsMatch(["warn - Session token 'unit-test' not found"]));
test.done();
});
}
/**
* findByToken - db error should log an error and return an error
*/
module.exports.findByToken_dbError = function(test) {
app.memcached().set('unit-test', {userId: Sarah.userId}, 0, _.noop);
app.dbModel('User').findOne.callsArgWith(1, new Error("Oops!"));
User.findByToken('unit-test', function(err, user) {
test.ok(err);
test.ok(init.logsMatch([/^error - Error accessing mongo: ./]));
test.done();
});
}
/**
* findByToken - not found in db should log an error and return an error
*/
module.exports.findByToken_dbNotFound = function(test) {
app.memcached().set('unit-test', {userId: Sarah.userId}, 0, _.noop);
app.dbModel('User').findOne.callsArg(1);
User.findByToken('unit-test', function(err, user) {
test.ok(err);
test.ok(init.logsMatch([/^error - User id '[^']+' not found in database$/]));
test.done();
});
}
These tests reference a data fixture, which, for the sake of completeness, is shown below.
test/fixtures/sarah.json
{
"userId": "2296d3e0-2f04-4bbe-b87b-2a9c145bfd06",
"username": "sarahv",
"name": {
"first": "Sarah",
"last": "Vasquez"
},
"password": "5f4dcc3b5aa765d61d8327deb882cf99",
"isAdmin": false
}
There are a couple things worth nothing in the tests. The first is that populating the cache is done the same way you'd populate memcached itself. Since it's a functional mock, you can use the normal APIs to set up the correct state. The only difference is that the actual cache changes are made synchronously, so, here again, you can just ignore the callback.
The other item to note is how the error scenario is set up. The mock is intended as a stand-in for memcached in happy path scenarios. Very little setup work should be required in those cases. When you need to test more complex scenarios, it's best to drop in your own stubs as we've done here.
The obvious advantage of the memcached-mock
module over manual stubbing is time savings. For the happy path, you don't have to create the stubs yourself, just set up any data you want. Our test scenarios were very simple, but more complicated data sets can be easily modeled as well.
The other advantage is that tests become less brittle. It's perfectly easy to just add this to the start of every test:
app.resources().memcached = { get: function(key, cb) { cb(undefined, {userId: Sarah.userId}); } };
That accomplishes the same thing, but if your implementation changes, your test might need to as well. If, for example, the findByToken
function was changed to touch the key and update its expiration time, the tests would need updating. Affected tests would need their setup revised to add a touch method on the memcached stub, or they'd likely throw exceptions.
Using a functional mock, however, doesn't require updating. The only change would be to add a new test to check the added functionality. In effect, you're able to write tests that verify the outputs of your functions and methods without being tied as tightly to the details of how they do it.
Substituting a mock instance for our cache is great when there's a separate module that provides the dependency, like app.js
in the example. What happens, though, when you need to test code with a direct dependency on the memcached
module itself? In that case, using something like proxyquire can be helpful for substituting the dependency transparently. That's useful if your code is structured differently where modules using the cache may directly require memcached
, or for simply testing the cache initialization code. In our example, we can take that approach to write tests for app.js
. A sample of what that might be like is shown below using proxyquire
.
test/unit/app.js
var proxyquire = require('proxyquire')
, memcachedMock = require('memcached-mock')
, app = proxyquire('../../app', {memcached: memcachedMock})
;
/**
* memcached - should return an instance of memcached and always the same instance
*/
module.exports.testMemcached = function(test) {
var result = app.memcached();
// should be memcached
test.ok(result instanceof memcachedMock);
// should be the same thereafter
test.strictEqual(app.memcached(), result);
test.done();
}
The memcached-mock
module is mostly geared at handling the situation in this example app, where an application only has one cache (or cache cluster) it connects to. It is possible to support multiple caches it that's required, but it does require some additional setup. Assume, for example, that instead of just one cache connection, our app setup looked like this:
/**
* Initialize memcached connections
*/
function initMemcached() {
var caches = {};
caches.sessionCache = new Memcached("192.168.1.10:11211");
caches.apiCache = new Memcached(["192.168.1.11:11211","192.168.1.12:11211"]);
resources.memcached = caches;
return resources.memcached;
}
To test this setup, you can simply create two mock instances the same way our single instance was created. By default, however, all mock instances share the same cache, which could be a problem if you're testing a scenario where key collisions could occur between the caches. In that case you'd want to override the cache being used by one or both mocks. That just requires passing the object you want to use for caching to the cache()
method of the mock. So, initialization for tests might look something like:
// set up stubs
_.extend(app.resources(), {
memcached: {
sessionCache: new Memcached("192.168.1.10:11211"),
apiCache = new Memcached(["192.168.1.11:11211","192.168.1.12:11211"])
},
schemas: {
User: mongoose.model('User', new mongoose.Schema({}, {strict: false}))
},
loggers: initLoggers()
});
// separate api mock cache
var apiCacheObject = {};
// cache override
app.resources().memcached.apiCache.cache(apiCacheObject);
Hopefully this testing approach and the memcached-mock
module wind up being useful to others. It's brand new, so any feedback is appreciated, whether it's comments below or GitHub issues.