Testing Asynchronous Code in Javascript

dev
javascript

Published at: 11/20/2024

Building on my previous article, Testing Asynchronously Thrown Errors in Javascript, I want to explore the broader topic of testing asynchronous code in JavaScript. Asynchronous programming is a cornerstone of JavaScript, and testing it effectively is a critical skill for any developer.

Three Options for Testing Asynchronous Code

There are three main approaches to testing asynchronous code in JavaScript:

  • Using promises
  • Using async/await
  • Using callbacks

In this article, I will illustrate each approach using examples from the Test-Driven Development for JavaScript course.

The production code we will test is a simple doTask function that simulates a task with random success or failure after a delay:

function doTask(taskNumber, randomNumber) {
  return new Promise(function (resolve, reject) {
    setTimeout(() => {
      if (randomNumber < 0.5) resolve(`Task ${taskNumber} resolved`)
      else reject(`Task ${taskNumber} rejected`)
    }, randomNumber * 5000)
  })
}

Using Promises

According to MDN,

The Promise object represents the eventual completion (or failure) of an asynchronous operation and its resulting value.

For a task that resolves, we can place our matcher in the then method:

test("task resolves if value < 0.5", () => {
  return doTask(1, 0.3).then((data) => {
    expect(data).toBe("Task 1 resolved")
  })
})

When testing rejection, it’s essential to add expect.assertions(n) to ensure the test verifies the expected number of assertions. Without this, the test could falsely pass if the promise unexpectedly resolves:

test("task rejects if value >= 0.5", () => {
  expect.assertions(1)
  return doTask(1, 0.5).catch((err) => {
    expect(err).toBe("Task 1 rejected")
  })
})

Jest provides resolves and rejects matchers for simpler syntax when working with promises, so we can use these too:

test("task resolves if value < 0.5", () => {
  return expect(doTask(1, 0.3)).resolves.toBe("Task 1 resolved")
})

test("task rejects if value >= 0.5", () => {
  return expect(doTask(1, 0.5)).rejects.toBe("Task 1 rejected")
})

Using Async/Await

The async/await syntax simplifies working with promises, allowing us to write asynchronous code in a synchronous style.

With async/await, we use await to pause execution until the promise resolves, and then we run our matcher:

test("task resolves if value < 0.5", async () => {
  const data = await doTask(1, 0.3)
  expect(data).toBe("Task 1 resolved")
})

When testing for rejection, use a try/catch block to handle the error and validate it. Again, include expect.assertions(n) to avoid false positives:

test("task rejects if value >= 0.5", async () => {
  expect.assertions(1)
  try {
    await doTask(1, 0.5)
  } catch (err) {
    expect(err).toBe("Task 1 rejected")
  }
})

Using callbacks

Testing with callbacks is less common today due to the widespread adoption of promises and async/await, but it’s still useful for legacy codebases or specific APIs.

In Testing Asynchronously Thrown Errors in Javascript, I explained that try/catch only works for synchronous logic. For asynchronous callbacks, you need to pass a done argument to the test and call it when the test is complete.

The Jest documentation provides a helpful example of testing asynchronous callbacks. Note that omitting the done callback causes the test to finish before the asynchronous operation completes:

// Incorrect: Test finishes before callback is invoked
test("the data is peanut butter", () => {
  function callback(error, data) {
    if (error) {
      throw error
    }
    expect(data).toBe("peanut butter")
  }

  fetchData(callback)
})

To fix this, we explicitly call done:

// Correct: Jest waits until done is called
test("the data is peanut butter", (done) => {
  function callback(error, data) {
    if (error) {
      done(error)
      return
    }
    try {
      expect(data).toBe("peanut butter")
      done()
    } catch (error) {
      done(error)
    }
  }

  fetchData(callback)
})

We can apply this pattern to test doTask with callbacks:

test("task resolves if value < 0.5", (done) => {
  function callback(err, data) {
    if (err) {
      done(err)
      return
    }
    try {
      expect(data).toBe("Task 1 resolved")
      done()
    } catch (err) {
      done(err)
    }
  }
  doTask(1, 0.3, callback)
})

To enable callback-based testing, we need to tweak the doTask function to support callbacks:

function doTask(taskNumber, randomNumber, callback) {
  return new Promise(function (resolve, reject) {
    setTimeout(() => {
      if (randomNumber < 0.5) resolve(`Task ${taskNumber} resolved`)
      else reject(`Task ${taskNumber} rejected`)
    }, randomNumber * 5000)
  })
    .then((data) => callback(null, data))
    .catch((err) => callback(err, null))
}

Conclusion

Testing asynchronous code in JavaScript requires understanding how promises, async/await, and callbacks work. Each approach has its strengths:

  • Promises: Ideal for direct promise handling with concise syntax.
  • Async/await: Simplifies promise-based code into a synchronous style.
  • Callbacks: Necessary for legacy code or APIs using callback patterns.

By choosing the right technique for your testing scenario, you can ensure reliable and robust tests for your asynchronous logic.


You May Also Like