Using Spies for Testing in JavaScript with Sinon.js

Introduction

In software testing, a "spy" records how a function is used when it is tested. This includes how many times it was called, whether it was called with the correct arguments, and what was returned.

While tests are primarily used to validate the output of a function, sometimes we need to validate how a function interacts with other parts of the code.

In this article, we'll have a deeper look into what spies are and when they should be used. We will then spy on a HTTP request while using Sinon.js in a JavaScript unit test.

This article is the second of a series about testing techniques with Sinon.js. We recommend you read our previous article as well:

What are Spies?

A spy is an object in testing that tracks calls made to a method. By tracking its calls, we can verify that it is being used in the way our function is expected to use it.

True to its name, a spy gives us details about how a function is used. How many times was it called? What arguments were passed into the function?

Let's consider a function that checks if a user exists, and creates one in our database if it doesn't. We may stub the database responses and get the correct user data in our test. But how do we know that the function is actually creating a user in cases we don't have pre-existing user data? With a spy, we will observe how many times the create-user function is called and be certain.

Now that we know what a spy is, let's think about the situations where we should use them.

Why Use Spies?

Spies excel in giving insight into the behavior of the function we are testing. While validating the inputs and outputs of a test is crucial, examining how the function behaves can be crucial in many scenarios:

When your function has side effects that are not reflected in its results, you should spy on the methods it uses.

An example would be a function that returns JSON to a user after making many calls to various external APIs. The final JSON payload does not tell the user how the function retrieves all its data. A spy that monitors how many times it called the external APIs and what inputs it used in those calls would tell us how.

Let's look at how we can use Sinon.js to create spies in our code.

Using Sinon.Js to Create a Spy

There are multiple ways to create a spy with Sinon.js, each with their advantages and disadvantages. This tutorial will focus on the following two methods, which target spies on a single function at a time:

  1. An anonymous function that tracks arguments, values, and calls made to a method.
  2. A wrapper to an existing function.

First, let's set up our project so we can run our test files and use Sinon.js.

Setup

Let's begin by creating a folder to store our JavaScript code. Create a new folder and move into it:

$ mkdir SpyTests
$ cd SpyTests

Initialize NPM so you can keep track of the packages you install:

$ npm init -y

Now let's install our testing dependencies. We install Mocha and Chai to run our tests, along with Sinon.js:

$ npm i mocha chai sinon --save-dev

Our setup is complete! Let's begin by using spies as anonymous functions.

Spies with Anonymous Functions

As anonymous functions, Sinon.js spies are often useful in cases where we want to test higher-order functions that take other functions, i.e callbacks as arguments. Let's look at a basic example that re-implements the Array.prototype.map() with a callback:

Create two files i.e mapOperations.js and mapOperations.test.js inside the spyTests directory as follows:

$ touch mapOperations.js mapOperations.test.js

Enter the following code in the mapOperations.js file:

const map = (array, operation) => {
    let arrayOfMappedItems = [];
    for (let item of array) {
        arrayOfMappedItems.push(operation(item));
    }
    return arrayOfMappedItems;
};

console.log(map([{ name: 'john', role: 'author'}, { name: 'jane', role: 'owner'}], user => user.name));

module.exports = { map };

In the code above, map() takes an array as its first argument and a callback function, operation(), that transforms the array items as its second argument.

Inside the map() function, we iterate through the array and apply the operation on each array item, then push the result to the arrayOfMappedItems array.

When you run this example on the console, you should get the following result:

$ node mapOperations.js
[ 'john', 'jane' ]

To test whether the operation() function was called by our map() function, we can create and pass an anonymous spy to the map() function as follows:

const { map } = require('./mapOperations');
const sinon = require('sinon');
const expect = require('chai').expect;

describe('test map', () => {
    const operation = sinon.spy();

    it('calls operation', () => {
        map([{ name: 'foo', role: 'author'}, { name: 'bar', role: 'owner'}], operation);
        expect(operation.called);
    });
});

While our callback does not actually transform the array, our spy can verify that the function we are testing actually uses it. This is confirmed when expect(operation.called); does not fail the test.

Let's see if our test passes! Run the test, you should get the following output:

$ mocha mapOperations.test.js

  test map

    ✓ calls operation


  1 passing (4ms)

✨  Done in 0.58s.

It works! We are now sure that our function will use whatever callback we put in its arguments. Let's now see how we can wrap a function or method using a spy.

Spies as Function or Method Wrappers

In the previous article, we saw how we can stub an HTTP request in our unit tests. We will use the same code to show how we can use Sinon.js to spy on an HTTP request.

In a new file called index.js, add the following code:

const request = require('request');

module.exports = {
    getAlbumById: async function(id) {
        const requestUrl = `https://jsonplaceholder.typicode.com/albums/${id}/photos?_limit=3`;
        return new Promise((resolve, reject) => {
            request.get(requestUrl, (err, res, body) => {
                if (err) {
                    return reject(err);
                }
                resolve(JSON.parse(body));
            });
        });
    }
};

To recap, the getAlbumById() method calls a JSON API that fetches a list of photos from an album whose ID we pass as a parameter. Previously, we stubbed the request.get() method to return a fixed list of photos.

This time, we will spy on the request.get() method so we can verify that our function makes an HTTP request to the API. We'll also check that it made the request once, which is good as we don't want a bug that spams the API's endpoint.

In a new test file called index.test.js, write the following JavaScript code line by line:

const expect = require('chai').expect;
const request = require('request');
const sinon = require('sinon');
const index = require('./index');

describe('test getPhotosByAlbumId', () => {
    let requestSpy;
    before(() => {
        requestSpy = sinon.spy(request, 'get');
    });

    after(() => {
        request.get.restore();
    });

    it('should getPhotosByAlbumId', (done) => {
        index.getAlbumById(2).then((photos) => {
            expect(requestSpy.calledOnce);
            expect(requestSpy.args[0][0]).to.equal("https://jsonplaceholder.typicode.com/albums/2/photos?_limit=3");
            photos.forEach(photo => {
                expect(photo).to.have.property('id');
                expect(photo).to.have.property('title');
                expect(photo).to.have.property('url');
            });
            done();
        });
    });
});

In the above test, we wrapped the request.get() method with a spy during setup in the before() function. We restore the function when we teardown the test in the after() function.

In the test case, we made the assertion that requestSpy, the object that tracks request.get()'s usage, only records one call. We then go deeper to confirm that its first argument of the request.get() call is the URL of the JSON API. We then made assertions to ensure that photos returned have the expected properties.

When you run the test, you should get the following output:

$ mocha index.test.js


  test getPhotosByAlbumId
    ✓ should getPhotosByAlbumId (570ms)


  1 passing (587ms)

✨  Done in 2.53s.

Please note that this test made an actual network request to the API. The spy wraps around the function, it does not replace its functionality!

Also, Sinon.js test stubs are already spies! If you ever create a test stub, you will be able to see how many time it was called and the arguments that were passed to the function.

Conclusion

A spy in testing gives us a way of tracking calls made to a method so that we can verify that it works as expected. We use spies to check whether a method was called or not called, how many times it was called, with what arguments it was called, and also the value it returned when called.

In this article, we introduced the concept of spies and saw how we can use Sinon.js to create spies. We also looked at how we can create spies as anonymous functions, and how we can use them to wrap methods. For more advanced use cases, Sinon.js provides a rich spy API which we can leverage. For more details, the documentation can be accessed here.

Author image
About Allan Mogusu
Nairobi, Kenya Twitter