Mock Functions or Spies Demystified - How Does jest.fn() Work?
Aug 14, 2019 • 11 Minute Read
Introduction
Mock functions, also known as spies, are special functions that allow us to track how a particular function is called by external code. Instead of just testing the output of the function, we can gain additional information about how a function was used.
By using mock functions, we can know the following:
- The number of calls it received.
- Argument values used on each invocation.
- The “context” or this value on each invocation.
- How the function exited and what values were produced.
We can also provide an implementation to override the original function behavior. And we can describe specific return values to suit our tests.
Functions Are First-class Citizens
In JavaScript, functions can be treated just like any value. You can pass them as arguments to other functions, they can be assigned as properties of objects (as methods), or you can return other functions from them. Internally, functions are just special objects that can you can invoke.
Let's see some examples:
function greet(name) {
return `Hello ${name}!`;
}
// Functions can be assigned to variables:
const other = fn;
// `other` and `fn` referr to the same function object:
other === fn; // true
// Can be passed as argument values:
function greetWorld(greettingFn) {
return greetingFn('world');
}
greetWorld(greet); // Hello world!
Higher-order Functions
Higher-order functions are functions that operate on other functions. Either by receiving them as arguments or returning them as values. In the previous example, we can say that greetWorld is a higher-order function, because it expects a function as an input argument.
In JavaScript itself, there are a lot of places where we have higher-order functions. The Array.prototype methods are great examples. They receive a callback function that is invoked on all the elements of the array object.
When to Use Mock Functions
We can use mock functions when we want to replace a specific function return value. Or when we want to check if our test subject is executing a function in a certain way. We can mock a standalone function or an external module method, and we can provide a specific implementation. For example, let's say you are testing a business logic module that uses another module to make requests to an external API. You can then mock the functions of the dependency to avoid hitting the API on your tests. You can then run your tests, knowing what the mocked functions will return to your test subject.
The fact that we can provide an implementation to external dependencies is useful because it allows us to isolate our test subject. We can focus on it. The unit tests would focus entirely on the business logic, without needing to care about the external API.
Also, when we implement higher-order functions, we can test how the test subject uses other functions. We pass a mock to the function we want to test, and we can verify how it was used.
How to Use jest.fn
There are several ways to create mock functions. The jest.fn method allows us to create a new mock function directly. If you are mocking an object method, you can use jest.spyOn. And if you want to mock a whole module, you can use jest.mock.
In this guide, we will focus on the jest.fn method, the simplest way to create a mock function. This method can receive an optional function implementation, which will be executed transparently. It means that running the mock will work just as if you were invoking the original function implementation. Internally jest.fn will track all the calls and will perform the execution of the implementation function itself.
For example, if we would like to test how greetWorld uses the greeting function, we can pass a mock function:
function greetWorld(greettingFn) {
return greetingFn('world');
}
test('greetWorld calls the greeting function properly', () => {
const greetImplementation = name => `Hey, ${name}!`;
const mockFn = jest.fn(greetImplementation);
const value = greetWorld(mockFn);
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledWith('world');
expect(value).toBe('Hey, world!');
});
In this test, we are passing a mock function to the greetWorld function. This mock function has an implementation, which is called internally. The act of passing a mock function to greetWorld allows us to spy on how it uses the function. We expect to have the function to be called one time with the 'world' string as the first argument.
How Does It Work?
The jest.fn method is, by itself, a higher-order function. It's a factory method that creates new, unused mock functions. Also, as we discussed previously, functions in JavaScript are first-class citizens. Each mock function has some special properties. The mock property is fundamental. This property is an object that has all the mock state information about how the function was invoked. This object contains three array properties:
- Calls
- Instances
- Results
In the calls property, it will store the arguments used on each call. The instances property will contain the this value used on each invocation. And the results array will store how and with which value the function exited each invocation.
There are three ways a function can complete:
-
The function explicitly returns a value.
-
The function runs to completion with no return statement (which is equivalent to returning undefined).
-
The function throws an error.
In the results property, Jest stores each result of the function as objects that have two properties: type and value. Type can be either 'return' or 'throw'. The value property will contain the return value or the error thrown. If we test the result from within the mock implementation itself, the type will be 'incomplete' since the function is currently running.
Jest provides a set of custom matchers to check expectations about how the function was called:
- expect(fn).toBeCalled()
- expect(fn).toBeCalledTimes(n)
- expect(fn).toBeCalledWith(arg1, arg2, ...)
- expect(fn).lastCalledWith(arg1, arg2, ...)
They are just syntax sugar to inspect the mock property directly.
Implementing Our Mock Function
There's no better way to understand something than by implementing it ourselves. Let's start with a simple mock function, only tracking the arguments used on each call:
// 1. The mock function factory
function fn(impl) {
// 2. The mock function
const mockFn = function(...args) {
// 4. Store the arguments used
mockFn.mock.calls.push(args);
return impl(...args); // call the implementation
};
// 3. Mock state
mockFn.mock = {
calls: []
};
return mockFn;
}
This first version is pretty straight-forward, let's break it up:
-
We declare fn, this will be our mock function factory, just as jest.fn. It accepts an implementation function as an argument.
-
Inside fn, we define mockFn, this is the function that we will return.
-
We assign a mock property in the function object. Remember, functions are just special, callable objects, we can assign properties to them.
-
Before calling the implementation, we record the arguments used in the function call.
Note that in our mockFn, we receive the arguments as an array, by using the ES6 rest parameter syntax.
The mock property of mockFn, in this first implementation, it has only one property, calls.
If we want to implement the other two features, to track the this value of each invocation and the results of the function, we need to change a couple of things:
- We need to add the instances and results arrays to our mock state object:
// 3. Mock state
mockFn.mock = {
calls: [],
instances: [],
results: [],
};
- We need to change how we call the mock implementation, to pass the right this value:
//...
const mockFn = function(...args) {
// 4. Store the arguments used
mockFn.mock.calls.push(args);
mockFn.mock.instances.push(this);
return impl.apply(this, args); // call impl, passing the right this
};
//...
The Function.prototype.apply method allows us to set the this value and apply the arguments array.
- We need to wrap the implementation function call in a try-catch statement to know if it throws:
//...
const mockFn = function(...args) {
// 4. Store the arguments used
mockFn.mock.calls.push(args);
mockFn.mock.instances.push(this);
try {
const value = impl.apply(this, args); // call impl, passing the right this
mockFn.mock.results.push({ type: 'return', value });
return value;
} catch (value) {
mockFn.mock.results.push({ type: 'throw', value });
throw value; // re-throw error
}
};
//...
Here is the example implementation in 20 lines:
// 1. The mock function factory
function fn(impl = () => {}) {
// 2. The mock function
const mockFn = function(...args) {
// 4. Store the arguments used
mockFn.mock.calls.push(args);
mockFn.mock.instances.push(this);
try {
const value = impl.apply(this, args); // call impl, passing the right this
mockFn.mock.results.push({ type: 'return', value });
return value; // return the value
} catch (value) {
mockFn.mock.results.push({ type: 'throw', value });
throw value; // re-throw the error
}
}
// 3. Mock state
mockFn.mock = { calls: [], instances: [], results: [] };
return mockFn;
}
Conclusion
Mock functions allow us to produce unit tests that are focused, reproducible, and independent of external factors. Often, our test subject depends on other modules. Mocks provide a convenient way to replace the implementation details of the dependencies. We can replace complex dependencies with objects composed of mock functions that simulate the behavior of the real-life objects predictable. Doing this allows us to isolate the behavior of the test subject.
The exercise of implementing a small, simplified version of a mock function gives us a clearer picture of how mock functions work internally. Having a more in-depth understanding of how things work is useful, it allows us to have a better knowledge of how features that seem complex or magical work. Keep in mind, the native jest mock functions provide much more functionality.
You can see the source of this mock implementation here and a full set of unit tests verifying all the behavior we described here.