Mocha Async/Await: Cleaner Async Tests Without Callback Hell
Mocha's async/await support eliminates callback boilerplate in async tests. This post shows a concise example, highlights a common pitfall, and gives a practical verification step.
16 Oct 2025, 17:09 UTC

The Problem: Callback Noise in Async Tests
Testing asynchronous code in Mocha used to mean either passing a done callback or returning a Promise explicitly. Both approaches add boilerplate and make it easy to forget error handling, leading to flaky tests that pass when they should fail.
Thesis: Async/Await Is Now the Default Path
Since Mocha 3.0 (released 2016), any test function that returns a Promise — including an async function — automatically signals completion. Failures propagate as rejections, and the test runner waits for the Promise to settle. This eliminates the done ceremony and makes async tests read like synchronous ones.
How It Works
When you declare a test callback as async, Mocha treats the returned Promise as the test's completion signal. A thrown error or rejected Promise fails the test; a resolved Promise passes it. You no longer need to call done() or return a manually constructed Promise.
Key points:
- Automatic synchronization: Mocha waits for the Promise returned by the async function.
- Error propagation: Uncaught rejections and synchronous throws inside the async function both fail the test.
- No
doneparameter: Omittingdonetells Mocha to use Promise-based completion.
Worked Example: Testing a Delayed Resolution
Suppose you have a utility that resolves after a timeout:
// src/delay.js
function delay(ms, value) {
return new Promise(resolve => setTimeout(() => resolve(value), ms));
}
module.exports = { delay };
Here's a concise Mocha test using async/await and Chai for assertions:
// test/delay.test.js
const { expect } = require('chai');
const { delay } = require('../src/delay');
describe('delay', () => {
it('resolves with the provided value after the timeout', async () => {
const result = await delay(50, 'done');
expect(result).to.equal('done');
});
it('rejects when the promise is rejected', async () => {
const failing = () => Promise.reject(new Error('boom'));
await expect(failing()).to.be.rejectedWith('boom');
});
});
Run it from the project root (where package.json lives) with:
npx mocha --reporter spec
Expected check: Both tests pass, and the reporter shows two passing tests. If you change the assertion to expect(result).to.equal('fail'), the test fails with a clear message.
Trade‑off: Node Version and Transpilation
Async/await requires Node 7.6+ (full support from Node 8). If your project must run on older engines or you enforce ES5 output, you'll need a transpiler like Babel with the @babel/preset-env preset. That adds build complexity and a potential source of mismatches between source and executed code.
For modern Node projects (Node 14+), this is a non‑issue. For legacy codebases, weigh the cost of adding Babel against the readability gain.
Common Pitfall: Forgetting to Await or Return
An async test that doesn't await a Promise — or doesn't return it — will complete before the async work finishes. Mocha sees the returned Promise (which resolves immediately) and marks the test as passed, even if the underlying operation later throws.
// ❌ False positive: missing await
it('does not wait for the promise', async () => {
delay(100, 'value'); // Promise created but not awaited
// test ends here, Mocha thinks it passed
});
Always await or return the Promise you care about. The rule of thumb: if the test contains await, the function must be async and you must not omit the await on the critical Promise.
Actionable Verification Steps
- Create a fresh directory and run
npm init -y. - Install dependencies:
npm install --save-dev mocha chai. - Add a test script to
package.json:"test": "mocha --reporter spec". - Write the
delay.jsanddelay.test.jsfiles as shown above. - Run
npm testand confirm both tests pass. - Modify the first test to expect the wrong value, run again, and verify a clear failure message appears.
This minimal setup proves that async/await integration works end‑to‑end and that failures are reported correctly.
Closing Takeaway
Mocha's async/await support removes the ceremony of done callbacks and makes asynchronous tests as readable as synchronous ones. Adopt it for any Node 8+ project; just remember to await (or return) every Promise that determines the test outcome. The verification steps above give you a quick, repeatable way to confirm the behavior in your own codebase.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.