DEV Community

Cover image for Converting your Unit and End-To-End Test Suites from Mocha, Chai, and Sinon to Jest in Nodejs.
Steven Victor
Steven Victor

Posted on • Edited on

Converting your Unit and End-To-End Test Suites from Mocha, Chai, and Sinon to Jest in Nodejs.

I started on a project recently and Jest is a requirement for testing. Making the switch from what I am already used to(mocha, chai, and sinon) is not difficult though, I wish to explain in this article some of the differences I observed using code samples.

Mocha

Mocha is a feature-rich JavaScript test framework running on Node.js and in the browser, making asynchronous testing simple and fun. Mocha tests run serially, allowing for flexible and accurate reporting while mapping uncaught exceptions to the correct test cases. In other words, mocha is a javascript test framework.

Chai

Chai is a BDD / TDD assertion library for node and the browser that can be delightfully paired with any javascript testing framework.

Sinon

Sinon provides standalone test spies, stubs and mocks for JavaScript.

Jest

Jest is a delightful JavaScript Testing Framework with a focus on simplicity.

Mocha or Jest?

Both Mocha and Jest are both javascript test frameworks(test runners).
A vivid comparison between both mocha and jest is found here.

Jest comes with built-in mocking and assertion abilities. In addition, Jest runs your tests concurrently in parallel, providing a smoother, faster test run. There’s no upfront configuration that you have to do. You just install it through npm or yarn, write your test, and run jest. Get the full details here.

Mocha provides developers with a base test framework, allowing you to have options as to which assertion, mocking, and spy libraries you want to use.
This does require some additional setup and configuration, which is a downside. However, if having complete control of your testing framework is something you want, Mocha is by far the most configurable and best choice. Get the full details here.

What we could deduce from the above explanation is that when using Jest, you have most of the tools that are needed both for your unit and end to end tests, such as assertion and mocking abilities, while when using Mocha, you will need to require external libraries for assertion and mocking. So, Chai can be used for assertions while Sinon can be used for mocking.

I have no problem using Jest alone or using Mocha along with Chai and Sinon. My use case is wholely dependent on the project requirement.

The project

I built a Mock Premier League Fixture API so as to demonstrate how you can use either jest or mocha. You can check out the code on github.
Jest is used in the master branch, while Mocha/Chai/Sinon are used in the mocha-chai-sinon branch.

Get the full code:
Using Jest here.
Using mocha here.

Test Setup

An in-memory database is used for the unit tests while a real test database is used for the end-to-end tests. Mongodb is used as the database in this project.

Jest Setup

This is only for jest use case.
First, install jest and @shelf/jest-mongodb and supertest(used for end-to-end tests)

npm install --save-dev jest supertest @shelf/jest-mongodb 
Enter fullscreen mode Exit fullscreen mode

Then we create a jest.config.js file in the root directory and specify the preset.

module.exports = {
  preset: '@shelf/jest-mongodb',
};
Enter fullscreen mode Exit fullscreen mode

Next we, create jest-mongodb-config.js file which is used to configure our in-memory db for unit tests:

module.exports = {
  mongodbMemoryServerOptions: {
    instance: {
      dbName: 'jest'
    },
    binary: {
      version: '4.0.2', // Version of MongoDB
      skipMD5: true
    },
    autoStart: false
  }
};
Enter fullscreen mode Exit fullscreen mode

We then need to setup database and seed data. Create the test-setup directory and the db-config.js and seed.js files

The db-config.js file looks like this:


import mongoose from 'mongoose'


//in-memory db used only in unit testing
export const connect = async () => {
  const mongooseOpts = {
    useNewUrlParser: true,
    autoReconnect: true,
    reconnectTries: Number.MAX_VALUE,
    reconnectInterval: 1000
  };
  await mongoose.connect(global.__MONGO_URI__, mongooseOpts)
};

//Drop database, close the connection. 
//Used by both unit and e2e tests
export const closeDatabase = async () => {
    await mongoose.connection.dropDatabase();
    await mongoose.connection.close();
};


//Remove all the data for all db collections. 
//Used by both unit and e2e tests
export const clearDatabase = async () => {
  const collections = mongoose.connection.collections;
  for (const key in collections) {
      const collection = collections[key];
      await collection.deleteMany();
  }
};

Enter fullscreen mode Exit fullscreen mode

The file above is self-explanatory. You can checkout out the seed.js file in the repo

The last setup using jest is to specify the script to run in the package.json file:

    "test": "cross-env NODE_ENV=test jest --runInBand  --testTimeout=20000"
Enter fullscreen mode Exit fullscreen mode

cross-env enable us run scripts that set and use environment variables across platforms. As seen above, it enabled us set our environment to test. Install using:

npm install cross-env
Enter fullscreen mode Exit fullscreen mode

To disable concurrency (parallel execution) in Jest, we specify the runInBand flag so as to make Jest run tests sequentially.
We then specified a timeout of 20 seconds (20000ms).

Specify a key in the package.json file to tell jest about the test environment, files to ignore while testing and that test output should be in verbose.

"jest": {
    "testEnvironment": "node",
    "coveragePathIgnorePatterns": [
      "/node_modules/",
      "/dist/"
    ],
    "verbose": true
  },
Enter fullscreen mode Exit fullscreen mode

Mocha, Chai and Sinon Setup

This is for Mocha, Chai, and Sinon users.
First, install mocha, chai and sinon, and their extensions that will be used in the unit and end-to-end tests

npm install --save-dev mocha chai chai-as-promised chai-http sinon @sinonjs/referee-sinon sinon-chai  
Enter fullscreen mode Exit fullscreen mode

For unit testing, we will need to install a mongodb memory server:

npm install mongodb-memory-server --save-dev
Enter fullscreen mode Exit fullscreen mode

We then install nyc which is the Istanbul command-line interface for code coverage:

npm install nyc --save-dev
Enter fullscreen mode Exit fullscreen mode

We next setup database and seed data. Create the test-setup directory and the db-config.js
The content of the db-config.js file:


import mongoose from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server'

const mongod = new MongoMemoryServer();

//in-memory db for unit test
export const connect = async () => {
  const uri = await mongod.getConnectionString();
    const mongooseOpts = {
      useNewUrlParser: true,
      autoReconnect: true,
      reconnectTries: Number.MAX_VALUE,
      reconnectInterval: 1000
    };

  await mongoose.connect(uri, mongooseOpts);

};

//works perfectly for unit test in-memory db
export const closeDatabase = async () => {
    await mongoose.connection.dropDatabase(); 
    await mongoose.connection.close();
};


//Remove all the data for all db collections. 
export const clearDatabase = async () => {
  const collections = mongoose.connection.collections;
  for (const key in collections) {
      const collection = collections[key];
      await collection.deleteMany();
  }
};
Enter fullscreen mode Exit fullscreen mode

We use the mongodb-memory-server library to setup in-memory db for unit tests. This can also be used for jest but we followed a different approach, as seen in the jest setup.

Next, create the mocha.env.js file which is used to tell our test the environment to run on. We used cross-env to take care of this in the jest configuration above. I tried using that with mocha, but I didn't give the desired result.
So the mocha.env.js file:

process.env.NODE_ENV = 'test';
Enter fullscreen mode Exit fullscreen mode

Then, the script file in package.json, where we will require the above file, use babel to convert ES6 to ES5, specify the directories mocha will look for when running our tests and set a timeout of 20seconds.

"test": "nyc --require @babel/register --require ./mocha.env.js  mocha ./api/**/*.test.js --timeout 20000 --exit"
Enter fullscreen mode Exit fullscreen mode

An Example

Remember to stick to using one test framework(jest or mocha) per project.

Let's consider the signup/create user flow.
We have the user.controller.js file:

import User from '../models/user'
import validate from '../utils/validate'

class UserController {
  constructor(userService){
    this.userService = userService
  }
  async createUser(req, res) {
    const errors = validate.registerValidate(req)
    if (errors.length > 0) {
      return res.status(400).json({
        status: 400,
        errors: errors
      })
    }
    const { name, email, password } =  req.body

    let user = new User({
      name: name.trim(),
      email: email.trim(),
      password: password.trim(),
    })
    try {
      const createUser = await this.userService.createUser(user)
      return res.status(201).json({
        status: 201,
        data: createUser
      })
    } catch(error) {
      return res.status(500).json({
        status: 500,
        error: error.message
      })
    }
  }
}

export default UserController

Enter fullscreen mode Exit fullscreen mode

We took the user's input from the request, called the registerValidate function from the validate.js file located in the utils directory in the repo, we then called the createUser method passing in the user to create. createUser is a method defined in the user.service.js file, which is passed into our controller using dependency injection.

The user.service.js file looks like this:

import User from '../models/user'
import password from '../utils/password';

class UserService {
  constructor() {
    this.user = User
  }
  async createUser(user) {
    try {
      //check if the user already exists
      const record = await this.user.findOne({ email: user.email })
      if (record) {
        throw new Error('record already exists');
      }
      user.password = password.hashPassword(user.password)
      //assign role:
      user.role = "user"
      //create the user
      const createdUser = await this.user.create(user);
      const { _id, name, role } = createdUser;
      //return user details except email and password:
      const publicUser = { 
        _id,
        name,
        role
      }
      return publicUser
    } catch(error) {
      throw error;
    }
  }
}

export default UserService

Enter fullscreen mode Exit fullscreen mode

Unit Tests

Let's now wire our test cases for the files above.
To achieve unit test, we will need to mock external function/method calls.
From the user.controller.js file above, the createUser controller method we will mock the calls to registerValidate function, createUser service method, the response and the status that is sent back to the client.
Alt Text
Looking at the user.service.js file, the createUser service method called an external function, hashPassword to help us hash the password. To achieve unit testing, we will mock that.
Alt Text

Using Jest

a. Controller createUser method.
To mock the response and the status, we will use jest.fn(), which is used to create a jest mock object.
We use jest.spyOn to mock the registerValidate and createUser methods. It is used to mock just a function/method in a given object or class.

The user.controller.test.js file:

import faker from 'faker'
import validate from '../utils/validate'
import UserController from './user.controller'
import UserService from '../services/user.service'

const mockResponse = () => {
  const res = {};
  res.status = jest.fn().mockReturnValue(res);
  res.json = jest.fn().mockReturnValue(res);
  return res;
};

describe('UserController', () => {
  describe('createUser', () => {
    let userController, userService, res;

    beforeEach(() => {
      res = mockResponse()
      userService = new UserService();
    });
    afterEach(() => {    
      jest.clearAllMocks();
    });

    it('should create a user successfully', async () => {
      const req = {
        body: { name: faker.name.findName(), email: faker.internet.email(), password: faker.internet.password() }
      };
      //since validate is foreign, we have to mock it to achieve unit test. We are only mocking the 'registerValidate' function
      const errorStub = jest.spyOn(validate, 'registerValidate').mockReturnValue([]); //no input error
      const stubValue = {
        name: faker.name.findName(),
      };
      //We also mock the 'createUser' service method
      const stub = jest.spyOn(userService, 'createUser').mockReturnValue(stubValue);

      userController = new UserController(userService);

      await userController.createUser(req, res);

      expect(errorStub).toHaveBeenCalledTimes(1)
      expect(stub).toHaveBeenCalledTimes(1)
      expect(res.status).toHaveBeenCalledTimes(1);
      expect(res.json).toHaveBeenCalledTimes(1);
      expect(res.status).toHaveBeenCalledWith(201);
      expect(res.json).toHaveBeenCalledWith({'status': 201, 'data': stubValue});
    });
  });
});

Enter fullscreen mode Exit fullscreen mode

You can check out the repo for unsuccessful user creation tests.
So, we tested only the createUser controller method and mocked all other methods that it depended on, with the help of jest mock and spies libraries. So we can say that the createUser controller method is unit testedπŸ”₯.

b. Service createUser method.
Instead of hitting a real database, we will use the in-memory database we had earlier set up in order to achieve unit tests in the services.

The user.service.test.js file:

import UserService from './user.service'
import  password from '../utils/password';
import { seedUser } from '../test-setup/seed'
import  { connect, clearDatabase, closeDatabase  }  from '../test-setup/db-config'


let seededUser

//Connect to in-memory db before test
beforeAll(async () => {
  await connect();
});
beforeEach(async () => {
  seededUser = await seedUser()
});
// Clear all test data after every test.
afterEach(async () => {
  await clearDatabase();
});
// Remove and close the db and server.
afterAll(async () => {
  await closeDatabase();
});


describe('UserService', () => {
  describe('createUser', () => {
    it('should not create a new user if record already exists', async () => {
      let user = {
        name: 'frank',
        email: seededUser.email,
        password: 'password',
      }
      const userService = new UserService();

      await expect(userService.createUser(user)).rejects.toThrow('record already exists'); 
    });

    it('should create a new user', async () => {
      let userNew = {
        name: 'kate',
        email: 'kate@example.com',
        password: 'password',
      }

      //'hashPassword' is a  dependency, so we mock it, and return any value we want
      const hashPass = jest.spyOn(password, 'hashPassword').mockReturnValue('ksjndfklsndflksdmlfksdf')

      const userService = new UserService();
      const user = await userService.createUser(userNew);

      expect(hashPass).toHaveBeenCalled();
      expect(user._id).toBeDefined();
      expect(user.name).toBe(userNew.name);
      expect(user.role).toBe(userNew.role);
    });
  });

Enter fullscreen mode Exit fullscreen mode

We have both a failure and a successful test case. For the failure test, we first seeded our in-memory db with a user, then tried to insert a record that has the same email as the seeded user. We expected that test to throw an error, which it did:

   await expect(userService.createUser(user)).rejects.toThrow('record already exists'); 
Enter fullscreen mode Exit fullscreen mode

We also tested for a successful insertion.

Using Mocha/Chai/Sinon

We will mock external methods and functions using sinon's stub.

a. Controller createUser method.
The user.controller.test.js file will look like this:

import chai from 'chai'
import sinon from 'sinon'
import sinonChai from 'sinon-chai'
import faker from 'faker'
import validate from '../utils/validate'
import UserController from './user.controller'
import UserService from '../services/user.service'

chai.use(require('chai-as-promised'))
chai.use(sinonChai)
const { expect } = chai

const mockResponse = () => {
  const res = {};
  res.status = sinon.stub()
  res.json = sinon.stub()
  res.status.returns(res);
  return res;
};

describe('UserController', () => {
  let userController, userService, res, sandbox = null;
  beforeEach(() => {
    sandbox = sinon.createSandbox()
    res = mockResponse()
    userService = new UserService();
  });
  afterEach(() => {
    sandbox.restore()
  })

  describe('createUser', () => {
    it('should create a user successfully', async () => {
      const req = {
        body: { name: faker.name.findName(), email: faker.internet.email(), password: faker.internet.password() }
      };
      //since validate is foreign, we have to mock it to achieve unit test. We are only mocking the 'registerValidate' function
      const errorStub = sandbox.stub(validate, 'registerValidate').returns([]); //no input error

      const stubValue = {
        name: faker.name.findName(),
      };
      const stub = sandbox.stub(userService, 'createUser').returns(stubValue);

      userController = new UserController(userService);
      await userController.createUser(req, res);

      expect(errorStub.calledOnce).to.be.true;
      expect(stub.calledOnce).to.be.true;
      expect(res.status.calledOnce).to.be.true;;
      expect(res.json.calledOnce).to.be.true;;
      expect(res.status).to.have.been.calledWith(201);
      expect(res.json).to.have.been.calledWith({'status': 201, 'data': stubValue});

    });
  });
});

Enter fullscreen mode Exit fullscreen mode

As seen above, the beforeEach() hook, we created a sinon sandbox. Sandboxes remove the need to keep track of every fake created, which greatly simplifies cleanup. It becomes useful when other tests are added, as shown in the repository.

b. Service createUser method
The user.service.test.js file will look like this:


import chai from 'chai'
import sinon from 'sinon'
import UserService from './user.service'
import  password from '../utils/password';
import { seedUser } from '../test-setup/seed'
import  { connect, clearDatabase, closeDatabase  }  from '../test-setup/db-config'

chai.use(require('chai-as-promised'))
const { expect } = chai

describe('UserService', () => {

  let seededUser, sandbox = null

  //Connect to in-memory db 
  before(async () => {
    await connect();
  });
  beforeEach(async () => {
    seededUser = await seedUser()
    sandbox = sinon.createSandbox()
  });
  //Clear all test data after every test.
  afterEach(async () => {
    await clearDatabase();
    sandbox.restore()
  });
  //Remove and close the db and server.
  after(async () => {
    await closeDatabase();
  });

  describe('createUser', () => {
    it('should not create a new user if record already exists', async () => {

      let user = {
        name: 'frank',
        email: seededUser.email,
        password: 'password',
      }
      const userService = new UserService();
      await expect(userService.createUser(user)).to.be.rejectedWith(Error, 'record already exists')
    });

    it('should create a new user', async () => {

      let userNew = {
        name: 'kate',
        email: 'kate@example.com',
        password: 'password',
      }

      //'hashPassword' is a  dependency, so we mock it
      const hashPass = sandbox.stub(password, 'hashPassword').returns('ksjndfklsndflksdmlfksdf')

      const userService = new UserService();
      const user = await userService.createUser(userNew);

      expect(hashPass.calledOnce).to.be.true;
      expect(user._id).to.not.be.undefined
      expect(user.name).to.equal(userNew.name);
      expect(user.role).to.equal(userNew.role);
    });
  });
});

Enter fullscreen mode Exit fullscreen mode

You can see that we have two tests in the above suite. One failure and one success. For the failure test, we seeded our in-memory db and tried to add a record with the same email like the one in the db. You might need to pay attention to this line:

await expect(userService.createUser(user)).to.be.rejectedWith(Error, 'record already exists')
Enter fullscreen mode Exit fullscreen mode

We expected the promise to be rejected with an error. This was made possible using:

chai.use(require('chai-as-promised'))
Enter fullscreen mode Exit fullscreen mode

We have used the create user functionality to see how we can write unit tests in our controllers and services, using either jest or mocha test framework. Do well to check the repo for the entire test suites.

End To End Tests(e2e)

For our e2e tests, we won't be mocking any dependency. We want to really test an entire functionality that cuts across different layers at a goal. This is essential as help gives us confidence that all layers in our api as working as expected. We will only see an example when jest is used. You can check the mocha-chai-sinon branch for e2e tests using mocha.

The entire e2e tests inside the e2e_tests directory:
Alt Text

A couple of things to note, we will use the supertest installed earlier in our e2e tests. We also use a real test database. You can check the db configuration in the database directory from the repository.

User e2e test

import supertest from 'supertest'
import app from '../app/app'
import http from 'http'
import User from '../models/user'
import { seedUser } from '../test-setup/seed'
import  { clearDatabase, closeDatabase  }  from '../test-setup/db-config'


let server, request, seededUser

beforeAll(async () => {
  server = http.createServer(app);
  await server.listen();
  request = supertest(server);
});
beforeEach(async () => {
    seededUser = await seedUser()
});
//Clear all test data after every test.
afterEach(async () => {
  await clearDatabase();
});
//Remove and close the test db and server.
afterAll(async () => {
  await server.close();
  await closeDatabase();
});


describe('User E2E', () => {
  describe('POST /user', () => {
    it('should create a user', async () => {
      let user = {
        name: 'victor',
        email: 'victor@example.com',
        password: 'password'
      }
      const res = await request
                        .post('/api/v1/users')
                        .send(user)

      const { _id, name, role } = res.body.data

      //we didnt return email and password, so we wont assert for them
      expect(res.status).toEqual(201);
      expect(_id).toBeDefined();
      expect(name).toEqual(user.name);
      expect(role).toEqual('user');

      //we can query the db to confirm the record
      const createdUser = await User.findOne({email: user.email })
      expect(createdUser).toBeDefined()
      expect(createdUser.email).toEqual(user.email);
      //since our password is hashed:
      expect(createdUser.password).not.toEqual(user.password);
    });

    it('should not create a user if the record already exist.', async () => {
      let user = {
        name: 'chikodi',
        email: seededUser.email, //a record that already exist
        password: 'password'
      }
      const res = await request
                        .post('/api/v1/users')
                        .send(user)

      expect(res.status).toEqual(500);
      expect(res.body.error).toEqual('record already exists');
    });


    it('should not create a user if validation fails', async () => {
      let user = {
        name: '', //the name is required
        email: 'victorexample.com', //invalid email
        password: 'pass' //the password should be atleast 6 characters
      }
      const res = await request
                        .post('/api/v1/users')
                        .send(user)

      const errors =  [ 
        { name: 'a valid name is required' },
        {email: 'a valid email is required'},
        { password: 'a valid password with atleast 6 characters is required' } 
      ]                  
      expect(res.status).toEqual(400);
      expect(res.body.errors).toEqual(errors);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

From the above, we have two failure tests and one successful test case.
We created a fake server so that we don't listen to the real server and mess it up. After the test, we close the fake server.
You can check how this test is done using mocha, chai, and chai-http from the mocha-chai-sinon branch.

A sample output of the project entire test suites:
Alt Text

Conclusion

With a few examples, we have explored use cases when using jest and mocha. These are some of my findings:
a. Declaring test hooks can be defined both inside and outside the describe block when using jest. This is not the case when using mocha, as test hooks are defined inside a describe block.
b. Jest has instabul built it for test coverage by using the --coverage flag when running tests. This is not the case with mocha which requires an external package nyc(which is Istanbul command line interface) for test coverage.
c. Jest has most of the test tools built-in, hence you can hit the ground running immediately. Mocha provides you a base test framework and allows you to use libraries of your choice for assertions, spies, and mocks.

Get the full code:
Using Jest here.
Using mocha here.

Happy Testing.

You can follow on twitter for new notifications.

Top comments (0)