Mocha async tests: done callback or async/await, not both
Mixing Mocha's done callback with async/await can hang tests or hide failures. Pick one completion style per test and verify your timeouts.
25 Oct 2025, 00:00 UTC

The problem: a test that never finishes
You add an asynchronous test to a Mocha suite. It calls a database, fetches an API, or waits on a timer. The test either hangs until Mocha’s default timeout, or worse, it passes before the async work finishes. The cause is usually a mismatch between how Mocha expects completion signals and how your test code actually finishes.
Useful takeaway: Mocha supports two completion styles for asynchronous tests—the done callback and returning a promise from an async function. Pick one style per test, do not mix them, and verify your timeout settings so slow operations fail loudly instead of silently.
How done works
When a test function accepts a parameter—conventionally named done—Mocha treats the test as asynchronous and waits for that callback to be invoked before marking the test complete. If done is never called, Mocha waits until the timeout and then fails the test. Calling done(err) with an argument marks the test as failed.
This pattern is older and still common in codebases that test callback-based APIs.
How async/await works
If a test is declared async, it returns a promise. Mocha waits for that promise to settle. If the promise rejects, the test fails. You do not pass done to an async test, and you do not call it inside one.
This is usually the clearer choice for modern JavaScript because it avoids callback nesting and makes error handling look like synchronous try/catch blocks.
Why mixing them causes trouble
Passing done to a test and also returning a promise gives Mocha two completion signals. The first signal may end the test while the other is still pending, producing ambiguous failure reports or false passes. A test that accepts done but never calls it will hang for the full timeout period, which slows down the entire suite.
Worked example: comparing the two styles
Create a file named async-example.test.js in your project directory:
const { describe, it } = require('mocha');
const { expect } = require('chai');
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe('async completion styles', () => {
it('completes with async/await', async () => {
await delay(50);
expect(true).to.equal(true);
});
it('completes with done', (done) => {
delay(50).then(() => {
expect(true).to.equal(true);
done();
});
});
it('hangs until timeout when done is never called', (done) => {
// Intentional example: done is never invoked.
// Mocha will wait for the timeout and then fail.
});
});
Run this from your project root:
npx mocha async-example.test.js --timeout 2000
Where to run: your terminal, in the directory containing node_modules or where Mocha is installed locally. Required permissions: standard user permissions to read files and execute Node.js. Expected checks: the first two tests should pass quickly; the third should fail after approximately two seconds with a timeout error. Risk: leaving the third test in a suite will add two seconds to every run, so remove or fix it before committing.
Adjusting timeouts
Mocha’s default timeout is 2000 milliseconds. If a legitimate async operation regularly needs more time, increase it globally or per test:
npx mocha async-example.test.js --timeout 5000
Or inside a test file:
this.timeout(5000);
Setting the timeout too low for slow operations causes flaky failures unrelated to application logic. Setting it too high makes real hangs expensive to detect.
Limitation and verification
Async hooks (beforeEach, afterEach) follow the same rules: use done or an async function, not both. A failing async hook that does not propagate its error can leave shared state dirty for later tests.
Practical check: run npx mocha --version to confirm your installed version. Then run a single async test with --timeout 1000 and intentionally omit the completion signal. Mocha should fail after roughly one second, confirming that timeout enforcement is active.
Closing
Prefer async functions for new Mocha tests unless you are wrapping an existing callback-based API. Reserve done for those legacy cases, never call it more than once, and never combine it with a returned promise. Keep timeouts realistic and remove tests that never complete.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.