Testing Asynchronous Code in Node.js
Ndoma Precious
Posted on May 18, 2024
Testing asynchronous code is crucial in Node.js applications since they rely heavily on non-blocking operations. Let's explore how to test callbacks, promises, async/await, handle timeouts, race conditions, and event-driven code using Jest.
In this tutorial, We'll cover:
- Testing callbacks, promises, and async/await
- Handling timeouts and race conditions
- Testing event-driven code
We'll use Jest as our testing framework throughout.
Setting Up the Project
First, we'll set up our Node.js project and install Jest. Create a new directory for the project and initialize it:
mkdir async-testing
cd async-testing
npm init -y
Install Jest as a development dependency:
npm install --save-dev jest
Update the package.json
to add a test script:
"scripts": {
"test": "jest"
}
Testing Callbacks
Creating the Callback Function
Let's start by creating a simple function that uses a callback. This function will simulate fetching data asynchronously. Create a new file named async.js
:
// async.js
function fetchData(callback) {
setTimeout(() => {
callback('peanut butter');
}, 1000);
}
module.exports = fetchData;
This fetchData function waits for 1 second before calling the provided callback with the string "peanut butter".
Writing Tests for Callbacks
Create a new file named async.test.js
:
// async.test.js
const fetchData = require('./async');
test('the data is peanut butter', (done) => {
function callback(data) {
try {
expect(data).toBe('peanut butter');
done();
} catch (error) {
done(error);
}
}
fetchData(callback);
});
In this test, we use Jest’s done callback to handle the asynchronous test. The done callback signals to Jest that the test is complete, allowing us to verify that fetchData
calls the provided callback with the expected value.
Testing Promises
Creating the Promise Function
Next, we'll convert our fetchData function to use promises. Update async.js
to include the promise-based function:
// async.js
function fetchData(callback) {
setTimeout(() => {
callback('peanut butter');
}, 1000);
}
function fetchDataPromise() {
return new Promise((resolve) => {
setTimeout(() => {
resolve('peanut butter');
}, 1000);
});
}
module.exports = { fetchData, fetchDataPromise };
The fetchDataPromise
function returns a promise that resolves to "peanut butter" after 1 second.
Writing Tests for Promises
Update async.test.js
to include tests for the promise-based function:
// async.test.js
const { fetchData, fetchDataPromise } = require('./async');
test('the data is peanut butter', (done) => {
function callback(data) {
try {
expect(data).toBe('peanut butter');
done();
} catch (error) {
done(error);
}
}
fetchData(callback);
});
test('the data is peanut butter (promise)', () => {
return fetchDataPromise().then(data => {
expect(data).toBe('peanut butter');
});
});
Here, we return the promise from our test. Jest waits for the promise to resolve before it considers the test complete.
Using async/await
Update async.test.js
to include a test using async/await:
// async.test.js
const { fetchData, fetchDataPromise } = require('./async');
test('the data is peanut butter', (done) => {
function callback(data) {
try {
expect(data).toBe('peanut butter');
done();
} catch (error) {
done(error);
}
}
fetchData(callback);
});
test('the data is peanut butter (promise)', () => {
return fetchDataPromise().then(data => {
expect(data).toBe('peanut butter');
});
});
test('the data is peanut butter (async/await)', async () => {
const data = await fetchDataPromise();
expect(data).toBe('peanut butter');
});
With async/await, the test code becomes cleaner and easier to read. The await keyword pauses the function execution until the promise resolves.
Handling Timeouts and Race Conditions
Creating a Function with a Timeout
Let's add a timeout to our promise-based function to simulate a longer-running task.
Update async.js
to include the timeout function:
// async.js
function fetchData(callback) {
setTimeout(() => {
callback('peanut butter');
}, 1000);
}
function fetchDataPromise() {
return new Promise((resolve) => {
setTimeout(() => {
resolve('peanut butter');
}, 1000);
});
}
function fetchDataWithTimeout() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('peanut butter');
}, 1000);
setTimeout(() => {
reject(new Error('timeout'));
}, 2000);
});
}
module.exports = { fetchData, fetchDataPromise, fetchDataWithTimeout };
The fetchDataWithTimeout
function resolves after 1 second but will reject if not resolved within 2 seconds.
Writing Tests for Timeouts and Race Conditions
Update async.test.js
to include tests for the timeout function:
// async.test.js
const { fetchData, fetchDataPromise, fetchDataWithTimeout } = require('./async');
test('the data is peanut butter', (done) => {
function callback(data) {
try {
expect(data).toBe('peanut butter');
done();
} catch (error) {
done(error);
}
}
fetchData(callback);
});
test('the data is peanut butter (promise)', () => {
return fetchDataPromise().then(data => {
expect(data).toBe('peanut butter');
});
});
test('the data is peanut butter (async/await)', async () => {
const data = await fetchDataPromise();
expect(data).toBe('peanut butter');
});
test('the data is peanut butter (timeout)', async () => {
await expect(fetchDataWithTimeout()).resolves.toBe('peanut butter');
});
test('throws an error if it times out', async () => {
jest.useFakeTimers();
const promise = fetchDataWithTimeout();
jest.advanceTimersByTime(2000);
await expect(promise).rejects.toThrow('timeout');
jest.useRealTimers();
});
In the timeout test, we use Jest's timer mocks to simulate the passage of time.
Testing Event-Driven Code
Creating the Event-Driven Function
Let's create a function that uses Node.js's EventEmitter. Update async.js
to include the event-driven function:
// async.js
const EventEmitter = require('events');
class MyEmitter extends EventEmitter {}
const myEmitter = new MyEmitter();
function emitEvent() {
setTimeout(() => {
myEmitter.emit('event', 'peanut butter');
}, 1000);
}
module.exports = { myEmitter, emitEvent, fetchData, fetchDataPromise, fetchDataWithTimeout };
Here, we have an EventEmitter that emits an event after 1 second.
Writing Tests for Event-Driven Code
Update async.test.js
to include tests for the event-driven function:
// async.test.js
const { myEmitter, emitEvent, fetchData, fetchDataPromise, fetchDataWithTimeout } = require('./async');
test('the data is peanut butter', (done) => {
function callback(data) {
try {
expect(data).toBe('peanut butter');
done();
} catch (error) {
done(error);
}
}
fetchData(callback);
});
test('the data is peanut butter (promise)', () => {
return fetchDataPromise().then(data => {
expect(data).toBe('peanut butter');
});
});
test('the data is peanut butter (async/await)', async () => {
const data = await fetchDataPromise();
expect(data).toBe('peanut butter');
});
test('the data is peanut butter (timeout)', async () => {
await expect(fetchDataWithTimeout()).resolves.toBe('peanut butter');
});
test('throws an error if it times out', async () => {
jest.useFakeTimers();
const promise = fetchDataWithTimeout();
jest.advanceTimersByTime(2000);
await expect(promise).rejects.toThrow('timeout');
jest.useRealTimers();
});
test('event emits with peanut butter', (done) => {
myEmitter.once('event', (data) => {
try {
expect(data).toBe('peanut butter');
done();
} catch (error) {
done(error);
}
});
emitEvent();
});
In this test, we listen for the event using once, ensuring the callback is called only once. When the event is emitted, we check that the data matches the expected value.
Conclusion
Testing asynchronous code in Node.js is crucial for ensuring your applications work correctly. In this guide, we covered how to test callbacks, promises, async/await, handle timeouts, race conditions, and event-driven code. By following these examples and writing comprehensive tests, you can make your Node.js applications more robust and reliable. Happy testing!
Posted on May 18, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.