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
Then we create a jest.config.js file in the root directory and specify the preset.
module.exports = {
preset: '@shelf/jest-mongodb',
};
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
}
};
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();
}
};
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"
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
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
},
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
For unit testing, we will need to install a mongodb memory server:
npm install mongodb-memory-server --save-dev
We then install nyc which is the Istanbul command-line interface for code coverage:
npm install nyc --save-dev
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();
}
};
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';
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"
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
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
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.
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.
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});
});
});
});
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);
});
});
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');
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});
});
});
});
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);
});
});
});
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')
We expected the promise to be rejected with an error. This was made possible using:
chai.use(require('chai-as-promised'))
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:
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);
});
});
});
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:
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)