Testing Async Code in Mocha: Choosing Between Callbacks, Promises, and Async/Await
A decision guide for Mocha async testing: compare done callbacks, returned promises, and async/await, then implement isolated tests with Sinon stubs and proper cleanup.
16 Aug 2026, 08:51 UTC

If your Mocha suite tests anything asynchronous — HTTP calls, database queries, timers — you face a real decision: which async style should tests use, and how do you isolate the units under test without hitting real services? The short answer: use async/await for test bodies, and Sinon for stubbing dependencies. Mocha supports all three async styles natively, but they differ sharply in readability and failure behavior.
The decision and its constraints
Mocha is a test runner, not an assertion or mocking library. It detects test completion in three ways: calling a done callback, returning a Promise, or completing an async function. Your constraints are usually: tests must fail clearly (not hang or silently pass), they must not touch real network or database resources, and the team must be able to read failures quickly.
Comparing the async styles
| Style | How Mocha detects completion | Main risk | Best for |
|---|---|---|---|
| done callback | You call done() or done(err) | Forgetting done causes a timeout; calling it twice throws | Legacy callback APIs you cannot wrap |
| Returned Promise | Mocha awaits the returned promise | Forgetting return makes the test pass without checking anything | Promise-based code, simple one-liners |
| async/await | The async function's implicit promise | Rejected promises become test failures automatically — lowest risk | Most modern suites; multi-step async flows |
The silent-pass bug is the strongest argument for async/await. With a plain promise test, omitting return means Mocha finishes the test before assertions run — the suite goes green while testing nothing. Inside an async function, an await is structurally required to sequence steps, so the failure mode is much harder to introduce by accident.
Isolating dependencies with Sinon
Mocha ships without mocking, so the supported path is Sinon. Use a stub when you want to replace a dependency's behavior (e.g., make an HTTP client return canned data), a spy when you only want to record calls, and a mock when you want to declare expected interactions up front. For most unit tests, stubs plus assertions on call arguments are enough; full mocks are brittle because they couple tests to exact call counts.
Cleanup is non-negotiable: restore stubs in an afterEach hook, or use a Sinon sandbox so one line restores everything. A leaked stub pollutes later tests and produces confusing, order-dependent failures.
Concrete implementation
Install the dependencies (run in your project root, requires Node.js 14+ for stable async/await; Mocha 9+ assumed):
npm install --save-dev mocha sinonSuppose the unit under test fetches a user and formats a display name:
// user-service.js
const api = require('./api-client');
async function getDisplayName(id) {
const user = await api.fetchUser(id);
return `${user.firstName} ${user.lastName}`;
}
module.exports = { getDisplayName };The test stubs the API client so no network call occurs:
// test/user-service.test.js
const assert = require('node:assert/strict');
const sinon = require('sinon');
const api = require('../api-client');
const { getDisplayName } = require('../user-service');
describe('getDisplayName', () => {
afterEach(() => sinon.restore());
it('formats the name from the API response', async () => {
const stub = sinon.stub(api, 'fetchUser')
.resolves({ firstName: 'Ada', lastName: 'Lovelace' });
const name = await getDisplayName(42);
assert.equal(name, 'Ada Lovelace');
sinon.assert.calledOnceWithExactly(stub, 42);
});
it('propagates API errors', async () => {
sinon.stub(api, 'fetchUser').rejects(new Error('timeout'));
await assert.rejects(() => getDisplayName(42), /timeout/);
});
});Run with npx mocha test/ from the project root. Expected result: two passing tests, each completing in milliseconds. If a test hangs, check for a missing await or a stub that never resolves; Mocha's default 2000 ms timeout will report it, and you can adjust per-test with this.timeout(5000) in a function-style (non-arrow) test.
Limitations and how to verify
Sinon can only stub properties on objects it can access — it cannot intercept a module's internal references to its own functions, which is why the example stubs api.fetchUser rather than an internal helper. Stubbing ES module exports requires additional tooling or dependency injection; the CommonJS pattern above is the path of least resistance. Also note that sinon.restore() only restores stubs created after the previous restore, so creating stubs outside hooks demands extra discipline.
To validate the isolation actually works, temporarily disconnect from the network or point the API client at an unreachable host and rerun the suite — it should still pass. To confirm tests can fail, change the expected name once and verify the assertion output names the actual and expected values.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.