Unit Testing using Mocha, Sinon, and Chai in NodeJS

Unit Testing using Mocha, Sinon, and Chai in NodeJS

We use Mocha as the test framework, Chai for assertions, and Sinon.js for creating Doubles. Here's the code for installation:

$ npm install sinon mocha chai --save-dev

--save-dev is used because these modules are only needed during development. We don't need these modules during production.

The Script

The main thing to do when creating a script is to determine which function to test, along with what will be tested in that function.

For example, if we want to do unit testing on the following function:

/*number-lib.js*/

function addInteger(x, y) {
    if (Number.isInteger(x) && Number.isInteger(y)) {
        return x + y   
    }
    else {
        return 'Number is not integer'
    }
    return 
}
module.exports = {
    addInteger
}

The function above is a function to add integers x and y. If they are not integers, it will return a string saying that the input is not an integer.

First, we will determine our test scope as follows:

// number-list-test.spec.js
const numberLib = require('./number-lib.js')

describe('Add integer', () => {
    it('should return 23', () => {
       // Arrange
       ...
       // Act
       ...
       // Assert
       ...
    })
    it('should return "Number is not integer", because input is not integer', () => {
       // Arrange
       ...
       // Act
       ...
       // Assert
    })
})

describe() and it() are functions of Mocha, where we will write our test script. In the describe() function, we determine our test scope. Inside each it() function, we determine the cases that we will perform.

In the case above, our scope is to test the addInteger() function with 2 agreed-upon cases, namely the function must return 23, and the function must return "Number is not integer" because the input we will give is not an integer.

In the it() function, there is an Arrange, Act, and Assert section. Here is an explanation:

  1. Arrange

In the Arrange section, it usually contains input initialization and doubles initialization which aims to produce the desired output.

  1. Act

In the Act section, we execute the function that we want to test using the input that we have initialized in the Arrange section.

  1. Assert

In the Assert section, we check whether the result produced by the function in the Act section matches the expected output that we have initialized in the Arrange section.

Now we will add the code snippet above to make it complete.

// number-list-test.spec.js
const numberLib = require('./number-lib.js')
const expect = require('chai').expect

describe('Add integer', () => {
    it('should return 23', () => {
       // Arrange
       let x = 11
       let y = 12
       // Act
       let result = numberLib.addInteger(x, y)
       // Assert
       expect(result).to.equal(23)
    })
    it('should return "Number is not integer", because input is not integer', () => {
       // Arrange
       let x = 1.1
       let y = 1.2
       // Act
       let result = numberLib.addInteger(x, y)
       // Assert
       expect(result).to.equal('Number is not integer')
    })
})

Doubles

For example, let's update the function we want to test like this:

function addNumber(x, y) {
    if(isInteger(x) && isInteger(y))) {
        return x + y
    }
    else if(isDecimal(x) && isDecimal(y))) {
        return Math.round(x) + Math.round(y)
    }
    else {
        return 'The input is not integer nor decimal value'
    }
}

function isInteger(x) {
    ...
}

function isDecimal(x) {
    ...
}

modules.export = {
    addInteger
}

Correct. There is a function inside a function. The concept of Unit Testing is that we only test the function that we want to test. If the function calls another function, then we ignore the other function by making it a Double.

In our example, we will not care about the isInteger() and isDecimal() functions. Both of these functions will be made into doubles with the help of sinon.

In that case, it was agreed that there are 3 cases to be tested. Here are the tests:

const sinon = require('sinon')
const expect = require('chai').expect
const numberLib = require('./number-lib')

describe('Add Number', () => {
    it('should return 40', () => {
    })
    it('the input is decimal, should return 15', () => {
    })
    it('should return "The input is not integer nor decimal value"', () => {
    })
})

We have determined the cases, now let's add the test script:

const sinon = require('sinon')
const expect = require('chai').expect
const numberLib = require('./number-lib')

describe('Add Number', () => {
    it('should return 40', () => {
        //Create new stub for isInteger that always return true
        let isInteger = sinon.stub(numberLib, 'isInteger').returns(true)
        let x = 20
        let y = 20
        let result = numberLib(x, y)
        expect(result).to.equal(40)
        isInteger.restore()
    })
    it('the input is decimal, should return 15', () => {
        //Create new stub for isInteger that always return false
        //stub for isDecimal always return true
        let isInteger = sinon.stub(numberLib, 'isInteger').return(false)
        let isDecimal = sinon.stub(numberLib, 'isDecimal').returns(true)
        let x = 7.4
        let y = 7.6
        let result = numberLib(x, y)
        expect(result).to.equal(15)
        isInteger.restore()
        isDecimal.restore()
    })
    it('should return "The input is not integer nor decimal value"', () => {
        //Create new stub for isInteger that always return false
        //stub for isDecimal always return false
        let isInteger = sinon.stub(numberLib, 'isInteger').return(false)
        let isDecimal = sinon.stub(numberLib, 'isDecimal').returns(false)
        let x = 'anything'
        let y = 'anything'
        let result = numberLib(x, y)
        expect(result).to.equal('The input is not integer nor decimal value')
        isInteger.restore()
        isDecimal.restore()
    })
})

At the beginning of each case, we always initialize the stub for the isInteger() and isDecimal() functions, by changing its return value according to the needs of the test case. When numberLib() is called, if a stub function we made before is encountered during execution, that function will be changed to a stub.

At the end of each case, we must restore() the stubs we made so that the functions that became stubs can run their own content again. If we don't do a restore(), there is a possibility that the next test will fail because these functions are still stubs.

The above test script can still be improved with the following Mocha functions:

...
const numberLib = require('./number-lib')
describe('Add number', () => {
    let isInteger
    let isDecimal
    beforeEach(() => {
        isInteger = sinon.stub(numberLib, 'isInteger')
        isDecimal = sinon.stub(numberLib, 'isDecimal')
    })
    afterEach(() => {
        isInteger.restore()
        isDecimal.restore()
    })
    it('...', () => {
        isInteger.returns(true)
        ...
    })
    it('...', () => {
        ...
    })
    it('...', () => {
        ...
    })
})

beforeEach() is always called before each test case is executed, while afterEach() is always called after each test case is executed. With the help of beforeEach() and afterEach() we can avoid repetitive initialization code and make our test script more readable.

Anonymous function cases

Sometimes we encounter something like this:

//my-route.js
router.get('/', (req, res) => {
    //function that we want to test
    ...
    ...
})
router.get('/user', (req, res) => {
    //other function
    ...
    ...
})
module.exports = router

The code above is one of the examples to create a router on express.js.

How do we test that function, when that function can't be called from another function or so it is called Anonymous Function? We can test it with Sinon's help.

First, create the test script:

const route = ('./my-route')

describe('Route test', () => {
    it('should ...', () =>{

    })
})

We can see from the function, router call get function that has 2 parameters. First parameter is the endpoint of the route, and the second is the function that we want to test. So we need to create spy on get function.

const route = require(./my-route)
const sinon = require('sinon')

describe('Route test', () => {
    it('should ...', () => {
      //create spy on get
      let get = sinon.spy(route, 'get')
      // Arrange input 
      let req = {}
      let res = {}
      //call the function
      route.router()
      ..
    })
}

When route.router() called, all function (router.get('/') dan router.get('/user')) will be called. Because we only want to test get('/'), the test code become like this:

const route = require(./my-route)
const sinon = require('sinon')

describe('Route test', () => {
    it('should ...', () => {
      let get = sinon.spy(route, 'get')

      let req = {}
      let res = {}

      route.router()
      //Calling the `first` get and then trigger anonymous function on the parameter no 1
      get.firstCall.callArgWith(1, req, res)

      expect(...)
      get.restore()
    })
}

get.firstCall will get only the function that is called first.

get.firstCall.callArgwith(1, req, res) will be triggering the function on second parameter (Zero based). And then, we succesfully call the anonymous function, asserting could be done.

Running

To run the test script, we need some modifications on package.json

// package.json
"scripts": {  
    "test": "mocha test/**/*.spec.js"
}

The goal is to running mocha command on all files in all folders that having .spec.js extension. So we need to name our test function with .spec.js

Run the script:

npm test

We can see the results in terminal:

> mocha test/**/*.spec.js

  Add Integer
    ✓ should return 23
    ✓ should return "Number is not integer", because input is not integer

 2 passing (1s)

Reporting

We need to module for this. Mocha-junit-reporter for report unit-test, and nyc for code coverage

$ npm install mocha-junit-reporter nyc --save-dev

Modify package.json on scripts:

"scripts": {
    "report": "mocha test/**/*.spec.js --reporter mocha-junit-reporter",
    "coverage": "nyc --reporter=lcov --reporter=text-lcov npm test"
},

Run it:

$ npm run report
$ npm run coverage

We will get .xml that we can show on any other application or tool like Jenkins

References

https://martinfowler.com/articles/mocksArentStubs.html

https://www.sitepoint.com/sinon-tutorial-javascript-testing-mocks-spies-stubs/