How the Heck do you test Async code with Mocha?
By: Brad
Hey Brad I have a function which returns a promise, how the heck do I test that with Mocha?
TL;DR Either call the
done
callback or simply return the promise. Checkout Things to Watch out For when testing Promises
No worries I can help, the mocha
testing framework does support testing asynchronous code. The only catch is that you need to tell mocha
that the code you are testing is asynchronous or it will assume the code to be synchronous.
How Mocha tests work
As talked about in What the Heck is Mocha mocha
runs each test defined via the it()
within a try-catch
and any it()
tests which throws are marked as failed tests. However since Async code by their nature will be executed after the it()
function is invoked the try-catch
won’t exist by the time the code under test is actually executed. So how does mocha
detect a failed assertion for async code?
Because mocha
was made for testing Node code and Node is primarily designed as asynchronous code mocha
defiantly support testing asynchronously; in fact it has two different ways of doing that: callbacks and Promises.
Callbacks
The first of the two ways mocha
has to test asynchronous code is the done
callback. Back in the day before Promise
s Node was all about callbacks. An async function generally took a callback function as an input parameter which the async function was to call when it finished processing. If the async function was successful it would call the callback with any optional parameters to pass down stream. If the async function ran into an error it would pass the error as the first parameter to the call back function.
function myAsyncFunc(callback) { // do something async ... callback(err); // if error ... callback(null, 42); // if success }
mocha
is setup to work in the same way, when it invokes the function you passed into the it()
function it will pass in a callback function for you to call; if the test passed just call the callback, if the test failed then pass the error into the callback.
it('my test', (done) => { const result = 5 + 5; if (result !== 10) { return done(new Error(`expect 10 got ${result}`)); } done(); });
Not the greatest example above I know as it is just a sync function but I think that adequate shows off how the done
callback is to be used.
A better example might be if the code we were testing was event based then we’d use the done
callback to let mocha
know when the code has finished and if it passed or failed.
it('my test', (done) => { doWork() .on('error', done) // If error event emitted invoke done with error .on('finish', done); // If finish event emitted invoke done });
mocha
will wait until the done
callback is called, if neither the error
or finish
events above are emitted then mocha
will timeout which by default will happen after 2 seconds.
mocha
can tell if you wrote your test with the done
callback or not and mark those with the callback as async. So it('my test', () => {...});
is a sync test and it('my test', (done) => {...});
is an async test. Just make sure that if you include the done
callback that you call it otherwise your test will timeout.
Promises
With the introduction of Promises into JavaScript/Node which promise to completely replace callbacks mocha
was updated to support that form of async code.
Instead of working with a done
callback mocha
can accept a Promise
object and check if the Promise
ends up being fulfilled or rejected. A rejected Promise
is interpreted as a failed test.
To use Promises
simply return the promise.
it('my test', () => { return doWorkReturnPromise(5); });
That is it, the doWorkReturnPromise()
function will return a Promise
. If that promise is fulfilled the test will pass, if not the test will fail.
Note: If the code you are testing returns a promise and you intend to use the
Promise
to tellmocha
if the code worked or not then do not use thedone
callback. The mixing ofdone
callback andPromise
s are not supported;mocha
will log warnings in the terminal if it detects a test which returns both aPromise
and takes in adone
callback.
Validating Error Cases
When working with promises and your dealing with the happy path cases where you expect a function to return a fulfilled promise the above test is fine. If something goes wrong and the promise is rejected the test will fail; However, the error message might be non-descriptive.
What about the un-happy path case? If you want to make sure your code is rejecting how do you make sure your test passes when the Promise
rejects?
You could simply catch the error, which will cause the Promise
to be fulfilled.
const { expect } = require('chai'); it('my test', () => { return doWorkReturnPromise(5) .catch((err) => { expect(err).to.have.property('message', 'boom'); }); });
This test would pass if the doWorkReturnPromise()
function rejected with an Error that has the message ‘boom’. Any other rejection would fail the test because the expect
would throw a new error which would reject the promise again.
But what if the doWorkReturnPromise()
didn’t reject?
Oops, the test would still pass. The Promise
is fulfilled which is all mocha
knows to care about. So how do we make sure the Promise
is rejecting?
You could put a .then()
before the .catch()
with an assertion which always fails so that if this promise ever fulfills throw an exception but that exception would end up in your .catch()
and if your .catch()
isn’t checking the error value again your test would pass when it should fail.
Instead of writing a bunch of code your self to detect failure to reject would it not be nice if there was an assertion method to do just that? Enter a chai
plugin called chai-as-promised
. This plugin adds more assertion helpers to the chai
library which know how to check Promises
.
const chai = require('chai'); const chaiAsPromised = require('chai-as-promised'); const { expect } = chai; chai.use(chaiAsPromised); it('my test', () => { return expect(doWorkReturnPromise(5)) .to.eventually.be.rejectedWith('boom'); });
By installing the chai-as-promised
module into chai
via the chai.use()
method we now have access to the .eventually.be.rejected
, .eventually.be.rejectedWith()
, and .eventually.be.fulfilled
assertion functions.
The above test is now explicitly stating that the doWorkReturnPromise()
function must reject, if it does not the assertion method will force a rejection stating that it expected a rejection but got a fulfillment. It also converts rejections into fulfillment if the expectation was to get a rejection.
Although for the happy path tests you can get by with just returning the promise as we did above, by using chai-as-promised
even on tests expecting a fulfillment the message you get in the terminal when the test fails is much better.
it('my test', () => { return expect(doWorkReturnPromise(5)) .to.eventually.be.fulfilled; }); it('my test', () => { return doWorkReturnPromise(5) });
If you have other things to check on the value of the promise you can always attach a .then()
to the assertion to validate the value.
// If the promise is rejected the .then() is not called it('my test', () => { return expect(doWorkReturnPromise(5)) .to.eventually.be.fulfilled .then((value) => { expect(value).to.equal(42); }); }); // If the promise is fulfilled the .then() is not called // because chai-as-promised threw since it didn't get a rejection it('my test', () => { return expect(doWorkReturnPromise(5)) .to.eventually.be.rejectedWith('boom') .then((err) => { expect(err).to.have.property('code', 404); }); });
Things to Watch Out For
When testing async code there are a few things to watch out for that might leave you thinking your tests are passing when they are really not.
Forgetting to return the Promise
When using Promise
s with mocha
it is important to return
the Promise
at the end of the test, otherwise mocha
will assume the test is sync and not wait to see if the Promise
fulfilled or rejected.
it('my test', () => { const p = doWorkReturnPromise(5); expect(p).to.eventually.be.fulfilled; });
Since we are not actually returning the Promise
p
here mocha
assumes the test is synchronous and since there are no exceptions being thrown reports the test as a success. The problem is that if the doWorkReturnPromise()
function did reject mocha
is no longer listening for it so what you end up with is an unhandled rejection in the terminal and a bunch of “passed” tests.
Not Checking Why your Test Rejected
The same can be said for not checking why your test threw. If your writing an unhappy path test which is expecting the code under test to throw or reject but you fail to actually verify that the thrown error or rejected promise is the error you were expecting your test could hide unexpected errors.
it('my test', () => { return expect(doWorkReturnPromise(5)) .to.eventually.be.rejected; });
This test would pass for any rejection even rejections caused by TypeErrors
resulting from faulty input or code trying to access properties off of undefined
. These are most likely not the errors you were looking for.
There you have it, that is how you test asynchronous code with mocha
, you either return the promise or call the done
callback. Just make sure not to do both in the same test and if your using Promise
s don’t forget to return the promise and always check the error to ensure it was the error you were expecting.
Until next time think imaginatively and design creatively