Mocha Async Test Handling: Mechanisms, Pitfalls, and Practical Patterns
Mocha detects async test completion via returned promises, async functions, or the done callback. This guide shows correct patterns, common pitfalls (missing returns, mixed mechanisms, timeout misconfig), and verification steps to ensure reliable async tests.
27 Mar 2026, 04:43 UTC

How Mocha Detects Async Completion
Mocha treats a test as asynchronous when the test function returns a Promise, is declared async, or accepts a done callback parameter. Only one mechanism should be used per test; mixing them causes unpredictable behavior. The framework waits for the returned promise to settle, the async function to complete, or done() to be called before marking the test as passed or failed.
Worked Example: Three Valid Patterns
The following test file demonstrates each supported async pattern. Save it as async-patterns.test.js and run with npx mocha --timeout 5000.
// async-patterns.test.js
const { expect } = require('chai');
// 1. Return a Promise
describe('Returning a Promise', () => {
it('resolves successfully', () => {
return Promise.resolve(42).then(value => {
expect(value).to.equal(42);
});
});
it('rejects and fails the test', () => {
return Promise.reject(new Error('oops'));
});
});
// 2. Async/await (implicitly returns a Promise)
describe('Async/await', () => {
it('awaits a resolved promise', async () => {
const value = await Promise.resolve(42);
expect(value).to.equal(42);
});
it('throws to fail the test', async () => {
throw new Error('async failure');
});
});
// 3. Legacy done callback
describe('done callback', () => {
it('calls done() on success', (done) => {
setTimeout(() => {
expect(true).to.be.true;
done();
}, 10);
});
it('calls done(err) on failure', (done) => {
setTimeout(() => {
done(new Error('callback error'));
}, 10);
});
});
All six tests should pass except the two designed to fail (rejected promise and thrown error). Mocha reports each failure with a clear stack trace.
Timeout Configuration
Mocha's default timeout is 2000 ms. Async tests often need more time. Set per-test timeout with this.timeout(ms) in a regular function, or via the test options object when using arrow functions:
// Regular function – can use this.timeout
it('slow operation', function() {
this.timeout(10000);
return slowAsyncTask();
});
// Arrow function – use options object
it('slow operation', { timeout: 10000 }, async () => {
await slowAsyncTask();
});
Setting timeout to 0 disables it (use sparingly). Global timeout can be set via --timeout CLI flag or in a Mocha config file.
Common Failure Modes
Missing Return on Promise
Forgetting to return a promise causes Mocha to treat the test as synchronous, producing a false positive:
// ❌ Bad – test passes instantly, promise runs unattended
it('forgot return', () => {
fetchData().then(result => {
expect(result).to.equal('expected');
});
});
// ✅ Good – return the promise
it('returned promise', () => {
return fetchData().then(result => {
expect(result).to.equal('expected');
});
});
Arrow Function Braces Without Return
Arrow functions with block bodies {} require an explicit return. Blockless arrows () => promise implicitly return the promise.
// ❌ Passes incorrectly – no return
it('block body no return', () => {
Promise.resolve(42).then(v => expect(v).to.equal(42));
});
// ✅ Explicit return
it('block body with return', () => {
return Promise.resolve(42).then(v => expect(v).to.equal(42));
});
// ✅ Blockless – implicit return
it('blockless arrow', () =>
Promise.resolve(42).then(v => expect(v).to.equal(42))
);
Mixing Mechanisms
Calling done() inside a .catch() while also returning the promise leads to double resolution:
// ❌ done() called multiple times
it('mixed mechanisms', (done) => {
return Promise.resolve()
.then(() => done())
.catch(err => done(err));
});
// ✅ Choose one: either return promise OR use done
it('promise only', () => Promise.resolve());
it('callback only', (done) => Promise.resolve().then(() => done()));
Unhandled Rejections Outside Test Function
Rejections in unattached .then() chains may crash the process or pass silently:
// ❌ Unhandled rejection – not returned
it('unhandled rejection', () => {
doSomething()
.then(() => { throw new Error('boom'); }); // not returned
});
// ✅ Return the chain
it('handled rejection', () => {
return doSomething()
.then(() => { throw new Error('boom'); });
});
Hooks Follow Same Rules
before, after, beforeEach, afterEach accept promises, async functions, or done. A failed hook aborts the suite; a hook that returns a pending promise hangs until timeout.
beforeEach(async () => {
await db.connect(); // async setup
});
afterEach(() => db.disconnect()); // returns promise
Parallel Execution and Isolation
Mocha's --parallel flag runs test files concurrently, but tests within a file remain sequential. Shared mutable state across tests in the same file is unsafe; use beforeEach/afterEach for isolation.
// Run with: npx mocha --parallel "test/**/*.test.js"
// Each file gets its own process; global variables are not shared.
Top-Level Await (Mocha 8+)
In ES module test files (.mjs or --loader=ts-node/esm), you can use top-level await for one-time async setup:
// setup.mjs
import { createClient } from 'db';
export const client = await createClient(); // runs once per file
In CommonJS, wrap async setup in a before hook or use dynamic import().
Verification Checklist
- Run a minimal suite with one test per mechanism; confirm pass/fail output matches expectations.
- Intentionally omit
returnon a promise test; verify Mocha reports instant pass (false positive). Addreturnand confirm it waits. - Set
this.timeout(100)on a slow async test; confirm timeout failure. Increase timeout and confirm pass. - Run the same suite with and without
--parallel; verify file-level concurrency but sequential execution within each file. - Test an arrow function with braces but no
return; confirm incorrect pass. Fix with explicitreturnor blockless arrow.
Limitations
- Mocha does not automatically fail on
console.errororprocess.exitinside tests. Use a custom reporter or wrapper to treat those as failures if needed. - Global unhandled rejection handlers (
process.on('unhandledRejection')) can mask test failures if they swallow errors before Mocha sees them. - Parallel mode does not isolate tests within a file; shared state requires explicit cleanup in hooks.
Practical Takeaway
Pick one async mechanism per test: return a promise (or use async/await) for modern code; reserve done for legacy callbacks. Always return promises, configure timeouts via the options object for arrow functions, and keep hooks isolated. Run the verification checklist after any test infrastructure change to catch regressions early.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.