- Published on
UNIT TESTS 102: ISOLATING YOUR CODE WITH MOCKS AND STUBS
One of the most important things to remember when testing code is to provide a consistent environment for tests to run in. This is done by isolating the code from the outside world which not only makes for consistent results, but also results in failures in one module not having adverse effects on tests of another. Isolating code can seem like a challenging task, especially in the fast changing world of JavaScript.
Note: I use ES6 throughout this article; check out my in-depth article on ES6 that includes starting kits in Grunt, Gulp and Webpack flavours.
The first rule of thumb in unit testing is to only test the feature that is being tested. This may sound obvious, but given the number of internal and external dependencies in a function, reliance on data to be fetched from the server and asynchronous functionality, this can become challenging. My recommendation is to "mock all the things!".
..."My recommendation is to "mock all the things!"
Mocking involves modifying variable items around the test subject in a way that they provide consistent results; this way, the focus can remain on testing the subject and not the environment around it.
Mocking begins with stubs and mocks:
MOCKS AND STUBS
Stubs act as module/class doubles; these are written ahead of time to provide some sort of behavior other when imported during testing.
Mocks, on the other hand are usually declared in the test itself. They can also act as module/class doubles, but are more flexible, because their behaviour can be tailored for a specific test.
Frameworks that provide dependency injection typically provide mocking out of box, but in the world of ES6, native exports and imports as well as pure functions, mocking is left up to the developer, and it may be challenging to find the right tools for the job.
Let's unit test the following module:
import API from './api';
import Modal from './modal;
export default class Gallery {
constructor() {
// Call the back-end api to load products
API.loadProducts()
.then((response) => response.json())
.then(({ products }) => {
// open a modal to show that products were loaded
return Modal.open('Loaded ' + products.length + ' products.')
// assign the products
.then(() => this.products = products);
}
}
The first and most obvious test is to make sure that the products loaded are correctly assigned to the Gallery instance. Before we run our tests, we need to create a new instance of Gallery. I'll be using examples in Jasmine, but they should translate to other test frameworks as well.
Before running each test, things need to be set-up; in this case, its creating a new instance of Gallery. in Jasmine, beforeEach is a block that runs before each test, making it a perfect place for set up.
import Gallery from './gallery'
describe('Gallery', () => {
let gallery
beforeEach(() => {
gallery = new Gallery()
})
})
The first test will attempt to load products. When working with asynchronous functionality, Jasmine provides a done callback that needs to be invoked after the tests are run.
it('should succeed in loading products', (done) => {
done()
})
Since loadProducts returns a promise, we can place the done callback inside of .then().
it('should succeed in loading products', (done) => {
let ran = false
gallery.loadProducts().then(() => {
ran = true
expect(ran).toEqual(true)
done()
})
})
But, this test now relies on the promise to resolve, which may not happen if we have a bug, and failure to call done will result in a Jasmine timeout, which takes 2 seconds and pollutes the message for the actual reason for failure.
A way to prevent this issue, is to use a Jasmine spy:
it('should succeed in loading products', (done) => {
let spy = jasmine.createSpy('callback')
gallery.loadProducts().then(spy)
setTimeout(() => {
expect(spy).toHaveBeenCalled()
done()
}, 0)
})
Note that the setTimeout is required, because asynchronous functions and promises are placed at the bottom of the call stack, even when we mock them. Although this feels like a hack, the timeout will always be zero, as setTimeout is purely used to place the expect calls lower in the stack than the functions passed into the promise.
Depending on how the environment is set-up, these tests may or may not pass, but there's a fundamental issue here: API.loadProducts() is actually fetching products from the server on every test. The purpose of these tests is not to test the API, but to test Gallery, so we need to isolate Gallery from the APIs by mocking them.
MOCKING API CALLS
For mocking pure ES6 code, I highly recommend babel-plugin-rewire. This Babel plugin provides a intuitive API to mock almost anything inside of ES6 classes.
Before each test runs, we want to replace what API is inside Gallery, with some mock code.
This is done by using Rewire inside of beforeEach():
import Gallery from './gallery'
describe('Gallery', () => {
let gallery
beforeEach(() => {
Gallery.__Rewire__('API', {
loadProducts() {
return Promise.resolve({
json() {
return Promise.resolve()
},
})
},
})
gallery = new Gallery()
})
})
Note: the reason for the double Promise, is that we use window.fetch for AJAX requests, which comes with a .json() method that then resolves with the data in JSON format. The mocks need to stay consistent with the real APIs so that the code being tested can remain the same.
The tests will now bypass the real API module and instead use the mock, which will result in a consistent successful response.
To test the path were API.loadProducts() fails, a separate block can be added with a different mock:
import Gallery from './gallery'
const APIMock = {
loadProducts() {
return Promise.resolve({
json() {
return Promise.resolve()
},
})
},
}
const APIMockFail = {
loadProducts() {
return Promise.reject('Unable to fetch data from server')
},
}
describe('Gallery - success', () => {
let gallery
beforeEach(() => {
Gallery.__Rewire__('API', APIMock)
gallery = new Gallery()
})
})
describe('Gallery - failure', () => {
let gallery
beforeEach(() => {
Gallery.__Rewire__('API', APIMockFail)
gallery = new Gallery()
})
})
Next, the modal needs the same treatment. When testing, it is important to only test your code. The makers of the third-party modal should and do their own testing, and mocks are used to assume all went well and Modal returned successfully.
...
describe('Gallery', () => {
let gallery;
beforeEach(() => {
Gallery.__Rewire__('API', APIMock);
Gallery.__Rewire__('Modal', {
open() {
return Promise.resolve()
}
});
gallery = new Gallery();
});
});
At this point the code paths for Gallery.loadProducts() are isolated from their dependencies, but we have no data to work with, so let's look into mocking some data.
MOCKING DATA
Mock data (or dummy data) is used to provide a consistent testing environment when working with mock APIs. Their idea is quite simple: given the API returns some set of data, loadProducts() should process it and respond with another set of data. This allows us to add a condition to check the expected result data against the output of loadProducts(), which will give us an overall idea if the function works as expected.
First, both the mock API data and expected result data needs to be loaded, followed by mock APIs modified responding with this data:
import Gallery from './gallery';
import loadProducts from './fixtures/load-products.json';
import resultProducts from './fixtures/result-products.json';
const APIMock = {
loadProducts() {
return Promise.resolve({
json() {
return Promise.resolve(loadProducts);
}
})
}
}
describe('Gallery', () => {
let gallery;
beforeEach(() => {
Gallery.__Rewire__('API', APIMock);
...
Next, the result data is compared with the data output by loadProducts() using Jasmine's toEqual() function:
it('should succeed in loading products', (done) => {
let spy = jasmine.createSpy('callback');
gallery.loadProducts()
.then(spy);
setTimeout(()=> {
expect(spy).toHaveBeenCalled();
expect(gallery.products).toEqual(resultProducts);
done();
}, 0);
});
This should be a good test to ensure that loadProducts() actually does what it was designed to do.
Gotcha: having multiple tests rely on the same json file means they all share the same reference, and mutating objects in one test will affect the results in another. This doesn't happen with a server, because fresh data is received with every call.
Serializing and de-serializing can be used to achieve the same result:
...
const APIMock = {
loadProducts() {
return Promise.resolve({
json() {
return Promise.resolve(JSON.parse(JSON.stringify(loadProducts)));
}
});
}
}
...
MOCKING PRIVATE MEMBERS
Often while mocking it is necessary to get and set members in the private area of a class:
// file: Gallery.js
let someVariable = 'someValue';
class Gallery {
...
Rewire provides the __get__ and __set__ methods to access these properties.
// file: Gallery.spec.js
import Gallery from './gallery';
beforeEach(() => {
Gallery.__get__('someVariable'); // returns 'someValue'
Gallery.__set__('someVariable', 'value2'); // sets it to value2 for the test
});
MOCKING GLOBAL VARIABLES
A rule I tend to follow with mocks, is that I should be able to revert the mock to the original value after the test has run. This can make variables on window challenging, so I recommend storing a reference to these in the private area of the class, and using Rewire as described in the point above to remap it to a mock version. I feel this is better as it does not change the reference on window, in case there's a part of the application that relies on it during testing.
// file: Gallery.js
const Hammer = window.Hammer;
class Gallery {
...
// file: Gallery.spec.js
import Gallery from './gallery';
beforeEach(() => {
Gallery.__set__('Hammer', HammerMock);
});
MOCKING NON-DEFAULT EXPORTS
The more common way to expose a module to another in ES6 is to use export default .... This exposes the whole module, which needs to be imported by name by another module.
Another way that is especially useful in Utility files is to expose multiple functions separately, and import using the bracket syntax, as follows:
// file: utils.js
export function find() {}
export function sort() {}
// file: gallery.js
import { find, sort } from './utils'
This allows a module to only import parts of another module, and can have some nice side effects (in Webpack 2.0, tree shaking will not bundle any unused functions using this method).
Testing modules with non-default exports requires the use of RewireAPI:
// file: Utils.spec.js
import { find, sort, __RewireAPI__ as UtilsAPI } from './utils'
beforeEach(() => {
UtilsAPI.__Rewire__('UtilsAPI', UtilsAPIMock)
})
it('should find objects by type', () => {
expect(find()).toEqual([])
})
UtilsAPI is used to for mocking, while find and sort can be used directly inside of tests.
MOVING TO STUBS
If you find that you're mocking the same module over and over again when, it usually helps to stub it out, and import the stub version when needed:
//file: module.js
const API = {
loadProducts() {
... actual HTTP requests
});
}
}
export default API;
// file: module.stub.js
const APIMock = {
loadProducts() {
return Promise.resolve({
json() {
// mock data
return Promise.resolve(mockData);
}
});
}
}
export default APIMock;
// file: module.spec.js
import APIMock from './module.mock.js'
... use Rewire to mock the module using the stub
CONCLUSION
Mocking and Stubs are essential techniques for writing unit tests and Rewire babel plugin is a great way to isolate your ES6 code for more consistent, controlled testing. Happy testing!