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:
this
value on each invocation.We can also provide an implementation to override the original function behavior. And we can describe specific return values to suit our tests.
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:
1function greet(name) {
2 return `Hello ${name}!`;
3}
4
5// Functions can be assigned to variables:
6const other = fn;
7
8// `other` and `fn` referr to the same function object:
9other === fn; // true
10
11// Can be passed as argument values:
12
13function greetWorld(greettingFn) {
14 return greetingFn('world');
15}
16
17greetWorld(greet); // Hello world!
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.
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.
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:
1function greetWorld(greettingFn) {
2 return greetingFn('world');
3}
4
5test('greetWorld calls the greeting function properly', () => {
6 const greetImplementation = name => `Hey, ${name}!`;
7 const mockFn = jest.fn(greetImplementation);
8 const value = greetWorld(mockFn);
9 expect(mockFn).toHaveBeenCalled();
10 expect(mockFn).toHaveBeenCalledWith('world');
11 expect(value).toBe('Hey, world!');
12});
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.
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:
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
).
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.
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// 1. The mock function factory
2function fn(impl) {
3 // 2. The mock function
4 const mockFn = function(...args) {
5 // 4. Store the arguments used
6 mockFn.mock.calls.push(args);
7 return impl(...args); // call the implementation
8 };
9 // 3. Mock state
10 mockFn.mock = {
11 calls: []
12 };
13 return mockFn;
14}
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.
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:
instances
and results
arrays to our mock state object:1 // 3. Mock state
2 mockFn.mock = {
3 calls: [],
4 instances: [],
5 results: [],
6 };
this
value:1//...
2 const mockFn = function(...args) {
3 // 4. Store the arguments used
4 mockFn.mock.calls.push(args);
5 mockFn.mock.instances.push(this);
6 return impl.apply(this, args); // call impl, passing the right this
7 };
8//...
The Function.prototype.apply
method allows us to set the this
value and apply the arguments array.
try-catch
statement to know if it throws:1//...
2 const mockFn = function(...args) {
3 // 4. Store the arguments used
4 mockFn.mock.calls.push(args);
5 mockFn.mock.instances.push(this);
6 try {
7 const value = impl.apply(this, args); // call impl, passing the right this
8 mockFn.mock.results.push({ type: 'return', value });
9 return value;
10 } catch (value) {
11 mockFn.mock.results.push({ type: 'throw', value });
12 throw value; // re-throw error
13 }
14 };
15//...
Here is the example implementation in 20 lines:
1// 1. The mock function factory
2function fn(impl = () => {}) {
3 // 2. The mock function
4 const mockFn = function(...args) {
5 // 4. Store the arguments used
6 mockFn.mock.calls.push(args);
7 mockFn.mock.instances.push(this);
8 try {
9 const value = impl.apply(this, args); // call impl, passing the right this
10 mockFn.mock.results.push({ type: 'return', value });
11 return value; // return the value
12 } catch (value) {
13 mockFn.mock.results.push({ type: 'throw', value });
14 throw value; // re-throw the error
15 }
16 }
17 // 3. Mock state
18 mockFn.mock = { calls: [], instances: [], results: [] };
19 return mockFn;
20}
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.