testing: mocking strategies

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:

  1. Monkey Patching
  2. Spying
  3. Mocking

To ground the conversation, I’ll be using the following small game as an example:

game.js
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.

Monkey Patching

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:

game.test.js
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:

game.js
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.

Monkey Patching & Extending

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:

game.test.js
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.

Spying

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 to object[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).

game.test.js
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:

game.test.js
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.

Mocking

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:

  1. Refactor getWinner into a separate module.5
  2. Use the Jest api requireActual

The latter is simpler, so we’ll go that route:

game.test.js
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.

game.test.js
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"]))
})

Global Mocking

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:

game.test.js
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.

Parting Thoughts

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.

Footnotes

  • 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:

    utils.js
    export function getWinner(player1, player2) {
      if (Math.Random() > 0.8) {
        return Math.round(Math.random()) ? player1 : player2
      }
    }
    game.js
    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.

    game.test.js
    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"]))
    })

Related Posts
  • Frontend Masters: Javascript Testing Practices & Princples


  • 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!