tldr: Testing promises is surprisingly hard. I wrote a mock-promises to address it.
A recent project of mine included a single page Marionette app that used promises around the requests for model data. Promises are a useful alternative to callbacks and events. More detailed explanations can be found here or here. If you have lots of event binding in your code, I suggest you give them a look.
In the course of my app, I might write some function addModelFromPromise
that looks something like
var models = [];
var promise = new Promise(function(resolve){resolve("I am a Model")});
....
var addModelFromPromise = function(promise, models) {
promise.then(function(model) {
models.push(model);
console.log("added model " + model);
});
console.log("I will add the model when the promise is done");
}
Where I used the ECMAScript 6 Promise API (somewhat available) to create a promise that will resolve to the string “I am a Model”. If you wanted to test addModelFromPromise
, you could imagine writing a Jasmine test for like
describe("addModelFromPromise", function() {
it("adds the model when the promise is done", function() {
var promise = new Promise(function(resolve){ resolve("I am a Model")});
var models = ["first model"];
addModelFromPromise(promise, models);
expect(models).toEqual(["first model", "I am a Model"]);
});
});
This test feels like it would pass, but the callback to the promise is asynchronous, and it will not be called until the synchronous JavaScript (ie your test), is finished. To solve this in Jasmine 2, you need to use the done()
callback. (If you are using jQuery promises, it has a sometimes-synchronous behavior that will often make the above test work, but this does not meet promises/A+ specification and all other libraries I’ve looked at are fully asynchronous.)
describe("addModelFromPromise", function() {
it("adds the model when the promise is done", function(done) {
var promise = new Promise(function(resolve){ resolve("I am a Model")});
var models = ["first model"];
addModelFromPromise(promise, models);
promise.then(function() {
expect(models).toEqual(["first model", "I am a Model"]);
done();
});
});
});
This works well enough, but at some cost. For starters, it is harder to read. Also, What happens if you did the done
wrong and the expect()
is never called? Now you want to write some extra code to check on this. Keep in mind, this is testing an extremely simple function, if you have more than one promise, or nested promises, or any other source of asynchrony, the tests require a tangle of callbacks before you can get to an expect()
.
To solve this problem, I wrote a promise mocking framework, mock-promises. The interface was inspired by jasmine-ajax and the development was done on Jasmine, but it does not require Jasmine.
With mock-promises installed, the new version of this test becomes
describe("addModelFromPromise", function() {
it("adds the model when the promise is done", function() {
var promise = new Promise(function(resolve){ resolve("I am a Model")});
var models = ["first model"];
addModelFromPromise(promise, models);
mockPromises.executeForPromise(promise);
expect(models).toEqual(["first model", "I am a Model"]);
});
});
The executeForPromise()
function synchronously executes all attached callbacks for that promise. Note that now everything is synchronous in this test, you don’t have to call your expect()
inside of any other callbacks. When there are multiple promises, there is no nesting within nesting, only an extra executeForPromise()
.
Testing With Deeply Nested Callbacks
Here is what a test might look like with nested, anonymous promises (full setup in jasmine_examples_spec.js).
it("safely adds a model to the collection", function(done) {
var modelPromise = getModel();
var collectionPromise = getCollection();
collectionPromise.then(function(collection) {
addModelIfSafe(modelPromise, collection);
modelPromise.then(function() {
setTimeout(function() {
setTimeout(function() {
expect(collection).toEqual(["first model", "I am a Model"]);
done();
}, 0);
}, 0);
});
});
});
It may not look like it, but I am pretty sure this is the best way to write this test. The first thing that stands out is the deeply nested callbacks. I had to use four anonymous functions just to get to my expect()
. The setTimeouts are even worse. Nothing like nested setTimeouts to feel confidence in your code execution order. This may look pretty bad, but I did end up needing to write things like this in my last project. I haven’t even included any other sources of asynchrony. Perhaps more importantly, this test only looks as good as it does because I’ve spent a lot of time writing asynchronous promise specs in Jasmine lately. The first time I wrote this test, it was twice as long and flaky. The analogous mock-promises spec is:
it("safely adds a model to the collection", function() {
var modelPromise = getModel();
var collectionPromise = getCollection();
var collection = mockPromises.valueForPromise(collectionPromise);
addModelIfSafe(modelPromise, collection);
mockPromises.executeForResolvedPromises();
mockPromises.executeForResolvedPromises();
expect(collection).toEqual(["first model", "I am a Model"]);
});
The nested promises do require promises to be flushed twice, but otherwise, the test is now much more straightforward.
There are some alternatives to mock-promises. You can mock the clock in jasmine and tick forward. This is will flush any resolved promises. Unfortunately, this does not work with native Promises. It should be possible with the other promise libraries, but sometimes requires mocking the clock before you load the library. In a similar way, you can flush the scope in Angular. This type of alternative is not as clear as resolving an individual promise, but is lighter on the mocking side. As far as I can tell, the most popular mocking alternative is Jasmine-As-Promised. It does not support Jasmine 2.0 or really solve the nested callbacks issue, which is why I wrote mock-promises.
I am no longer on a project that requires promises, but the promise testing challenge is still fun. Let me know if you want to know more, or if you know someone who has solved it a better way.
- Build software people ❤️ at SpringOne Platform 2017