2020-11-01
|~10 min read
|1834 words
When it comes to testing our code, it may be desireable to avoid following the normal code path all the way. Some examples of calls that we may want to avoid when writing tests are non-deterministic code paths, network requests, third-party dependencies.
Depending on the situation, we have multiple strategies available to us which I aim to delve into with an example in this post. I’ll be covering:
To ground the conversation, I’ll be using the following small game as an example:
export function play(player1, player2) {
let winner
while (!winner) {
winner = getWinner(player1, player2)
if (winner) {
return winner
}
}
}
export function getWinner(player1, player2) {
if (Math.random() > 0.8) {
return Math.round(Math.random()) ? player1 : player2
}
}
While the game may be fun for the players, its use of Math.random
means that we don’t know who’s going to win. Sometimes with tests, we want to have greater certainty - so that’s exactly what we’ll be looking at establishing.
The first strategy we’ll explore is Monkey Patching, aka reassignment of a method at runtime. Monkey Patching was much more common years ago having fallen out of favor due to some of its limitations / potential pitfalls.1 Still, it’s a relatively direct solution, so let’s start there:
import * as game from "./game"
test("returns winner", () => {
const ogGetWinner = game.getWinner
game.getWinner = (player1, player2) => player2 //highlight
const winner = game.play("John", "Jane")
expect("Jane").toBe(winner)
game.getWinner = ogGetWinner
})
The key to making monkey patching work is keeping track of the original so that we can reset it at the end of the test. If we don’t, every test that runs after this one will use our new fake definition of getWinner
. That may not be intended. We did accomplish our goal of making getWinner
much more deterministic, which in our case now always says player two will win the game.
Besides the fact that this kind of monkey patching is suboptimal (and there are linter rules to guard against it too2), the bigger issue is that a mock like this “severs” all relationship with the original method, i.e. if we modify the original API for getWinner
to require a third parameter, then it would be our responsibility to remember that the function had been mocked and update it accordingly.3 If we don’t, well, the test could still pass even though it no longer reflects the reality of the code.
For example, assume that the monkey patch above hasn’t changed, but getWinner
has:
export function getWinner(player1, player2, game) {
if (game === "CHESS") return player1
if (Math.Random() > 0.8) {
return Math.round(Math.random()) ? player1 : player2
}
}
Now, if the game is CHESS
, then player1
will always win, but our tests don’t reflect that.
Let’s extend our monkey patching strategy with a mock
object so that we can get even more declarative assertions. This mock
object will enable tracking call volume and the arguments used during invocation.
Modifying the test from before:
import * as game from "./game"
test("returns winner", () => {
const ogGetWinner = game.getWinner
game.getWinner = (...args) => {
game.getWinner.mock.calls.push(args)
return args[1]
}
game.getWinner.mock = { calls: [] }
const winner = game.play("John", "Jane")
expect("Jane").toBe(winner)
expect(game.getWinner.mock.calls).toHaveLength(1)
game.getWinner.mock.calls.forEach((call) =>
expect(call).toEqual(["John", "Jane"]),
)
game.getWinner = ogGetWinner
})
Adding the mock
property on the getWinner
method mock means that we can now more easily track the number of times the method is invoked as well as with which arguments.4 While this isn’t a perfect solution for ensuring the contract is maintained, we have greater visibility at a minimum and our tests will fail if a new argument is inserted without our knowledge in the original implementation.
What if there was a way to get insight into whether a function was called and what it was called with without having to modify a function’s object or mock it? Well, that’s what spying does. The Jest implementation of spyOn
has the following to say:
Creates a mock function similar to
jest.fn
but also tracks calls toobject[methodName]
. Returns a Jest mock function.
The big benefit of the spy
method is that Jest (in this case) takes on a lot the heavy lifting for us in terms of managing the details. For example, when we wrap our method with spyOn
, Jest will now look for a mockImplementation
and call that without modifying the original (though we still need to be sure to restore the mock implementation to prevent it from bleeding into other tests).
import * as game from "./game"
test("returns winner", () => {
const spy = jest.spyOn(game, "getWinner").mockImplementation((p1, p2) => p2)
const winner = game.play("John", "Jane")
expect("Jane").toBe(winner)
expect(spy.mock.calls).toHaveLength(1)
spy.mock.calls.forEach((call) => expect(call).toEqual(["John", "Jane"]))
spy.mockRestore()
})
While I prefer using the returned object, it’s also possible to write this without referencing a spy
object:
test("returns winner", () => {
- const spy = jest.spyOn(game, "getWinner").mockImplementation((p1, p2) => p2)
+ jest.spyOn(game, "getWinner")
+ game.getWinner.mockImplementation((p1, p2) => p2)
const winner = game.play("John", "Jane")
expect("Jane").toBe(winner)
- expect(spy.mock.calls).toHaveLength(1)
+ expect(game.getWinner.mock.calls).toHaveLength(1)
- spy.mock.calls.forEach((call) =>
+ game.getWinner.mock.calls.forEach((call) =>
expect(call).toEqual(["John", "Jane"]),
)
- spy.mockRestore()
+ game.getWinner.mockRestore()
})
It’s worth pointing out that the primary difference between monkey patching (and mocking as we’ll see) and spying is that the former modifies the underlying method, while spying merely adds some monitoring capabilities. In our example we actually modified the method through the use of mockImpelementation
. If we had not done that, we could still have asked how many times getWinner
was invoked without having the certainty of the outcome (i.e. always pick p2
).
Finally, while Jest is now responsible for modifying the object, it’s still modifying it on the namespace (in this case that’s game
). Mocking is an alternative approach here that addresses that issue. Let’s look at it now.
The Jest Mock function will do create an auto-mock, which is helpful for tracking how many times it’s been invoked (because Jest will manage modifying the function object), however, if we need to alter the behavior, that’s where the factory argument comes in handy.
jest.mock
will automatically mock the entire object referenced with the first argument (a relative path), however, so before we can put this to use, we need to do one of two things:
getWinner
into a separate module.5The latter is simpler, so we’ll go that route:
import * as game from "./game"
jest.mock("./game", () => {
const originalGame = jest.requireActual("./game")
return {
...originalGame,
getWinner: jest.fn((p1, p2) => p2),
}
})
test("returns winner", () => {
const winner = game.play("John", "Jane")
expect("Jane").toBe(winner)
expect(spy.mock.calls).toHaveLength(1)
spy.mock.calls.forEach((call) => expect(call).toEqual(["John", "Jane"]))
})
The key here is that we spread the original in the object returned by the mock, so that we don’t overwrite the original implementations. Either way, just like that, we’ve mocked getWinner
and allowed Jest has done all the heavy lifting.
While the mock works, there’s one more consideration we need to take into account: resetting the mock so that our tests remain independent. This is a good opportunity for a beforeEach
hook.
import * as game from "./game"
jest.mock("./game", () => {
const originalGame = jest.requireActual("./game")
return {
...originalGame,
getWinner: jest.fn((p1, p2) => p2),
}
})
beforeEach(() => { game.getWinner.mockClear()})
test("returns winner", () => {
const winner = game.play("John", "Jane")
expect("Jane").toBe(winner)
expect(spy.mock.calls).toHaveLength(1)
spy.mock.calls.forEach((call) => expect(call).toEqual(["John", "Jane"]))
})
So far, our mocks are scoped to a single test file. If there’s a module that’s going to be mocked regularly, it may be useful to pull it out into a global mock. If the module is internal (i.e. something that’s defined in the project), you would put a file in __mocks__
directory. The directory is at the same level as the file it’s mocking.
For example, let’s imagine we want to mock a utils.js
for our game.js
.
├── __mocks__
│ └── utils.js
├── game.test.js
├── game.js
└── utils.js
Now, in our test file, instead of declaring the mock inline, we allow Jest to “auto-mock” it. Because there’s a __mocks__
file that matches (by filename) the module we are mocking, Jest will be able to infer what to do:
import * as game from "./game"
jest.mock("./utils")
//...
This same pattern would work for an imported package. For example, if we wanted to mock axios
(so as to not make network requests), the __mocks__
directory would live in the root of the project (next to the node_modules
directory). Everything else would operate the same.
Whew! We’ve covered a lot of ground here and learned quite a bit! How to monkey patch a function, spy on a function, or mock it in a variety of ways. Of course, I’m just scratching the surface here with testing - which seems to always be the case. Oh well, that’s part of the spice of life!
That said, the mocking still severs the connection between a test and its source code. So, where possible, the guidance from those who know better than I do, is avoid mocking where possible.
Thanks to Kent C. Dodds for everything - this post was inspired by what I learned from his Front-End Masters course on Testing Practices & Principles.
1 This common practice actually lead to serious problems, for example, MooTools worked by monkey patching the prototype of native features. Due to the tools popularity TC39 had to change its name for a new method it wanted to introduce from .contains
to .includes
so as to avoid conflict with the MooTools implementation with which it was incompatible. Here’s some more about the issue.
2 The Namespace rule for ESLint as example “Reports on assignment to a member of an imported namespace.”
3 This is a common problem with mocks actually, and is often addressed through a form of testing called Contract Testing.
4 What I really like about this approach is how it takes advantages of the fact that nearly everything in Javascript is an object, including functions. We’re taking advantage of this property here by assigning a mock
property to a the function object.
5 If we wanted to refactor, this would be how:
export function getWinner(player1, player2) {
if (Math.Random() > 0.8) {
return Math.round(Math.random()) ? player1 : player2
}
}
import { getWinner } from "./utils"
export function play(player1, player2) {
let winner
while (!winner) {
winner = getWinner(player1, player2)
if (winner) {
return winner
}
}
}
Now that we have separated our modules, we can take advantage of the jest.mock
.
import * as game from "./game"
import * as utils from "./utils"
jest.mock("./utils", () => ({
getWinner: jest.fn((p1, p2) => p2),
}))
test("returns winner", () => {
const winner = game.play("John", "Jane")
expect("Jane").toBe(winner)
expect(spy.mock.calls).toHaveLength(1)
spy.mock.calls.forEach((call) => expect(call).toEqual(["John", "Jane"]))
})
Hi there and thanks for reading! My name's Stephen. I live in Chicago with my wife, Kate, and dog, Finn. Want more? See about and get in touch!