DEV Community

loading...
Cover image for Unit testing async functions

Unit testing async functions

7tonshark profile image Elliot Nelson ・4 min read

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

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

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

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

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

Discussion (0)

pic
Editor guide