Asynchronous code is everywhere in Node.js applications.
Whether you are calling an API, reading a file, querying a database, or using a timer, the result usually does not come back immediately. Because of this, testing asynchronous code is slightly different from testing normal synchronous functions.
In this guide, you will learn how to test asynchronous code with Mocha.js using practical examples.
We will cover:
- Testing callbacks
- Testing Promises
- Testing
async/await - Handling errors
- Testing API-like functions
- Using
done() - Common mistakes
- Best practices
What Is Asynchronous Code?
Before writing tests, let’s understand what asynchronous code means.
Synchronous code runs one statement after another and waits for each operation to finish.
For example:
function add(a, b) {
return a + b;
}
const result = add(10, 20);
console.log(result);
The function immediately returns the result.
Asynchronous code works differently. The operation may take some time to complete.
For example:
function getUser(callback) {
setTimeout(() => {
callback({ name: "John" });
}, 1000);
}
Here, the result is available after one second.
This creates a problem for testing because Mocha must know when the asynchronous operation has finished.
Why Do We Need Special Async Tests?
Consider this test:
it("should return a user", function() {
getUser((user) => {
console.log(user);
});
});
The test starts getUser(), but Mocha does not automatically know that it needs to wait.
The test function finishes before the callback executes.
For asynchronous operations, we need to tell Mocha:
“Wait until this operation is finished before deciding whether the test passed or failed.”
Mocha provides several ways to do this.
The main approaches are:
- Callback with
done() - Returning a Promise
- Using
async/await
1. Testing Callback-Based Code With done()
Let’s start with callbacks.
Suppose we have this function:
function getUser(callback) {
setTimeout(() => {
callback(null, {
id: 1,
name: "John"
});
}, 1000);
}
The function uses a callback to return the result.
We can test it using Mocha’s done() function.
const assert = require("assert");
describe("getUser", function() {
it("should return user data", function(done) {
getUser((error, user) => {
assert.strictEqual(error, null);
assert.strictEqual(user.id, 1);
assert.strictEqual(user.name, "John");
done();
});
});
});
Let’s understand what is happening.
done() tells Mocha the test is finished
When Mocha sees a function parameter such as:
function(done)
it knows that the test is asynchronous.
Mocha waits until done() is called.
done();
Once this line executes, Mocha considers the asynchronous test complete.
What If the Test Fails?
You can pass an error to done().
For example:
done(error);
This tells Mocha that the test failed.
A callback test can therefore look like this:
it("should return user data", function(done) {
getUser((error, user) => {
if (error) {
return done(error);
}
try {
assert.strictEqual(user.name, "John");
done();
} catch (error) {
done(error);
}
});
});
This is useful when working with callback-based APIs.
2. Testing Promises With Mocha
Modern Node.js applications commonly use Promises.
For example:
function getUser() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
id: 1,
name: "John"
});
}, 1000);
});
}
The function returns a Promise.
Mocha can wait for that Promise if we return it from the test.
it("should return user data", function() {
return getUser().then((user) => {
assert.strictEqual(user.id, 1);
assert.strictEqual(user.name, "John");
});
});
The important part is:
return getUser()
By returning the Promise, we tell Mocha to wait until the Promise settles.
3. Testing Promises With async/await
async/await makes asynchronous tests much easier to read.
Using the same function:
function getUser() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
id: 1,
name: "John"
});
}, 1000);
});
}
We can write:
it("should return user data", async function() {
const user = await getUser();
assert.strictEqual(user.id, 1);
assert.strictEqual(user.name, "John");
});
This is one of the cleanest ways to test asynchronous code.
The test function is marked as:
async function()
and we use:
await getUser();
Mocha waits for the returned Promise from the async test function.
4. Testing Rejected Promises
Asynchronous operations can also fail.
For example:
function getUser() {
return Promise.reject(
new Error("Unable to fetch user")
);
}
We can test the rejected Promise with try/catch.
it("should throw an error when user cannot be fetched", async function() {
try {
await getUser();
} catch (error) {
assert.strictEqual(
error.message,
"Unable to fetch user"
);
}
});
However, there is a problem with this approach.
If getUser() unexpectedly succeeds, the test may not fail as expected.
A better test explicitly checks that an error occurred.
it("should throw an error", async function() {
try {
await getUser();
assert.fail("Expected getUser() to throw an error");
} catch (error) {
assert.strictEqual(
error.message,
"Unable to fetch user"
);
}
});
Now the test fails if the Promise unexpectedly resolves.
5. Testing an API-Like Function
Let’s look at a more realistic example.
Imagine a function that fetches a product:
function getProduct() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
id: 101,
name: "Laptop",
price: 50000
});
}, 500);
});
}
We can test it with async/await.
describe("getProduct", function() {
it("should return product information", async function() {
const product = await getProduct();
assert.strictEqual(product.id, 101);
assert.strictEqual(product.name, "Laptop");
assert.strictEqual(product.price, 50000);
});
});
This test waits for the asynchronous operation before checking the result.
6. Testing Multiple Asynchronous Cases
You can create multiple tests for different scenarios.
For example:
describe("getProduct", function() {
it("should return the product", async function() {
const product = await getProduct();
assert.strictEqual(product.id, 101);
});
it("should return the correct product name", async function() {
const product = await getProduct();
assert.strictEqual(product.name, "Laptop");
});
it("should return the correct price", async function() {
const product = await getProduct();
assert.strictEqual(product.price, 50000);
});
});
However, in real projects, avoid creating too many tests that repeat exactly the same setup.
Instead, group related assertions when appropriate.
7. Testing setTimeout()
You may sometimes need to test code that uses timers.
For example:
function delayedMessage() {
return new Promise((resolve) => {
setTimeout(() => {
resolve("Hello");
}, 1000);
});
}
The test can simply wait for the Promise:
it("should return the message after a delay", async function() {
const message = await delayedMessage();
assert.strictEqual(message, "Hello");
});
The important thing is that the test waits for the asynchronous operation rather than finishing immediately.
8. Testing Callback Errors
Callbacks commonly follow the Node.js error-first pattern:
callback(error, result);
For example:
function getUser(id, callback) {
setTimeout(() => {
if (!id) {
return callback(
new Error("User ID is required")
);
}
callback(null, {
id: id,
name: "John"
});
}, 500);
}
We can test the error case:
it("should return an error when ID is missing", function(done) {
getUser(null, (error, user) => {
assert.ok(error);
assert.strictEqual(
error.message,
"User ID is required"
);
assert.strictEqual(user, undefined);
done();
});
});
This test checks that the function correctly handles invalid input.
9. Do Not Mix done() and Promises
One common mistake is using both approaches in the same test.
For example:
it("should return user", function(done) {
return getUser().then((user) => {
assert.strictEqual(user.name, "John");
done();
});
});
This is unnecessary and can cause problems.
You should choose one approach.
Using done()
it("should return user", function(done) {
getUser((error, user) => {
assert.strictEqual(user.name, "John");
done();
});
});
Or using a Promise
it("should return user", function() {
return getUser().then((user) => {
assert.strictEqual(user.name, "John");
});
});
Or using async/await
it("should return user", async function() {
const user = await getUser();
assert.strictEqual(user.name, "John");
});
For modern Node.js projects, async/await is often the easiest approach to read.
10. What Happens If You Forget await?
Consider this test:
it("should return user", async function() {
const user = getUser();
assert.strictEqual(user.name, "John");
});
This is incorrect.
getUser() returns a Promise, not the actual user object.
You need:
const user = await getUser();
Now user contains the resolved value.
This is a very common mistake when beginners start testing asynchronous JavaScript.
11. Testing Async Functions That Return Data
Suppose we have:
async function calculateTotal(price, quantity) {
return price * quantity;
}
Even though the calculation itself is simple, the async keyword means the function returns a Promise.
We can test it like this:
it("should calculate the total", async function() {
const total = await calculateTotal(100, 3);
assert.strictEqual(total, 300);
});
Without await, total would be a Promise.
12. Testing Async Functions That Throw Errors
An async function can throw an error:
async function divide(a, b) {
if (b === 0) {
throw new Error("Cannot divide by zero");
}
return a / b;
}
We can test the error:
it("should throw an error when dividing by zero", async function() {
try {
await divide(10, 0);
assert.fail("Expected function to throw an error");
} catch (error) {
assert.strictEqual(
error.message,
"Cannot divide by zero"
);
}
});
This verifies that the asynchronous function rejects with the expected error.
13. A Complete Example
Let’s put everything together.
userService.js
function getUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (!id) {
return reject(
new Error("User ID is required")
);
}
resolve({
id: id,
name: "John",
email: "john@example.com"
});
}, 500);
});
}
module.exports = {
getUser
};
userService.test.js
const assert = require("assert");
const { getUser } = require("./userService");
describe("getUser", function() {
it("should return user information", async function() {
const user = await getUser(1);
assert.strictEqual(user.id, 1);
assert.strictEqual(user.name, "John");
assert.strictEqual(
user.email,
"john@example.com"
);
});
it("should throw an error when ID is missing", async function() {
try {
await getUser();
assert.fail("Expected getUser() to throw");
} catch (error) {
assert.strictEqual(
error.message,
"User ID is required"
);
}
});
});
Run the tests with:
npx mocha
Mocha waits for each async test to finish before moving on.
Callback vs Promise vs Async/Await
Mocha supports different approaches for asynchronous tests.
| Approach | Example | Best For |
|---|---|---|
| Callback | done() | Older callback-based code |
| Promise | return promise | Promise-based code |
| Async/Await | await promise | Modern JavaScript |
For example:
Callback
it("works", function(done) {
doSomething((result) => {
assert.ok(result);
done();
});
});
Promise
it("works", function() {
return doSomething().then((result) => {
assert.ok(result);
});
});
Async/Await
it("works", async function() {
const result = await doSomething();
assert.ok(result);
});
Common Mistakes When Testing Async Code
1. Forgetting await
Incorrect:
const result = getData();
Correct:
const result = await getData();
2. Forgetting done()
If you are using a callback-based test:
it("works", function(done) {
getData((data) => {
assert.ok(data);
});
});
You forgot:
done();
The test may time out because Mocha is waiting for completion.
3. Not Returning a Promise
Incorrect:
it("works", function() {
getData().then((data) => {
assert.ok(data);
});
});
Correct:
it("works", function() {
return getData().then((data) => {
assert.ok(data);
});
});
Or use async/await:
it("works", async function() {
const data = await getData();
assert.ok(data);
});
4. Mixing done() With Promises
Avoid:
function(done) {
return promise.then(() => done());
}
Choose either the callback approach or the Promise/async approach.
5. Testing Only the Success Case
Don’t test only when everything works.
Also test:
- Invalid input
- Missing data
- Network failures
- Rejected Promises
- Database errors
- Timeout scenarios
- Unexpected responses
Good tests should verify both success and failure.
Best Practices for Async Tests
Here are some useful practices to follow.
1. Prefer async/await for modern code
It usually makes tests easier to understand.
it("should return user", async function() {
const user = await getUser();
assert.strictEqual(user.name, "John");
});
2. Always wait for asynchronous operations
Use one of:
done()
return promise
or:
await promise
3. Test errors as well as successful results
A reliable test suite should verify what happens when something goes wrong.
4. Keep asynchronous tests focused
Each test should verify one meaningful behavior.
5. Avoid unnecessary delays
If your test waits several seconds for every operation, the test suite can become slow.
Use appropriate mocking or test techniques for external dependencies when possible.
Why Async Testing Matters in Node.js
Node.js applications rely heavily on asynchronous operations.
For example:
Application
↓
API Request
↓
Database Query
↓
Process Data
↓
Return Response
Almost every step can involve asynchronous behavior.
If these operations are not tested properly, bugs can remain hidden.
Async tests help verify that your application correctly handles:
- API responses
- Database operations
- File operations
- Promises
- Callbacks
- Errors
- Timeouts
- External services
Quick Reference
Callback
it("should work", function(done) {
getData((data) => {
assert.ok(data);
done();
});
});
Promise
it("should work", function() {
return getData().then((data) => {
assert.ok(data);
});
});
Async/Await
it("should work", async function() {
const data = await getData();
assert.ok(data);
});
Async Error
it("should throw an error", async function() {
try {
await getData();
assert.fail("Expected an error");
} catch (error) {
assert.ok(error);
}
});
Frequently Asked Questions
Can Mocha test asynchronous JavaScript?
Yes. Mocha supports asynchronous tests using callbacks, Promises, and async/await.
What is done() in Mocha?
done() is a callback provided by Mocha for asynchronous tests. Calling done() tells Mocha that the test has finished.
Do I need done() with async/await?
No. When you use async/await, Mocha can wait for the Promise returned by the async test function.
Should I use callbacks or async/await?
For new code, async/await is generally easier to read. done() is useful when testing older callback-based APIs.
What happens if I forget await?
Your variable will contain the Promise rather than its resolved value, which can cause your assertions to fail.
Can Mocha test rejected Promises?
Yes. You can test rejected Promises using try/catch or an appropriate assertion strategy.
Conclusion
Testing asynchronous code is an important part of Node.js testing.
With Mocha.js, you can test asynchronous operations using three main approaches:
Callbacks → done()
Promises → return Promise
Async/Await → await
For modern Node.js applications, async/await provides a clean and readable way to write asynchronous tests.
The most important thing to remember is simple:
Mocha needs to know when your asynchronous operation has finished.
Once you understand done(), Promises, and async/await, testing asynchronous Node.js code becomes much easier.




