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.