Introduction
Test doubles are a powerful tool available to us inside of our testing frameworks. They allow us to make fake versions of our code so that we can test in deterministic ways. But this seems counter intuitive, no? What benefit could there possibly be to testing fake code? At least I know that was the hardest part for me to wrap my head around. Let's take a look at an example to solidify the thought process.
A Roll of the Dice
Let's say that we're creating a board game application called Caverns and Creatures. This game relies on the user rolling dice to determine outcomes in a given situation. Let's take a look at an example function in our game:
const Roll = require("./utils/dice")
function fight(enemy) {
const characterAttackValue = Roll.d6()
const enemyHP = enemy.hitPoints
if (characterAttackValue > 5) {
enemyHP -= 20
} else if (characterAttackValue > 3) {
enemyHP -= 10
} else if (characterAttackValue > 1) {
enemyHP -= 5
} else {
enemyHP -= 1
}
}
Testing Trouble
Now, as we know about unit testing, we should only be testing the smallest units of code possible. So we want to test our fight
function. The problem is that we are going to get a random value back from each call to Roll.d6()
. Since we can't predict exactly what those values will be, we can't very well test our fight
function. What would we assert?
Here is what our Roll.d6
method looks like:
class Roll {
d6() {
return Math.floor(Math.random * 6) + 1
}
}
This is where mocking comes in. We will make a mock (fake version) of the Roll.d6()
function so that we know exactly what value it will return. With a static value being returned for our dice role, we can write deterministic tests against our fight
function.
Solution
Here's what the test would look like:
const Roll = require("../src/js/utils/dice")
const { fight } = require("../src/actions")
test("Decreases enemy HP by 20 when 6 is rolled", () => {
const originalRollD6 = Roll.d6
Roll.d6 = jest.fn(() => 6)
const goblin = {
hitPoints: 50,
}
fight(goblin)
expect(goblin.hitPoints).toEqual(30)
Roll.d6 = originalRollD6
})
Stubs
So what we're able to do with a stub is just replace the implementation of an existing method or function so that we can deterministically test another one. So we want to test the fight
function. To make sure that we can assert the right thing and get the right answer every time, we just remove the randomness from the dice roll and test based on a fixed number. We can also test things about how the stub was used.
Spies
Jest also has a spyOn
method that gives you even more control over the stubbed function. Let's add this to our test:
const Roll = require("../src/utils/dice")
const { fight } = require("../src/actions")
test("Decreases enemy HP by 20 when 6 is rolled", () => {
jest.spyOn(Roll, "d6")
Roll.d6.mockImplementation(() => 6)
const goblin = {
hitPoints: 50,
}
fight(goblin)
expect(Roll.d6.mock.calls).toEqual([[]])
expect(goblin.hitPoints).toEqual(30)
Roll.d6.mockRestore()
})
Spies provide us with all of the functionality that we were implementing ourselves prior. We now don't have to keep track of the original value of Roll.d6
and Jest takes care of restoring it for us when we're done. a spies job is to give us information about how the stubbed function was used. How many times it was called, what parameters were passed to it, etc.
Mocks
Now to deal with full on mocks. A mock is something that we use when we want to create a test double of and entire module or object, in our case the Roll
object. Let's alter our test one final time:
const Roll = require("../src/utils/dice")
const { fight } = require("../src/actions")
jest.mock("../src/utils/dice", () => {
return {
d6: jest.fn(() => 6),
}
})
test("Decreases enemy HP by 20 when 6 is rolled", () => {
const goblin = {
hitPoints: 50,
}
fight(goblin)
expect(Roll.d6.mock.calls).toEqual([[]])
expect(goblin.hitPoints).toEqual(30)
Roll.d6.mockReset()
})
This method gives us a lot more control over the entire module that we're testing. As you can see, we could stub multiple methods and provide a lot more implementation details about Roll
all at one time. It's not often that you will see mocks implemented like this though. Usually they are more universal so they can be reused. Let's see how to approach that.
External Mocks
First, we need to make a directory called __mocks__
directly adjacent to the file we'll be mocking and name the mock file the same thing as the production code. So in our case we need to make src/utils/__mocks__/dice.js
and give it the following code:
module.exports = {
d6: jest.fn(() => 6),
}
Now we can alter our test like so:
const Roll = require("../src/utils/dice")
const { fight } = require("../src/actions")
jest.mock("../src/utils/dice")
test("Decreases enemy HP by 20 when 6 is rolled", () => {
const goblin = {
hitPoints: 50,
}
fight(goblin)
expect(Roll.d6.mock.calls).toEqual([[]])
expect(goblin.hitPoints).toEqual(30)
Roll.d6.mockReset()
})
Conclusion
Mocking is a really powerful tool for helping us isolate specific units of code so that we can deterministically test our units and make sure our code is covered so we can efficiently refactor. Keep in mind that, to some, mocking is usually a code smell (I am one of these people). Mocking has it's place for sure, but you should be doing your best to decouple your code in a way that avoids mocking as much as you can. Make sure to check out the Jest Docs to look at some more examples.
If you want to play around with the code from this example, that's located here