paint-brush
Testing Asynchronous JS Code: 2020 Editionby@Team: Igniter
366 reads
366 reads

Testing Asynchronous JS Code: 2020 Edition

tldt arrow

Too Long; Didn't Read

Asynchronous JS Code: 2020 Edition, Testing AsyncJS Code, 2020 Edition by Igniter, Igniter. This article includes a fictional dialogue between two programmers on their way to discover the orgastic pleasures of a library called AsyncFn. We'll also cover topics such as TDD, pair programming, evil pairing, and “negation testing’s not the main topic here, but some words are spared for them nonetheless to justify a way of thinking. We get a new perspective of structuring and writing unit tests using the library called asyncFn. This simplifies unit testing by allowing tests that read chronologically, like a story.
featured image - Testing Asynchronous JS Code: 2020 Edition
Team: Igniter from Houston Inc. Consulting HackerNoon profile picture

The text you are about to read describes an imaginary dialog between two programmers on their way to discover the orgastic pleasures of a library called asyncFn.

asyncFn provides additional methods to eg. jest.fn or sinon.spy to introduce “late resolve” for the promises returned by mock functions. This simplifies async unit testing by allowing tests that read chronologically, like a story.

From this, we get a new perspective of structuring and writing unit tests.

Foreword

Be warned that we’ll also cover topics such as TDDpair programming, evil pairing, and “negation testing”. They are not the main topic here, but some words are spared for them nonetheless to justify a way of thinking.

Best of luck, Dear Reader.

Chapter 1: The premise and the foundation

Fry: My name is Fry, and I find it difficult to unit test async-stuff in JavaScript.

Leela: Tell me more.

Fry: Yup, say I wanted to implement something to this specification:

Feature: As a player, I can play a game called Monster Beatdown

    Scenario: I can damage an encountered monster
        Given I encounter a monster
        And I choose to attack it
        Then the monster loses hit points

    Scenario: I can try flee a monster
        Given I encounter a monster
        And I choose to flee
        Then the monster eats me
        And I lose

    Scenario: I can win the game by beating a monster until it is knocked out
        Given I encounter a monster
        When I attack it until it has no hit points
        Then the monster is knocked out
        And I win

Fry: The kicker here is that in this game, prompting the player for action is asynchronous.

Leela: Mm-hmm, I think I get it. Let’s get our hands dirty and see where it lands us.

Fry: Here’s an initial test for good measure:

import gameWithoutDependencies from './monsterBeatdown';

it('when a monster is encountered, informs the player of this', () => {
  const messagePlayerMock = jest.fn();
  const game = gameWithoutDependencies({
    messagePlayer: messagePlayerMock,
  });

  game.encounterMonster();

  expect(messagePlayerMock).toHaveBeenCalledWith(
    'You encounter a monster with 5 hit-points.',
  );
});

Contents of 

./monsterBeatdown.js
 so far:

export default ({ messagePlayer }) => ({
  encounterMonster: () => {
    messagePlayer(`You encounter a monster with 5 hit-points.`);
  },
});

Leela: Ok, so far so good. I see you chose to inject the mock-function for messaging the player as an argument for the function we are testing. Crystal. Full steam ahead.

Fry: Right on. Next up will be my vision for asking the player if they want to attack, and if not, they lose.

Chapter 2: The problems emerge

import gameWithoutDependencies from './monsterBeatdown';

it('when a monster is encountered, informs the player of this', async () => {
  const messagePlayerMock = jest.fn();
  const game = gameWithoutDependencies({
    messagePlayer: messagePlayerMock,
  });

  await game.encounterMonster();

  expect(messagePlayerMock).toHaveBeenCalledWith(
    'You encounter a monster with 5 hit-points.',
  );
});

it('when a monster is encountered, asks player to attack', async () => {
  const askPlayerToHitMock = jest.fn();
  const game = gameWithoutDependencies({
    askPlayerToHit: askPlayerToHitMock,
  });

  await game.encounterMonster();

  expect(askPlayerToHitMock).toHaveBeenCalledWith('Do you want to attack it?');
});

it('given a monster is encountered, when player chooses to flee, the monster eats the player', async () => {
  // When asked to attack, choosing false means to flee.
  const askPlayerToHitMock = jest.fn(() => Promise.resolve(false));

  const messagePlayerMock = jest.fn();

  const game = gameWithoutDependencies({
    askPlayerToHit: askPlayerToHitMock,
    messagePlayer: messagePlayerMock,
  });

  await game.encounterMonster();

  expect(messagePlayerMock).toHaveBeenCalledWith(
    'You chose to flee the monster, but the monster eats you in disappointment.',
  );
});

it('given a monster is encountered, when player chooses to flee, the game is over', async () => {
  const askPlayerToHitMock = jest.fn(() => Promise.resolve(false));

  const messagePlayerMock = jest.fn();

  const game = gameWithoutDependencies({
    askPlayerToHit: askPlayerToHitMock,
    messagePlayer: messagePlayerMock,
  });

  await game.encounterMonster();

  expect(messagePlayerMock).toHaveBeenCalledWith('Game over.');
});

Contents of 

./monsterBeatdown.js
 so far:

export default ({ messagePlayer = () => {}, askPlayerToHit = () => {} }) => ({
  encounterMonster: () => {
    // For real life, the implementation below makes little sense.
    // This is intentional, and is so to prove a point later.
    // At this point, it suffices that all tests are green
    messagePlayer('Game over.');

    messagePlayer('You encounter a monster with 5 hit-points.');

    askPlayerToHit('Do you want to attack it?');

    messagePlayer(
      'You chose to flee the monster, but the monster eats you in disappointment.',
    );
  },
});

Leela: Hold the phone. I see two problems here. One is the duplication, and the other one is about the test describing occurrences in non-chronological order, making the test harder to read. Let’s start by fixing the first problem.

Chapter 3: The clumsy fix

Fry: Uh, I was just about to remove the duplication anyway. Red-green-refactor, right?

Leela: Right.

import gameWithoutDependencies from './monsterBeatdown';

describe('given a monster is encountered', () => {
  let askPlayerToHitMock;
  let messagePlayerMock;
  let game;

  beforeEach(() => {
    askPlayerToHitMock = jest.fn();
    messagePlayerMock = jest.fn();

    game = gameWithoutDependencies({
      askPlayerToHit: askPlayerToHitMock,
      messagePlayer: messagePlayerMock,
    });
  });

  it('informs the player of this', async () => {
    await game.encounterMonster();

    expect(messagePlayerMock).toHaveBeenCalledWith(
      'You encounter a monster with 5 hit-points.',
    );
  });

  it('asks player to attack', async () => {
    await game.encounterMonster();

    expect(askPlayerToHitMock).toHaveBeenCalledWith(
      'Do you want to attack it?',
    );
  });

  describe('when player chooses to flee', () => {
    beforeEach(async () => {
      // When asked to attack, choosing false means to flee.
      askPlayerToHitMock.mockResolvedValue(false);

      await game.encounterMonster();
    });

    it('player is informed of the grim outcome', () => {
      expect(messagePlayerMock).toHaveBeenCalledWith(
        'You chose to flee the monster, but the monster eats you in disappointment.',
      );
    });

    it('the game is over', () => {
      expect(messagePlayerMock).toHaveBeenCalledWith('Game over.');
    });
  });
});

Contents of 

./monsterBeatdown.js
 so far:

// no changes, as only tests were refactored.

Fry: There. Now the duplication for the test setup has been removed, but only to a degree. However, the other problem of test not being written in chronological order prevents us from removing all the duplication. See how we are forced to repeat 

game.encounterMonster()
because
askPlayerToHitMock
 needs to know how to behave before it is called.

Worse yet, this makes the “describe” dishonest, as it claims to display how "
given a monster is encountered
", but in reality, this is something that happens only later in the test setup, if ever.

All this kind of bums me out, and I’ve felt the pain of this getting out of hand as in real-life requirements and features start to pile up. I know many ways to ease the pain a little bit, but nothing I’ve tried has left me comfortable.

Leela: I see. Let’s now introduce 

asyncFn
 as an expansion to the normal
jest.fn
to tackle all.

Behold.

Chapter 4: The beholding of asyncFn

import gameWithoutDependencies from './monsterBeatdown';
import asyncFn from '@asyncFn/jest';

describe('given a monster is encountered', () => {
  let askPlayerToHitMock;
  let messagePlayerMock;
  let game;

  beforeEach(() => {
    // Note: Before we used jest.fn here instead of asyncFn.
    askPlayerToHitMock = asyncFn();
    messagePlayerMock = jest.fn();

    game = gameWithoutDependencies({
      askPlayerToHit: askPlayerToHitMock,
      messagePlayer: messagePlayerMock,
    });

    game.encounterMonster();
  });

  it('informs the player of this', async () => {
    expect(messagePlayerMock).toHaveBeenCalledWith(
      'You encounter a monster with 5 hit-points.',
    );
  });

  it('asks player to attack', () => {
    // Note how even if askPlayerToHitMock is now a mock made using asyncFn(), it still is a jest.fn(). This means eg. toHaveBeenCalledWith can be used with it.
    expect(askPlayerToHitMock).toHaveBeenCalledWith(
      'Do you want to attack it?',
    );
  });

  describe('when player chooses to flee', () => {
    beforeEach(async () => {
      // Note how choosing false here still means fleeing combat, but here we use asyncFn's .resolve() to control the outcome. And then we await for the consequences of that.
      await askPlayerToHitMock.resolve(false);
    });

    it('player is informed of the grim outcome', () => {
      expect(messagePlayerMock).toHaveBeenCalledWith(
        'You chose to flee the monster, but the monster eats you in disappointment.',
      );
    });

    it('the game is over', () => {
      expect(messagePlayerMock).toHaveBeenCalledWith('Game over.');
    });
  });
});

Contents of 

./monsterBeatdown.js
 so far:

// no changes, as only tests were refactored.

Leela: Now the duplication is gone, and everything takes place in clean, chronological order. It kind of reads like a story, don’t you think?

Fry: Mm-hmm. I see it. I have a good feeling about it. But something is bothering me with the production code. I see that the tests are all green, but clearly, the code does not do anything sensical. It just blows through, merely satisfying the tests.

Leela: You got me there. We are, in fact doing something called “evil pairing”. If you want to mold the production code your way, you need to order it from the universe by writing a very specific test first.

Let me show you how.

Chapter 5: Evil is good and negative is positive

import gameWithoutDependencies from './monsterBeatdown';
import asyncFn from '@asyncFn/jest';

describe('given a monster is encountered', () => {
  let askPlayerToHitMock;
  let messagePlayerMock;
  let game;

  beforeEach(() => {
    askPlayerToHitMock = asyncFn();
    messagePlayerMock = jest.fn();

    game = gameWithoutDependencies({
      askPlayerToHit: askPlayerToHitMock,
      messagePlayer: messagePlayerMock,
    });

    game.encounterMonster();
  });

  it('informs the player of this', async () => {
    expect(messagePlayerMock).toHaveBeenCalledWith(
      'You encounter a monster with 5 hit-points.',
    );
  });

  it('asks player to attack', () => {
    expect(askPlayerToHitMock).toHaveBeenCalledWith(
      'Do you want to attack it?',
    );
  });

  // This is the "negation test" referred later in narrative.
  it('when player has not chosen anything yet, the player is not eaten', () => {
    expect(messagePlayerMock).not.toHaveBeenCalledWith(
      'You chose to flee the monster, but the monster eats you in disappointment.',
    );
  });

  // This is another negation test.
  it('when player has not chosen anything yet, the game is not over', () => {
    expect(messagePlayerMock).not.toHaveBeenCalledWith('Game over.');
  });

  describe('when player chooses to flee', () => {
    beforeEach(async () => {
      await askPlayerToHitMock.resolve(false);
    });

    it('player is informed of the grim outcome', () => {
      expect(messagePlayerMock).toHaveBeenCalledWith(
        'You chose to flee the monster, but the monster eats you in disappointment.',
      );
    });

    it('the game is over', () => {
      expect(messagePlayerMock).toHaveBeenCalledWith('Game over.');
    });
  });

  describe('when player chooses to attack', () => {
    beforeEach(async () => {
      await askPlayerToHitMock.resolve(true);
    });

    // Another negation test
    it('player does not lose', () => {
      expect(messagePlayerMock).not.toHaveBeenCalledWith(
        'You chose to flee the monster, but the monster eats you in disappointment.',
      );
    });

    it('the game is over', () => {
      expect(messagePlayerMock).toHaveBeenCalledWith('Game over.');
    });
  });
});

Contents of 

./monsterBeatdown.js
 so far:

export default ({ messagePlayer, askPlayerToHit }) => ({
  encounterMonster: async () => {
    messagePlayer('You encounter a monster with 5 hit-points.');

    const playerChoseToAttack = await askPlayerToHit(
      'Do you want to attack it?',
    );

    if (!playerChoseToAttack) {
      messagePlayer(
        'You chose to flee the monster, but the monster eats you in disappointment.',
      );
    }

    messagePlayer('Game over.');
  },
});

Leela: There. I’ve reduced the “evilness” of the code by adding something we call “negation tests”. By writing tests that describe what is not supposed to happen we forced the production code to make a little bit more sense.

And if you are wondering where the name “evil pairing” comes from, it comes from playing unit testing ping-pong in such a way that the production code is always written in the most evil way possible, ie. the code is non-sensical for real-life, yet it still satisfies the tests. The only way out of this is to force sense in production through unit tests.

Sometimes you can tell coders are evil pairing from the evil laughter.

In general, this “evil pairing -mentality” helps produce code that is very robust for the sake of refactoring, and also allows programmers to hone their TDD-mojo a little bit more.

But I digress. However important this may be, it is slightly off-course. What is relevant is that

asyncFn
supports evil pairing as a line of thinking.

Let’s motor on.

Chapter 6: The multiplicity challenge and the enlightenment

Fry: For sure. Can we see how the game develops some steps further? Particularly, I’ve been suffering from testing of functions that are called multiple times, yet return promises.

Leela: I feel you. My guess is an asyncFn-mock will get called multiple times soon enough, when we start attacking the monster multiple times.

Let’s see how that pans out.

import gameWithoutDependencies from './monsterBeatdown';
import asyncFn from '@asyncFn/jest';

describe('given a monster is encountered', () => {
  let askPlayerToHitMock;
  let messagePlayerMock;
  let game;

  beforeEach(() => {
    askPlayerToHitMock = asyncFn();
    messagePlayerMock = jest.fn();

    game = gameWithoutDependencies({
      askPlayerToHit: askPlayerToHitMock,
      messagePlayer: messagePlayerMock,
    });

    game.encounterMonster();
  });

  it('informs the player of this', async () => {
    expect(messagePlayerMock).toHaveBeenCalledWith(
      'You encounter a monster with 5 hit-points.',
    );
  });

  it('asks player to attack', () => {
    expect(askPlayerToHitMock).toHaveBeenCalledWith(
      'Do you want to attack it?',
    );
  });

  it('when player has not chosen anything yet, the game is not lost', () => {
    expect(messagePlayerMock).not.toHaveBeenCalledWith('Game over.');
  });

  describe('when player chooses to flee', () => {
    beforeEach(async () => {
      await askPlayerToHitMock.resolve(false);
    });

    it('player is informed of the grim outcome', () => {
      expect(messagePlayerMock).toHaveBeenCalledWith(
        'You chose to flee the monster, but the monster eats you in disappointment.',
      );
    });

    it('the game is not won', () => {
      expect(messagePlayerMock).not.toHaveBeenCalledWith(
        'You knock out the monster. You are a winner.',
      );
    });

    it('the game is over', () => {
      expect(messagePlayerMock).toHaveBeenCalledWith('Game over.');
    });
  });

  it('when player hits the monster, the monster loses a hit-point', async () => {
    await askPlayerToHitMock.resolve(true);

    expect(messagePlayerMock).toHaveBeenCalledWith(
      'You hit the monster. It now has 4 hit-points remaining.',
    );
  });

  describe('when player hits the monster enough times to knock it out', () => {
    beforeEach(async () => {
      // Here the monster is attacked multiple times, and therefore, askPlayerToHitMock is called multiple times, and then the test resolves the mock multiple times.

      // Using asyncFn, the logic remains the same: things that happen are written and observed in chronological order.
      await askPlayerToHitMock.resolve(true);
      await askPlayerToHitMock.resolve(true);
      await askPlayerToHitMock.resolve(true);
      await askPlayerToHitMock.resolve(true);
      await askPlayerToHitMock.resolve(true);
    });

    it('does not show out-of-place message about no hit-points', () => {
      expect(messagePlayerMock).not.toHaveBeenCalledWith(
        'You hit the monster. It now has 0 hit-points remaining.',
      );
    });

    it('player is congratulated', () => {
      expect(messagePlayerMock).toHaveBeenCalledWith(
        'You knock out the monster. You are a winner.',
      );
    });

    it('game is won', () => {
      expect(messagePlayerMock).toHaveBeenCalledWith('Game over.');
    });

    it('the game is not lost', () => {
      expect(messagePlayerMock).not.toHaveBeenCalledWith(
        'You chose to flee the monster, but the monster eats you in disappointment.',
      );
    });
  });

  it('when player hits the monster until it only has 1 hit-point remaining, the game is not over yet', async () => {
    // Hit the monster only 4 times instead of 5.
    await askPlayerToHitMock.resolve(true);
    await askPlayerToHitMock.resolve(true);
    await askPlayerToHitMock.resolve(true);
    await askPlayerToHitMock.resolve(true);

    expect(messagePlayerMock).not.toHaveBeenCalledWith('Game over.');
  });
});

The final form of 

./monsterBeatdown.js
:

export default ({ askPlayerToHit, messagePlayer }) => ({
  encounterMonster: async () => {
    let monsterHitPoints = 5;

    messagePlayer(
      `You encounter a monster with ${monsterHitPoints} hit-points.`,
    );

    while (await askPlayerToHit('Do you want to attack it?')) {
      monsterHitPoints--;

      if (monsterHitPoints === 0) {
        break;
      }

      messagePlayer(
        `You hit the monster. It now has ${monsterHitPoints} hit-points remaining.`,
      );
    }

    if (monsterHitPoints === 0) {
      messagePlayer('You knock out the monster. You are a winner.');
    } else {
      messagePlayer(
        'You chose to flee the monster, but the monster eats you in disappointment.',
      );
    }

    messagePlayer('Game over.');
  },
});

Leela: There. Now the monster gets hit multiple times, all while things still happen in a clear order. Our work is done here.

Fry: Color me enlightened. This has changed my view of the world as a programmer and a human being. I shall make sacrifices in your honor and get an asyncFn-tattoo. My grandchildren will know your name.

Summary

Having witnessed the adventure, we’ve seen how the late resolve of

asyncFn
permitted troublesome tests, with awkward readability, to be refactored orderly as a story, with significantly better readability.

asyncFn
is something that we, as a team, have used for multiple years now. It has categorically changed the way we approach unit testing of asynchronous code in JavaScript. Which we so adore.

See more about asyncFn for jest or sinon.

Who are we?

asyncFn is lovingly crafted by Your Pals at Team: Igniter from Houston Inc. Consulting.

We are a software development team of friends, with proven tradition in professional excellence. We specialize in holistic rapid deployments without sacrificing quality.

Come say hi at Gitteremail us, or check out the team itself. We just might be available for hiring ;)

Previously published at https://medium.com/houston-io/how-to-unit-test-asynchronous-code-for-javascript-in-2020-41c124be2552