Testing Asynchronous Code in Node.js
Tags: testing, webdev, javascript, beginners
Published: 2024-05-18 00:00:20

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!