Choosing Between done() and Returned Promises in Mocha: An Architecture Note for Async Test Design
An architecture note on Mocha's two async completion mechanisms — the done callback and returned Promises — covering when each fits, timeout configuration, and the failure modes that should change your design.
25 Jul 2026, 19:24 UTC

Every Mocha suite eventually hits the same design decision: a test does asynchronous work, and the runner must not evaluate assertions until that work finishes. Mocha offers two completion mechanisms — the done callback and a returned Promise — and picking the wrong one for a given test produces hangs, double‑completion errors, or silently passing tests. This note lays out the requirement, the smallest design that satisfies it, and the failure modes that should push you from one mechanism to the other.
The requirement: one unambiguous completion signal per test
Mocha cannot know when your asynchronous code is finished unless you tell it. A test that fires off a timer or an HTTP request and then returns synchronously will be reported as passing before the async work completes — the classic false‑positive. The requirement, then, is that each test emits exactly one completion signal, and that the signal fires only after all assertions have run.
Mocha enforces this strictly: if a test function both accepts a done parameter and returns a Promise, Mocha throws an error at runtime because the completion signal would be ambiguous. That constraint is the anchor for the whole design.
The smallest suitable design: return a Promise by default
For anything that already produces a Promise — fetch, database drivers, fs/promises, async functions — the smallest correct design is to return the Promise (or declare the test async, which is equivalent). No callback parameter, no manual signaling:
const assert = require('node:assert/strict');
const { fetchUser } = require('../src/users');
describe('fetchUser', () => {
it('resolves with the user record', async () => {
const user = await fetchUser(42);
assert.equal(user.id, 42);
assert.ok(user.name.length > 0);
});
});
Run this with npx mocha test/users.test.js from your project root (no special permissions needed; it runs under your normal user). Mocha waits for the returned Promise: resolution passes the test, rejection fails it with the rejection reason as the error. Because the Promise chain carries errors automatically, a failed await or a thrown assertion propagates to Mocha without extra plumbing.
The key correctness rule inside an async test: every Promise you care about must be awaited or returned. A floating Promise — one created but never awaited — lets the test complete while work is still in flight, recreating the false‑positive problem inside an otherwise correct‑looking test.
Trust and data boundaries: where done() is still the right tool
The done callback exists for code whose completion signal does not travel through a Promise: event emitters, callback‑style legacy APIs, streams, and message queues. Wrapping these in a Promise is possible, but when the test itself is about the event sequence — ordering, payloads, error events — the wrapper adds a layer that can hide the very behavior under test.
const { EventEmitter } = require('node:events');
const assert = require('node:assert/strict');
describe('order processor', () => {
it('emits "shipped" after "paid"', (done) => {
const processor = new OrderProcessor(); // EventEmitter subclass
const seen = [];
processor.on('paid', () => seen.push('paid'));
processor.on('shipped', () => {
seen.push('shipped');
try {
assert.deepEqual(seen, ['paid', 'shipped']);
done();
} catch (err) {
done(err); // pass assertion failures to Mocha
}
});
processor.process({ id: 'order-1' });
});
});
Two boundary rules matter here. First, assertions inside event handlers throw in a context Mocha does not own, so an assertion failure would otherwise surface as an uncaught exception rather than a clean test failure — wrap the handler body in try/catch and forward errors via done(err). Second, calling done() with no argument signals success; calling it with any truthy argument signals failure with that value as the error.
Operational checks: timeouts and double‑completion
Both mechanisms share the same safety net: the test timeout. Mocha's default is 2000 ms per test (this has been stable across recent major versions, but confirm against your installed version with npx mocha --help). A forgotten done() call or a Promise that never settles does not hang the run forever — the test fails with a timeout error after the limit. Adjust per test when the work legitimately takes longer:
it('processes a large import', async function () {
this.timeout(10000); // must use function(), not an arrow function
await runLargeImport();
});
Note the function keyword: arrow functions do not get Mocha's test context, so this.timeout would not work. Set a suite‑wide default with describe('...', function () { this.timeout(5000); ... }) or globally via --timeout on the CLI or in .mocharc.yml.
Calling done() twice — a common bug when both a success and an error handler can fire — makes Mocha report a "done() called multiple times" error. Treat that error as a signal that your test's completion boundary is wrong, not as noise to suppress.
Failure modes and what would change the design
- Test passes but async work still runs afterward. A floating Promise or a missing
donepath. Fix by awaiting everything or auditing every code path for adonecall. - Timeout failures on correct code. The timeout is too small for the environment (CI is slower than your laptop). Raise it per test rather than globally, so genuinely hung tests still fail fast.
- "Resolution method is overspecified" error. The test both takes
doneand returns a Promise. Remove the callback parameter and return the Promise, or drop the Promise and use onlydone. - Assertion failures reported as uncaught exceptions. Assertions inside event callbacks without
try/catch+done(err).
The design changes when the boundary changes. If a callback‑style API gains a promisified variant, migrate the test to the returned‑Promise form — it removes the try/catch boilerplate and the double‑done risk. Conversely, if a test's real subject is event ordering or stream backpressure, keep done even if a Promise wrapper is technically possible, because the wrapper moves the completion signal away from the behavior you are verifying.
Verifying the mechanism works as you expect
Before trusting a suite built on these rules, exercise each path deliberately: write one test that returns a resolving Promise and confirm it passes without done; write one that calls done(new Error('boom')) and confirm it fails with that message; write one that never calls done and confirm it fails with a timeout, not a hang; and write one that both returns a Promise and calls done to confirm Mocha rejects the overspecified test. These four checks take minutes and pin down the exact behavior of your installed Mocha version, which matters more than any general description — including this one.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.