Javascript Classes vs. Closures (3/3)
Testability
This post is the last in a three-part series comparing two close substitutes in Javascript: classes and the “closure pattern” (also frequently known as the factory class pattern). Part one argued that closures have a lintability advantage. Part two argued that classes, on balance, have a performance advantage. This post will show an example of how classes can be easier to test.
Mocking and Monkey-patchings
At Livestream, we put a lot of effort into mocking. We incessantly mock not only each other but entities in our code. For the former, our sharp wits are sufficient; for the latter, we rely heavily upon the library Sinon.JS, and we love it.
Unfortunately, sometimes it can be ‘hard to reach’ a particular method inside your test, which makes mocking that method hard or impossible. (Except, not quite impossible — for better or worse, nothing is impossible in Javascript). In particular, a method is ‘hard to reach’ if you both:
- Don’t use dependency injection (DI). If you consistently use DI, you can inject any mock when you initialize what is being tested.
- AND you use closures instead of classes. If you use classes, all instances of your class share the method on the prototype, you can reference it from your test and override it with a mock. If you use closures, then your instance methods are inaccessible, and you cannot do this. It is possible in node.js, but it requires dark magic.
It’s example time!
Example 1: Dependency Injection with Closures
Suppose we have an HttpClient, implemented using the closure pattern.
'use strict'
function HttpClient (config) {
return {
get: function (path, callback) {
// The body of this function doesn't matter,
// we're going to mock it in our tests.
}
}
}
module.exports = HttpClient
Now suppose we have a model that uses the HttpClient, and expects it to be provided via DI.
// foo_model.js'use strict'
function FooModel (fooClient) {
return {
getById: function (fooId, callback) {
return fooClient.get(`/foo/${fooId}`, (err, result) => {
if (err) return callback(err)
const foo = JSON.parse(result.body)
return callback(null, foo)
})
}
}
}
module.exports = FooModel
Now suppose we want to write a test for the getById function. We want to mock our HttpClient class and make sure it is called as we expect it to be. This is easy! We construct a mock using our good friend Sinon.JS, and inject it.
// test.js'use strict'
const sinon = require('sinon')
const FooModel = require('./foo_model')describe('Foo', () => {
describe('#getById', () => {
describe('when fooId is 10', () => {
let mockFooClient
beforeEach(() => {
mockFooClient = { get: sinon.spy() }
})
it('sends GET /foo/10 to the foo service', () => {
// Inject the mock http client into FooModel
const fooModel = FooModel(mockFooClient)
// Call the function under test
fooModel.getById(10)
// Assert that our mock was called as expected.
sinon.assert.calledWith(mockFooClient.get, '/foo/10')
})
})
})
})
Too easy!
Example 2: Non-DI with Closures
In this example, the FooModel is tweaked slightly to initialize its own HttpClient, instead of having it injected.
// foo_model.js
const HttpClient = require('./http_client')function FooModel (config)
// no easy way to access 'fooClient' from a test!
const fooClient = HttpClient(config)
return {
getById: function (fooId, callback) {
return fooClient.get(`/foo/${fooId}`, (err, result) => {
if (err) return callback(err)
const foo = JSON.parse(result.body)
return callback(null, foo)
})
}
}
}
module.exports = FooModel
We can’t inject our mock function anymore. In fact, the only possible way to attach our mock to the fooClient it is to call upon the darker magicks and mess with the Node.js require cache. Something like this:
'use strict'
const sinon = require('sinon')describe('Foo', () => {
describe('#getById', () => {
describe('when fooId is 10', () => {
let mockFooClient
beforeEach(() => {
mockFooClient = { get: sinon.spy() }
}) let FooModel
beforeEach(() => {
// Clear 'foo_model' from the require cache
// in case it's already there.
delete require.cache
[require.resolve('./foo_model')] // Make sure 'http_client' is in the require cache.
require('./http_client') // Override http_client with our mock.
require.cache
[require.resolve('./http_client')]
.exports =
() => mockFooClient // Reload foo_model, now using the
// overriden http_client module
FooModel = require('./foo_model')
}) it('sends GET /foo/10 to the foo service', () => {
const fooModel = FooModel()
fooModel.getById(10)
sinon.assert.calledWith(mockFooClient.get, '/foo/10')
})
})
})
})
I did it by hand, which is painful — but this is actually a viable testing strategy. There’s a wonderful library called Mockery that gives you the tools to be able to do this more easily and helps you with some of the gotchas this approach has. However, overriding things in the require cache is a delicate art. Even with the right tools there is some mental and coding overhead into getting it right. It is better if you don’t have to in the first place.
If you wish to inject a mock into code written this way, but cannot afford a major refactor, the lightest touch is often to expose the subject of your mocking on the returned object:
function FooModel (config) { const fooClient = HttpClient(config)
return {
getById: function (fooId, callback) {
return fooClient.get(`/foo/${fooId}`, (err, result) => {
if (err) return callback(err)
const foo = JSON.parse(result.body)
return callback(null, foo)
})
},
_fooClient: fooClient }
}
The mock can be injected as follows:
const fooModel = FooModel(config)
fooModel._fooClient.get = sinon.spy()
This doesn’t seem too bad in a trivial example — but the ‘harder to reach’ the function is, the more awkward it will be to expose it.
We can avoid this problem altogether if we use classes.
Example 3: Non-DI with Classes
If the HttpClient used the class pattern instead of the closure pattern, it is easier to test. No need to do dark magic on the require cache , no need to dangle testing apparatus from all your objects — you can override the ‘get’ method directly on the HttpClient prototype.
// http_client.js'use strict'
const request = require('request')
class HttpClient {
constructor (config) {
this.config = config
}
get (path, callback) {
// The body of this still doesn't matter. We are
// going to replace it with a mock.
}
}
module.exports = HttpClient// foo_model.js
// The only difference between this and the previous iteration
// Is the 'new' keyword (because HttpClient is a class now)'use strict'
const HttpClient = require('./http_client')
function FooModel (config) {
const fooClient = new HttpClient(config)
return {
getById: function (fooId, callback) {
return fooClient.get(`/foo/${fooId}`, (err, result) => {
if (err) return callback(err)
const foo = JSON.parse(result.body)
return callback(null, foo)
})
}
}
}
module.exports = FooModel//test.js
'use strict'
const sinon = require('sinon')
const FooModel = require('./foo_model')
const HttpClient = require('./http_client')describe('Foo', () => {
describe('#getById', () => {
describe('when fooId is 10', () => {
let mockFooClient
let $get
beforeEach(() => {
$get = HttpClient.prototype.get
HttpClient.prototype.get = sinon.stub()
})
afterEach(() => {
// Clean up for other tests in the test suite.
HttpClient.prototype.get = $get
}) it('sends GET /foo/10 to the foo service', () => {
const fooModel = FooModel()
fooModel.getById(10)
sinon.assert.calledWith(HttpClient.prototype.get, '/foo/10')
})
})
})
})
See? No messing with the require cache necessary. Pretty straightforward. It can get tedious storing, overriding, and restoring every method you want to mock. Sinon has a syntax (sinon.stub(object, “method”)) which makes this a little more convenient. That’s not the solution I favor — more in a future blog post!
The takeaway from all this: if you use DI all the way down, mocking is a cinch. If you don’t, then your testing life might be harder if the mocked modules are closures instead of classes.
I hope this post and its two older siblings were informative, entertaining, and/or effective fodder for arguments with your colleagues!
—
Article too long? Follow me on Twitter.
Article too short? Stay tuned to the Livestream engineering blog for more.
Also, we’re hiring.