skip to Main Content

Consider the faux account creation code below:

export const accounts = [];
export const emails = [];

export async function createAccount(name, email) {
    accounts.push({name, email});
    void sendEmail(email);
}

async function sendEmail(email) {
    setTimeout(0, () => emails.push(`Account created to ${email}`));
}

The createAccount() function "saves" an account in the array, and then "sends an email" reporting the account was created. Note that createAccount() does not wait for the email to be sent, since it can take some time.

Now, we write a test to ensure the email is sent:

import {assert} from 'chai';
import {createAccount, emails} from './index.js';

describe('test', async () => {
  it('test', async() => {
    await createAccount('John Doe', '[email protected]');
    assert(emails[0], 'Account created to [email protected]');
  });
});

Of course, though, the test does not pass…

$ npm run test

> [email protected] test
> mocha test.js



  test
    1) test


  0 passing (5ms)
  1 failing

  1) test
       test:
     AssertionError: Account created to [email protected]
      at Context.<anonymous> (file:///home/me/sandbox/foo/test.js:7:5)

…because the sendEmail() function, being called asynchronously, did not yet "send" the email.

But I really want to test if the email was eventually sent!

How could I test that?

Ideally, it would not:

  • involve exposing the sendEmail() return value;
  • involve sleeps/timeouts.

Any ideas?

Appendix

To make things easier to test, you can use this package.json:

{
  "name": "foo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "mocha test.js"
  },
  "type": "module",
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "chai": "^5.1.1",
    "mocha": "^10.6.0"
  }
}

2

Answers


    1. You could accept a callback that gets triggered after the email has been sent.
    2. You could test the side effect (e.g., get something to intercept the call to your email API).
    3. Your createAccount could return a promise that only resolves after the email has been sent. Note that it’s fine to return an object with a emailSentPromise property separate from the main returned Promise.
    4. You could use mocking/dependency injection to let the caller provide an implementation of sendEmail instead of the default one.
    Login or Signup to reply.
  1. Make createAccount return a promise for the result of the account creation. That result contains another promise for the result of the email being sent. This follows the fetch pattern where the response object represents only the headers and one needs to wait for the contents of the body separately.

    export async function createAccount(name, email) {
      accounts.push({name, email});
      const emailSent = sendEmail(email); // .then(x => void x) if you don't want to leak further details
      emailSent.catch(e => { /* ignore */ });
      return { emailSent };
    }
    
    async function sendEmail(email) {
      await new Promise(resolve => { setTimeout(0, resolve); });
      emails.push(`Account created to ${email}`));
    }
    
    describe('test', async () => {
      it('test', async() => {
        const { emailSent } = await createAccount('John Doe', '[email protected]');
        assert(accounts[0], '…');
        await emailSent;
        assert(emails[0], 'Account created to [email protected]');
      });
    })
    

    Alternatively, instead of having a promise on the result which someone "should" await, you can have a notify method that the caller can (but doesn’t need to) call and can await or forget.

    export async function createAccount(name, email) {
      accounts.push({name, email});
      return {
        notify() {
          await sendEmail(email);
        },
      };
    }
    
    <!-->
    describe('test', async () => {
      it('test', async() => {
        const account = await createAccount('John Doe', '[email protected]');
        assert(accounts[0], '…');
        await account.notify();
        assert(emails[0], 'Account created to [email protected]');
      });
    });
    

    This puts more responsibility on the caller to not forget .notify() of course. If you need, provide another helper method wrapping this behaviour.


    If you don’t want to expose those internals at all, you can of course still test these internals separately. Export saveAccount and sendEmail from the implementation module, test them separately. Then for testing the public createAccount, just mock them and check they are being called. This way, you can even test that createAccount does in fact not wait for sendEmail.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search