How to set up Mocha with Sinon.js

Step by step guide to using Sinon.js mocking library with Mocha.js test runner

Let's say we have a small Node program and want to confirm it logs "Hello" string to the console

app.js
1
2
3
4
5
const app = () => console.log('Hello')
module.exports = { app }
if (!module.parent) {
app()
}
1
2
3
4
$ npm start
> node ./app

Hello

We need a test runner to write a test and an ability to spy console.log method. We could write both the test runner and method spying ourselves, but there are already 2 great tools for this: Mocha.js and Sinon.js. Let's use them.

Note: you can find the source code for this blog post in mocha-sinon-example repository.

Basic setup

First, install both NPM dependencies

1
2
3
4
$ yarn add -D mocha sinon
info Direct dependencies
├─ [email protected]
└─ [email protected]

Next, write a spec file test/app-spec.js that will import app, set up spying and then confirm the spy was called with expected argument

test/app-spec.js
1
2
3
4
5
6
7
8
9
const sinon = require('sinon')
const { app } = require('../app')
it('logs Hello', () => {
const log = sinon.spy(console, 'log')
app()
if (!log.calledOnceWith('Hello')) {
throw new Error('Log was not called')
}
})

To run unit tests, I like using NPM script command npm test

1
2
3
4
5
6
{
"scripts": {
"test": "mocha 'test/*-spec.js'",
"start": "node ./app"
}
}

The test passes

1
2
3
4
5
6
7
8
9
$ npm t

> [email protected] test /Users/gleb/git/mocha-sinon-example
> mocha 'test/*-spec.js'

Hello
✓ logs Hello

1 passing (5ms)

Tip: whenever there is new code pulled for the project, I like using shortcut npm it to run npm install + npm test together. Even better, I could use shortcut npm cit that runs npm ci + npm test together.

Sandbox

We have set up a Sinon spy on the global console.log - but we have not cleared it. This could lead to unexpected behavior in the unit tests that follow. For example if the next test runs by itself it works

1
2
3
4
5
6
7
it('logs Hello', () => {
const log = sinon.spy(console, 'log')
...
})
it.only('logs again', () => {
const log = sinon.spy(console, 'log')
})

But when the two tests run together the second attempt to spy causes an error.

1
2
1) logs again:
TypeError: Attempted to wrap log which is already wrapped

We really don't want the two tests to be dependent on each other. Thus I strongly recommend using Sinon sandboxes. We could create the sandbox once and reset the sandbox before each test in a single call.

test/app-spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const sinon = require('sinon')
const { app } = require('../app')

let sandbox
before(() => {
sandbox = sinon.createSandbox()
})
beforeEach(() => {
sandbox.restore()
})

it('logs Hello', () => {
const log = sandbox.spy(console, 'log')
app()
if (!log.calledOnceWith('Hello')) {
throw new Error('Log was not called')
}
})

it('logs again', () => {
const log = sandbox.spy(console, 'log')
// no problems
})

Global sandbox

Rather than each spec file creating a sandbox (and restoring it), we could move the before and beforeEach hooks into its own test helper file, creating root-level hooks.

test/helper.js
1
2
3
4
5
6
7
const sinon = require('sinon')
before(() => {
global.sandbox = sinon.createSandbox()
})
beforeEach(() => {
global.sandbox.restore()
})
test/app-spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const { app } = require('../app')

it('logs Hello', () => {
const log = sandbox.spy(console, 'log')
app()
if (!log.calledOnceWith('Hello')) {
throw new Error('Log was not called')
}
})

it('logs again', () => {
const log = sandbox.spy(console, 'log')
// no problems
})

I change the NPM test script to load the helper file

1
2
3
4
5
{
"scripts": {
"test": "mocha test/helper 'test/*-spec.js'"
}
}

CI setup

Let's run these tests on CI - I will use GitHub Actions because they are awesome. I have added .github/workflows/test.yml file.

1
2
3
4
5
6
7
8
9
name: main
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: bahmutov/npm-install@v1
- run: npm t

The code is checked out, bahmutov/npm-install action runs yarn with caching of NPM dependencies, and then npm t runs unit tests.

GitHub Action output

Assertions

In the above example, we used a plain Error in case the spy was not called as expected.

1
2
3
4
5
6
7
it('logs Hello', () => {
const log = sandbox.spy(console, 'log')
app()
if (!log.calledOnceWith('Hello')) {
throw new Error('Log was not called')
}
})

Sinon.js comes with assert methods that can help produce better error messages with more context. For example, if we expect the spy to have been called with "Bye" for some reason, the error message prints the actual calls

1
2
3
4
5
it('logs again', () => {
const log = sandbox.spy(console, 'log')
app()
require('sinon').assert.calledWith(log, 'Bye')
})

Sinon.assert error message

Even better is to bring in Chai assertions with Sinon.js Chai plugin.

1
2
3
4
$ yarn add -D chai sinon-chai
info Direct dependencies
├─ [email protected]
└─ [email protected]

Then add to the test/helper.js file the following

test/helper.js
1
2
3
4
5
const chai = require('chai')
const sinonChai = require('sinon-chai')
chai.use(sinonChai)
global.expect = chai.expect
// global Sinon sandbox code ...

Now we can use easy to read assertions inside the specs

test/app-spec.js
1
2
3
4
5
it('logs again', () => {
const log = sandbox.spy(console, 'log')
app()
expect(log).to.have.been.calledOnceWith('Hello')
})

Matches

If you don't know the exact argument a stub or a spy should be called, you can use sinon.match utils. For example, if the code calls the method with o.method('hello', name) then you can assert it with

1
2
3
const sinon = require('sinon')
const greeting = sinon.spy(o, 'method')
expect(greeting).to.have.been.calledOnceWithExactly('hello', sinon.match.string)

See also