On Spy functions in JS

Spy functions are these beautiful things that we all use to test our code but never implement ourselves. Until recently I never thought about how to build them, but the Nodeschool’s functional javascript workshop has a lovely chapter, where you build your own basic spy function, so I decided to dig a bit further.

As it turns out spy functions are very joyful creatures to build and think about. While designing my implementation I realised that one is faced with some JS specific constraints. But I’m getting ahead of myself.

In this article I will debate some basic design considerations by walking you through two basic designs one for spying on methods and one for spying on methods as well as functions. Along the way I will take the opportunity to say a word or two about Function#apply, the arguments keyword, partial application and wrapper functions.

Accessing Functions and Methods

Before we start exploring different spy implementations we need to understand why there are different approaches to methods and functions. In JS we can’t observe functions the way we can observe methods. This comes from the fact that JS doesn’t allow the same access to the function’s pointer. In JS we can only refer the original pointer to a new piece of information if the pointer exists inside of an object. Since functions don’t exist inside objects but are objects themselves we can’t change them while keeping the original pointer. When reassigning a function or as a matter of fact any other object that does not reside inside an object, JS creates a new pointer to a new location in memory instead of changing the original entry. This is illustrated in the following example:

const notChanging = function() { return 'I will never change!' }
const willing = { toChange: function() { return 'I can change if you want.' } }

// this doesn't reassign the original function
function mutateFunction(fn) {
  fn = function() { return "Voilà! I've changed." }
} 
// but this does reassign the original method
function mutateMethod(object, method) {
  object[method] = function() { return "Voilà! I've changed." }
} 

mutateFunction(notChanging)
notChanging() // I will never change!

mutateMethod(willing, 'toChange')
willing.toChange() // Voilà! I've changed.

Due to this and to the fact that there literally doesn’t exist any other way to alter the original function body permanently, we are forced to approach functions differently.

General Usage of Spy Functions

A spy serves to spy on a function’s usage and behaviour. For example maybe we are using a function inside another one (like in a dependency injection) and we want to find out whether it has been called. A spy can check that for you.

Spies can be implemented in two ways. We can either overwrite the original function to turn it into a spy and then track its usage (which will be referred to as a method spy) or we can create a wrapper and use it in place of the original function (which will be referred to as a function spy).

Function spies are the most common ones and are usually used in the following schematic way:

test('should call dependency function inside main function', () => {
  // create a spy
  const spyFunction = createSpy(optionalOriginalFunction)
  // use it
  main(spyFunction)
  // check its properties
  expect(spyFunction.timesCalled).toBe(1)
})

In this design we use the spy instead of the original function. In this approach the original function is not even necessarily needed. If we just want to check that a function is called in a certain place without any functionality being needed, the spy creator will be able to create a dummy function without any functionality for us. The benefit of this approach is that we explicitly inject the spy in the places needed. This makes side effects less likely to occur and therefore our tests more robust. The limiting factor on the other hand is that we need to be able to inject the function, which let’s face it is not always the case.

Method spies address this limitation but can’t always be implemented in JS. They are used in the following schematic way:

test("should call object's method", () => {
  // create an observer
  const methodSpy = observerSpy(object, 'method')
  // use the method
  object.method()
  // check properties of the observer
  expect(methodSpy.observedCalls).toBe(1)
})

The interesting feature of this implementation is that one can even observe calls to the object’s method that are not directly reachable for us. This happens because we can directly change an object’s method in JS and therefore the changed method will be applied everywhere the object is used. The problem with this approach is that it can cause more side effects. You need to be sure about your object’s usage inside the code you are testing, because otherwise your test results can differ greatly from what you expected without you knowing why.

Implementations of Spy Functions

Building a method spy seemed in the beginning like a piece of magic to me. Due to JS’ pass by reference mechanism when passing an object as a function’s argument we can manipulate the original method. As it turns out the implementation of a method spy is the easiest way to implement a fairly capable spy.

Here you can see a sample implementation of such:

function observerSpy(target, method) {
  let results = { observedCalls: 0 }
  // save original method
  let originalFunction = target[method]
  // overwrite original function
  target[method] = function() {
      // apply all spy functionality you want
      results.observedCalls++
      // call original function
      return originalFunction.apply(target, arguments)
  }
  // the spy itself only returns the 'stats'
  return results
}

This observerSpy function overwrites the original method as a side effect and returns the stats we want to collect about the method we’re spying on. I¡ use the Function#apply method in this implementation to use the original object as the this context and also add all original arguments through the arguments keyword. This is very important because from now on this method will exist in the observerSpy scope, in which we will be able to manipulate it and add properties to it.

Function#apply is an interesting method of the Function.prototype. You can use it to change a function’s context. Through this we can recreate the objects context even though the method is called from the context of our observer. In a lot of situations this is not important, but if the method makes usage of the this keyword the right context will be needed. Through the Function#apply method in combination with the original object as the target the original context of the method will be preserved.

To implement a function spy we need to approach the problem differently. Like a lot of JS testing libraries and frameworks we can instead wrap our spy functionality around the original function and inject the spy in place of the original function. In JS this can be achieved by using Proxy, the decorator pattern or a wrapper function. Here an implementation of a wrapper function to create a spy:

function createSpy(fn, context) {
  // create a function in case none was given as an argument
  if (!fn) fn = function() {}
  
  function spyFn() {
    spyFn.timesCalled++
    // apply the given context or the spyFn context
    return fn.apply((context ? context : this), arguments)
  }
  // make the spyFn look like the original function
  spyFn.prototype = fn.prototype
  Object.defineProperty(spyFn, "name", { value: fn.name })
  // setup needed additional properties
  spyFn.timesCalled = 0

  return spyFn
}

This implementation makes use of partial application to return the set up spy function. The returned spyFn function can be used like the original function but we now have access to the additional attributes we set up.

Other than the method spy implementation where the method continued to exist in its original context we need to adjust a few properties with our function spy implementation to make it indistinguishable from the original. This can be important in case we use the function in places where its prototype or name properties are of relevance.

Spy Function Properties

Obviously we don’t only want to count how many times a function was called. There are other things we are interested in like the arguments that were used on our spy or the value it returned. Here an implementation of a couple of attributes for inspiration:

function createSpy(fn, context) {
  if (!fn) fn = function() {}
  function spyFn() {
    // saved in variable in case we want to use mock return
    const originalReturn = fn.apply((context ? context : this), arguments)
    const functionReturn = spyFn.hasMockedReturn ? spyFn.mockReturn : originalReturn
    // functions to freeze the spy in case we want to stop observing
    // from a certain point on
    spyFn.freeze = () => spyFn.frozen = true
    spyFn.unfreeze = () => spyFn.frozen = false

    // in frozen state ALL actions other than unfreeze are unavailable
    if (!spyFn.frozen) {
      spyFn.hasBeencalled = true
      spyFn.timesCalled++
      // pushes all used arguments as an array into the allArguments array
      spyFn.allArguments.push([...arguments])
      // filter unique arguments
      spyFn.allUniqueArguments = spyFn.allArguments
        .flat()
        .sort()
        .filter((arg, index, args) => arg !== args[++index])
      spyFn.lastArguments = spyFn.allArguments[spyFn.allArguments.length - 1]
      // following the WYSIWYG approach we save the actual return value
      spyFn.allReturnValues.push(functionReturn)
      spyFn.lastReturnValue = spyFn.allReturnValues[spyFn.allReturnValues.length - 1]

      // clear data in case we want to use the spy in a longer text
      // with several expects
      spyFn.clear = function () {
        spyFn.hasBeencalled = false
        spyFn.allArguments = []
        spyFn.timesCalled = 0
        spyFn.allReturnValues = []
        spyFn.hasMockedReturn = false
        spyFn.mockReturn = undefined
      }

      // mock the return value
      spyFn.setMockReturn = function (mockedReturn) {
        spyFn.mockReturn = mockedReturn
        spyFn.hasMockedReturn = true
      }

      spyFn.clearMockReturn = function () {
        spyFn.mockReturn = undefined
        spyFn.hasMockedReturn = false
      }
    }

    return functionReturn
  }
  spyFn.prototype = fn.prototype
  Object.defineProperty(spyFn, "name", { value: fn.name })

  // create properties to collect data
  spyFn.hasBeencalled = false
  spyFn.timesCalled = 0
  spyFn.allArguments = []
  spyFn.allReturnValues = []

  // create properties to modify spy's behaviour
  spyFn.frozen = false
  spyFn.hasMockedReturn = false
  spyFn.mockReturn = undefined

  return spyFn
}

To save the arguments used on our spy in a sane format it is practical to turn them into an array (the arguments object looks like this: { '0': 'Foo', '1': 'Bar' }). Therefore allArguments will take the shape of an array of arrays and through its index we will be able find all used arguments for a specific time the function was called.

Saving the delivered return value in allReturnValues seems to be the implementation with the least surprises for users of the spy. It could be interesting to implement an additional attribute allOriginalReturnValues in which we keep track uninfluenced by the mock return.

There are probably many more methods we could add to our implementation. clear and freeze are the bare minimum and should always exist. There are a lot of frameworks with additional methods like reset and restore which would allow to adjust the resetting of the spy in a more gradual way. In Jest for example clear only clears the data, reset also gets rid of a set mockReturn and restore turns the spy into the original function.

Conclusion

Even though some of JS’ limitations drove me crazy, I found it very helpful and interesting to implement a spy function from scratch. Some concepts needed to build it aren’t so common which makes it a great opportunity to practice and deepen your knowledge.

References