Unit testing async functions
Elliot Nelson
Posted on March 30, 2021
If you're just getting comfortable writing async
functions in JavaScript (or using Promises in general), a potential stumbling block you might run into is writing proper unit tests for them.
The good news is that as long as your test framework provides a way to write expectations for resolved values and rejected values (usually Errors), adjusting your unit tests should be relatively simple. To give some examples, I'll show some simple positive and negative unit tests for async functions using three popular test frameworks - Jasmine, Jest, and Mocha + Chai.
Code under test
Before we start testing, we need an example of an asynchronous function to test, right? Let's check if a string is a palindrome:
async function palindrome(value) {
if (typeof value !== 'string') {
throw new Error(`${value} is not a string`);
}
let chars = value.toLowerCase().replace(/[^a-z]+/g, '');
return [...chars].reverse().join('') === chars;
}
(This function doesn't have to be asynchronous, but let's consider it a stand-in -- perhaps our real palindrome checker is on a server and the palindrome()
function actually makes a REST call, etc.)
Jasmine
Jasmine has been around a long time and remains one of my favorite test frameworks -- it's tiny, fast, and has no dependencies. It comes out of the box with async matchers, although you need to remember that asynchronous expectations in Jasmine must be made using the special expectAsync
function instead of the usual expect
function.
describe('palindrome', () => {
it('returns true if the string is a palindrome', async () => {
// You can await for value, then do a normal expect
expect(await palindrome(`Madam, I'm Adam`)).toBe(true);
// Or, you can do an asynchronous expectation
await expectAsync(palindrome(`Madam, I'm Adam`)).toBeResolvedTo(true);
});
it('raises an error if the value is not a string', async () => {
await expectAsync(palindrome(37)).toBeRejectedWithError(/.+ is not a string/);
});
});
For positive expectations, I prefer awaiting for a value first and then using a standard expect -- this is more flexible, because you can use any Jasmine matcher (like toBeInstanceOf
, toContain
, etc.). If you use the asynchronous expect, you can only make an equality comparison.
For negative expectations, you don't have the option of waiting for a value (the rejected promise would fail the test). In this example I've used a regular expression, but we can also pass a string or an Error object (the API for .toBeRejectedWithError
is consistent with Jamine's .toThrowError
).
Jest
Jest is the opposite of Jasmine, with its huge install footprint and slower runtime, but is immensely popular nowadays (especially for React testing). Like Jasmine, Jest comes with async matchers out of the box.
describe('palindrome', () => {
it('returns true if the string is a palindrome', async () => {
// You can await for value, then do a normal expect
expect(await palindrome(`Step on no pets`)).toBe(true);
// Or, you can do an asynchronous expectation
await expect(palindrome(`Step on no pets`)).resolves.toBe(true);
});
it('raises an error if the value is not a string', async () => {
await expect(palindrome(37)).rejects.toThrow(/.+ is not a string/);
});
});
Notice how in Jest, you can await expect
for asynchronous expectations (there's not a separate function), and instead of using separate matchers, you can use the chaining functions .resolves
or .rejects
to "unwrap" a Promise and then use a normal expectation matcher. I think this is one of the better matching APIs out there.
Mocha + Chai
Mocha is a popular test framework that doesn't bundle its own assert/expect library, which makes it very flexible but also requires installing a few more packages to setup your test environment.
For this example, I am using Mocha, plus Chai for its BDD expect
syntax and the chai-as-promised plugin for asynchronous matchers.
describe('palindrome', () => {
it('returns true if the string is a palindrome', async () => {
// You can await for value, then do a normal expect
expect(await palindrome(`Never odd or even`)).to.equal(true);
// Or, you can do an asynchronous expectation
await expect(palindrome(`Never odd or even`)).to.eventually.equal(true);
});
it('raises an error if the value is not a string', async () => {
await expect(palindrome(37)).to.be.rejectedWith(/.+ is not a string/);
});
});
For positive expectations, the chai-as-promised library gives you the .eventually
chain, which works just like Jest's .resolves
chain and allows you to append any other regular matcher. For negative expectations, it works more like Jasmine -- there's a special rejectedWith
matcher. Just like the other two frameworks, you can pass an Error object, a string, or a regular expression.
Summary
Of the three test frameworks above, I think Jest has the best, most consistent style for writing asynchronous expectations. Personally, I'll drop back to Mocha or Jasmine for small tools and libraries, because I like the smaller footprint, but all 3 frameworks are quite close -- the same functionality and testing patterns are available in all, and your choice boils down to which particular flavor of syntax sugar you prefer.
Is there a test runner or framework you prefer (maybe one not mentioned above)? Let me know!
Photo by Franki Chamaki on Unsplash
Posted on March 30, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 24, 2024
November 25, 2024