A JavaScript project I'm working on recently underwent a pretty good refactor. Many of the modules/methods in the application worked in a synchronous fashion which meant their unit tests were also generally synchronous. This was great because synchronous code is pretty much always easier to test since they're simpler and easier to reason about.
However, even though I new early on that I would likely have to turn a good number of my synchronous methods into asynchronous ones I tried holding off on that as long as absolutely necessary. I was in a mode of prototyping as much of the application out as possible before I wanted to be worried/thinking about asynchronous aspects of the code base.
Part of why I held of on this was because I was pretty confident using the new proposed ES7 async/await
syntax to turn the sync code into async code relatively easily. While there were a few bumps along the refactor actually went extremely well.
An example of one bump I ran into included replacing items.forEach(item => item.doSomethingNowThatWillBecomeAsyncSoon())
with something that worked asynchronously and I found this blog post immensely helpful. Basically, don't try to await a forEach
instead build a list of promises you can await.
Another one I ran into was dealing with async mocha tests, which is what the rest of this post is about.
MochaJS is great because the asynchronous testing has been there from the beginning. If you've done
(see what I did there?) any asynchronous testing with MochaJS then you already know that you can signal to Mocha an asynchronous test is done
by calling the test's async callback method.
Before we look at how to test asynchronous Mocha tests leveraging the new ES 7 async/await syntax, let's first take a little journey through some of the various asynchronous testing options with Mocha.
Note: you will see example unit tests that use the
expect(...).to.equal(...)
style assertions from ChaiJS.
How to create an asynchronous MochaJS test?
If you look at a normal synchronous test:
it("should work", function(){
console.log("Synchronous test");
});
all we have to do to turn it into an asynchronous test is to add a callback function as the first parameter in the mocha test function (I like to call it done
) like this
it("should work", function(done){
console.log("Synchronous test");
});
But that's an invalid asynchronous test.
Invalid basic async mocha test
This first async example test we show is invalid because the done
callback is never called. Here's another example using setTimeout
to simulate proper asynchronicity. This will show up in Mocha as a timeout error because we never signal back to mocha by calling our done
method.
it("where we forget the done() callback!", function(done){
setTimeout(function() {
console.log("Test");
}, 200);
});
Valid basic async mocha test
When we call the done
method it tells Mocha the asynchronous work/test is complete.
it("Using setTimeout to simulate asynchronous code!", function(done){
setTimeout(function() {
done();
}, 200);
});
Valid basic async mocha test (that fails)
With asynchronous tests, the way we tell Mocha the test failed is by passing an Error
or string
to the done(...)
callback
it("Using setTimeout to simulate asynchronous code!", function(done){
setTimeout(function() {
done(new Error("This is a sample failing async test"));
}, 200);
});
Invalid async with Promise mocha test
If you were to run the below test it would fail with a timeout error.
it("Using a Promise that resolves successfully!", function(done) {
var testPromise = new Promise(function(resolve, reject) {
setTimeout(function() {
resolve("Hello!");
}, 200);
});
testPromise.then(function(result) {
expect(result).to.equal("Hello World!");
done();
}, done);
});
If you were to open up your developer tools you may notice an error printed to the console:
Uncaught (in promise) i {message: "expected 'Hello!' to equal 'Hello World!'", showDiff: true, actual: "Hello!", expected: "Hello World!"}
The problem here is the expect(result).to.equal("Hello World!");
above will fail before we can signal to Mocha via the done()
of either an error or a completion which causes a timeout.
We can update the above test with a try/catch
around our expectations that could throw exceptions so that we can report any errors to Mocha if they happened.
it("Using a Promise that resolves successfully with wrong expectation!", function(done) {
var testPromise = new Promise(function(resolve, reject) {
setTimeout(function() {
resolve("Hello World!");
}, 200);
});
testPromise.then(function(result){
try {
expect(result).to.equal("Hello!");
done();
} catch(err) {
done(err);
}
}, done);
});
This will correctly report the error in the test.
But there is a better way with promises. (mostly)
Mocha has built-in support for async tests that return a Promise. However, run into troubles with async and promises in the hook functions like before/beforEach/etc...
. So if you keep reading you'll see a helper function that I've not had any issues with (besides it's a bit more work...).
Thanks to a comment from @syrnick below, I've extended this write-up...
Async tests can be accomplished in two ways. The first is the already shown done
callback. The second is if you returned a Promise
object from the test. This a great building block. The above example test has become a little verbose with all the usages of done
and the try/catch
- it just gets a little cumbersome to write.
If we wanted to re-write the above test we can simplify it to return just promise.
IMPORTANT: if you want to return a promise, you have to remove the
done
callback or mocha will assume you'll be using that first and not look for a promise return. Although I've seen comments in Mocha's github issues list where some people depend on it working with both a callback and a promise - your mileage may vary.
Here's an example of returning a Promise
that correctly fails the test with the readable error message from Chaijs.
it("Using a Promise that resolves successfully with wrong expectation!", function() {
var testPromise = new Promise(function(resolve, reject) {
setTimeout(function() {
resolve("Hello World!");
}, 200);
});
return testPromise.then(function(result){
expect(result).to.equal("Hello!");
});
});
The great thing here is we can remove the second error
promise callback (where we passed in done
) as Mocha should catch any Promise rejections and fail the test for us.
Running the above test will result in the following easy to understand error message:
AssertionError: expected 'Hello!' to equal 'Hello World!'
Turn what we know above into async/await.
Now that we know there are some special things we need to do in our async mocha tests (done
callbacks and try/catch
or Promise
s) let's see what happens if we start to use the new ES7 async/await syntax in the language and if it can enable more readable asynchronous unit tests.
The beauty of the async/await syntax is we get to reduce the .then(callback, done)
... mumbo jumbo and turn that into code that reads like it were happening synchronously. The downside of this approach is that it's not happening synchronously and we can't forget that when we're looking at code and starting to use it this way. But overall it is generally easier to reason about in this style.
The big changes from the above Promise
style test and the transformed async
test below are:
- Place the
async
word in front of theasync function(done){...
. This tells the system that inside of this function there may (or may not be) the use of theawait
keyword and in the end the function is turned into aPromise
under the hood. a Promise to simplify our unit tests. - We replace the
.then(function(result){
promise work and in place use theawait
keyword to have it return the promise value assign it toresult
so after that we can run our expectations against it. - Remove the
done
callback. If you aren't aware,async/await
is a fancy compiler trick that under-the-hood turns the code into simplePromise
chaining and callbacks. So we can use what we learned above about Mocha using 5.return
the Promise.
If we apply the 5 notes listed above, we see that we can greatly improve the test readability.
it("Using a Promise with async/await that resolves successfully with wrong expectation!", async function() {
var testPromise = new Promise(function(resolve, reject) {
setTimeout(function() {
resolve("Hello World!");
}, 200);
});
var result = await testPromise;
expect(result).to.equal("Hello!");
});
Notice the async function(){
part above turns this into a function that will (under-the-hood) return a promise that should correclty report errors when the expect(...)
fails.
Handling errors with async/await
One interesting implementation detail around async await is that exceptions and errors are handled just like you were to handle them in synchronous code using a try/catch
. While under-the-hood the errors turn into rejected
Promises
.
NOTE: You're mileage may vary with the async/await and mocha tests with promises. I tried playing around with
async
in mocha hooks likebefore/beforeEach
but ran into some troubles.
Since there may or may-not be issues with mocha hook methods, one work-around is to leverage a try/catch
and the done
callback to manually handle exceptions. You may run into this so I'll show examples of how to avoid relying on Mocha to trap errors.
Below shows the (failing) but alternative way (not using a return Promsie
) but using the done
callback instead.
it("Using a Promise with async/await that resolves successfully with wrong expectation!", async function(done) {
var testPromise = new Promise(function(resolve, reject) {
setTimeout(function() {
resolve("Hello World!");
}, 200);
});
try {
var result = await testPromise;
expect(result).to.equal("Hello!");
done();
} catch(err) {
done(err);
}
});
Removing the test boilerplate
One I started seeing the pattern and use of try/catch
boilerplate showing up in my async tests, it became apparent that there had to be a more terse approach that could help me avoid forgetting the try/catch
needed in each async test. This was because I would often remember the async/await
syntax changes for my async tests but would often forget the try/catch
which often resulted in timeout errors instead of proper failures.
another example below with the async/await and try/catch
it("Using an async method with async/await!", async function(done) {
try {
var result = await somethingAsync();
expect(result).to.equal(something);
done();
} catch(err) {
done(err);
}
});
So I refactored that to reduce the friction.
And the mochaAsync higher order function was born
This simple little guy takes an async
function which looks like async () => {...}
. It then returns a higher order function which is also asynchronous but has wrapped your test function in a try/catch and also takes care of calling the mocha done
in the proper place (either after your test is asynchronously completed, or errors out).
var mochaAsync = (fn) => {
return async (done) => {
try {
await fn();
done();
} catch (err) {
done(err);
}
};
};
You can use it like this:
it("Sample async/await mocha test using wrapper", mochaAsync(async () => {
var x = await someAsyncMethodToTest();
expect(x).to.equal(true);
}));
It can also be used with the mocha before
, beforeEach
, after
, afterEach
setup/teardown methods.
beforeEach(mochaAsync(async () => {
await someLongSetupCode();
}));
In closing.
This post may have seemed like quite a journey to get to the little poorly named mochaAsync
or learn to use Mocha's Promise support but I hope it was helpful and I can't wait for the async/await
syntax to become mainstream in JavaScript, but until then I'm thankful we have transpiling tools like Babel so we can take advantage of these features now. ESNext-pecially in our tests...
Happy Testing!