DEV Community

Ndoma Precious
Ndoma Precious

Posted on

Testing Asynchronous Code in Node.js

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
Enter fullscreen mode Exit fullscreen mode

Install Jest as a development dependency:

npm install --save-dev jest
Enter fullscreen mode Exit fullscreen mode

Update the package.json to add a test script:

"scripts": {
  "test": "jest"
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)