Foreword
Chapter 1: The premise and the foundation
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
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.',
);
});
./monsterBeatdown.js
so far:export default ({ messagePlayer }) => ({
encounterMonster: () => {
messagePlayer(`You encounter a monster with 5 hit-points.`);
},
});
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.');
});
./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.',
);
},
});
Chapter 3: The clumsy fix
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.');
});
});
});
./monsterBeatdown.js
so far:// no changes, as only tests were refactored.
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.
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.');
});
});
});
./monsterBeatdown.js
so far:// no changes, as only tests were refactored.
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.');
});
});
});
./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.');
},
});
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
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.');
});
});
./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.');
},
});
Summary
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.