So far, I have not found a real workable example of unit testing Express controller/route for API. Most I saw either label integration test as unit test or offer little meaningful in right direction.
To be clear what I am talking about, see the controller code below:
Controller.create = function (req, res) {
return Vehicle.create(req.body, function (err, vehicle) {
if (err) {
return res.status(500).end();
}
else {
return res.json(vehicle);
}
})
};
This is the simplest of controller (or route) functions we would like to unit test. In other words, it should always respond with either status 500 or an object. Remember, the enclosed if-else logic will remain in the controller, however lean it is.
But Mongoose’s async method Vehicle.create
, whose callback function(err, vehicle)
encloses the controller’s if-else logic, poses a problem. Because it uses res
, the callback function is impure and therefore difficult to test in isolation. For this reason, we have to test Controller.create
, if at all, as one unit.
In short, we’re interested in covering all the enclosed paths and confirming the responses of the controller are correct by simulating all the possible results we do know are expected of MongoDB. And while we’re at it, we can assert a few useful things like find()
should be called with {}
or update()
be called with option {new: true}
.
Also of note is that we’re not interested in validity of parameters. For example, req.body
has all the fields we need or not for vehicle creation is better managed at middleware level by the likes of express-validator .
In this tutorial, I’ll add five CRUD APIs for vehicle following test driven development process (or at least I’ll try!). Full code available at github repository .
The APIs are pretty basic and might seem useless to unit test. But in next post I’ll test and refactor a relatively complex update API.
Minimum Requirements
-
Working knowledge of Mocha and Sinon. Especially Sinon’s spy, stub, and yield
-
Express application with mongoose.
npm install express mongoose body-parse method-override --save
-
Global installation of testing framework mocha and test coverage library istanbul (optional but recommended)
npm install mocha istanbul -g
. -
Sinon as dev dependency.
npm install sinon --save-dev
. -
"test": "istanbul cover _mocha app/**/*.spec.js"
or without istanbul"test": " _mocha app/**/*.spec.js"
in package.json under scripts. (Change directory to where your spec.js files are located)
Resulting package.json:
{
"name": "express-mongoose-api-unit-testing",
"main": "server.js",
"scripts": {
"test": "istanbul cover _mocha app/**/*.spec.js",
"start": "node server"
},
"dependencies": {
"express": "~4.16.2",
"mongoose": "~5.0.9",
"body-parser": "~1.18.2 ",
"method-override": "~2.3.10"
},
"devDependencies": {
"sinon": "^1.17.7"
}
}
Mongoose Schema
Let’s take Vehicle
example with three required fields.
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema
var VehicleSchema = new Schema({
name: { type: String, required: true },
model: { type: String, required: true },
manufacturer: { type: String, required: true },
});
module.exports = mongoose.model('Vehicle', VehicleSchema);
Test And Controller Files
vehicle.controller.spec.js
const sinon = require('sinon');
const Controller = require('./vehicle.controller')
const Vehicle = require('./vehicle.model')
describe('Vehicle Controller', function () {
}
vehicle.controller.js
'use strict';
const Vehicle = require('./vehicle.model');
const Controller = {};
Controller.create = function (req, res) {
};
Controller.index = function (req, res) {
};
Controller.get = function (req, res, next) {
};
Controller.destroy = function (req, res) {
};
Controller.update = function (req, res) {
};
module.exports = Controller;
Now, let’s add content to the files in TDD way:
Create
First we need req, res, expectedResult, error etc. for all tests, so let’s define them at the top, right after describe.
describe('Vehicle Controller', function () {
// req contains unchanged body and id parameters
// required for all tests
let req = {
body: { // for testing create vehicle
manufacturer: "Toyota",
name: "Camry",
model: "2018",
},
params: {
id: "5aa06bb80738152cfd536fdc" // for testing get, delete and update vehicle
}
},
// server error
error = new Error({ error: "blah blah" }),
res = {},
expectedResult;
.
.
.
Now for create API we only have two conceivable possibilities, either a document is created or its not due to some error (we describe it as server error).
Both tests need their own spied res.json and res.status, so we add them in beforeEach
.
describe('create', function () {
beforeEach(function () {
res = {
json: sinon.spy(),
status: sinon.stub().returns({ end: sinon.spy() }) // to spy res.status(500).end()
};
});
it('should return created vehicle obj', sinon.test(function () {
expectedResult = req.body
this.stub(Vehicle, 'create').yields(null, expectedResult);
Controller.create(req, res);
sinon.assert.calledWith(Vehicle.create, req.body);
sinon.assert.calledWith(res.json, sinon.match({ model: req.body.model }));
sinon.assert.calledWith(res.json, sinon.match({ manufacturer: req.body.manufacturer }));
}));
it('should return status 500 on server error', sinon.test(function () {
this.stub(Vehicle, 'create').yields(error);
Controller.create(req, res);
sinon.assert.calledWith(Vehicle.create, req.body);
sinon.assert.calledWith(res.status, 500);
sinon.assert.calledOnce(res.status(500).end);
}));
});
Note on the above working:
-
this.stub(Vehicle, ‘create’).yields(null, expectedResult); stubs the create method of Vehicle model. On calling Vehicle.create its callback function will be invoked immediately and receive null, and expectedResult as parameters.
-
Controller.create(req, res); calls the actual controller method
-
sinon.assert.calledWith( Vehicle.create, req.body); asserts if Vehicle.create was called with first argument req.body
-
sinon.assert.calledWith( res.json, sinon.match({ model: req.body.model })); and sinon.assert.calledWith( res.json, sinon.match({ manufacturer: req.body.manufacturer }) assert if res.json was called with an object that had given model and manufacturer respectively
-
sinon.assert.calledWith( res.status, 500); asserts res.status was called with 500
-
sinon.assert.calledOnce( res.status(500).end); asserts res.status(500).end was called once
Running npm test
on terminal will result in failing tests:
Vehicle Controller
create
1) should return created vehicle obj
2) should return status 500 on server error
0 passing (18ms)
2 failing
Add vehicle creation code to pass the tests
Controller.create = function (req, res) {
return Vehicle.create(req.body, function (err, vehicle) {
if (err) {
return res.status(500).end();
}
else {
return res.json(vehicle);
}
})
};
Vehicle Controller
create
✓ should return created vehicle obj
✓ should return status 500 on server error
2 passing (19ms)
Get All Vehicles (index)
Get vehicles is similar to create except for expected result which will be an array. You can return 404 on empty array and add a third test, but I keep it simple here and just assert if the result is array.
describe('index (get all)', function () {
beforeEach(function () {
res = {
json: sinon.spy(),
status: sinon.stub().returns({ end: sinon.spy() })
};
expectedResult = [{}, {}, {}]
});
it('should return array of vehicles or empty array', sinon.test(function () {
this.stub(Vehicle, 'find').yields(null, expectedResult);
Controller.index(req, res);
sinon.assert.calledWith(Vehicle.find, {});
sinon.assert.calledWith(res.json, sinon.match.array);
}));
it('should return status 500 on server error', sinon.test(function () {
this.stub(Vehicle, 'find').yields(error);
Controller.index(req, res);
sinon.assert.calledWith(Vehicle.find, {});
sinon.assert.calledWith(res.status, 500);
sinon.assert.calledOnce(res.status(500).end);
}));
});
on npm test
Vehicle Controller
create
✓ should return created vehicle obj
✓ should return status 500 on server error
index (get all)
1) should return array of vehicles or empty array
2) should return status 500 on server error
2 passing (25ms)
2 failing
And similarly adding code will pass the tests:
Controller.index = function (req, res) {
return Vehicle.find({}, function (err, vehicles) {
if (err) {
return res.status(500).end();
}
else {
return res.json(vehicles);
}
})
};
index (get all)
✓ should return array of vehicles or empty array
✓ should return status 500 on server error
Notice that if accidentally {_id: req.params.id}
or any other parameter than {}
was passed to Vehicle.find
the test would fail because of our assertion sinon.assert.calledWith(Vehicle.find, {});
Get
All other tests follow the similar pattern. So I’ll just copy the code and passing results.
describe('get', function () {
beforeEach(function () {
res = {
json: sinon.spy(),
status: sinon.stub().returns({ end: sinon.spy() })
};
expectedResult = req.body
});
it('should return vehicle obj', sinon.test(function () {
this.stub(Vehicle, 'findById').yields(null, expectedResult);
Controller.get(req, res);
sinon.assert.calledWith(Vehicle.findById, req.params.id);
sinon.assert.calledWith(res.json, sinon.match({ model: req.body.model }));
sinon.assert.calledWith(res.json, sinon.match({ manufacturer: req.body.manufacturer }));
}));
it('should return 404 for non-existing vehicle id', sinon.test(function () {
this.stub(Vehicle, 'findById').yields(null, null);
Controller.get(req, res);
sinon.assert.calledWith(Vehicle.findById, req.params.id);
sinon.assert.calledWith(res.status, 404);
sinon.assert.calledOnce(res.status(404).end);
}));
it('should return status 500 on server error', sinon.test(function () {
this.stub(Vehicle, 'findById').yields(error);
Controller.get(req, res);
sinon.assert.calledWith(Vehicle.findById, req.params.id);
sinon.assert.calledWith(res.status, 500);
sinon.assert.calledOnce(res.status(500).end);
}));
});
Controller.get = function (req, res, next) {
return Vehicle.findById(req.params.id, function (err, vehicle) {
if (err) {
return res.status(500).end();
}
else if (!vehicle) {
return res.status(404).end();
}
else {
return res.json(vehicle);
}
})
};
get
✓ should return vehicle obj
✓ should return 404 for non-existing vehicle id
✓ should return status 500 on server error
Delete
describe('destroy', function () {
beforeEach(function () {
res = {
json: sinon.spy(),
status: sinon.stub().returns({ end: sinon.spy() })
};
});
it('should return successful deletion message', sinon.test(function () {
this.stub(Vehicle, 'findByIdAndRemove').yields(null, {});
Controller.destroy(req, res);
sinon.assert.calledWith(Vehicle.findByIdAndRemove, req.params.id);
sinon.assert.calledWith(res.json, sinon.match({ "message": "Vehicle deleted successfully!" }));
}));
it('should return 404 for non-existing vehicle id', sinon.test(function () {
this.stub(Vehicle, 'findByIdAndRemove').yields(null, null);
Controller.destroy(req, res);
sinon.assert.calledWith(Vehicle.findByIdAndRemove, req.params.id);
sinon.assert.calledWith(res.status, 404);
sinon.assert.calledOnce(res.status(404).end);
}));
it('should return status 500 on server error', sinon.test(function () {
this.stub(Vehicle, 'findByIdAndRemove').yields(error);
Controller.destroy(req, res);
sinon.assert.calledWith(Vehicle.findByIdAndRemove, req.params.id);
sinon.assert.calledWith(res.status, 500);
sinon.assert.calledOnce(res.status(500).end);
}));
});
Controller.destroy = function (req, res) {
return Vehicle.findByIdAndRemove(req.params.id, function (err, vehicle) {
if (err) {
return res.status(500).end();
}
else if (!vehicle) {
return res.status(404).end();
}
else {
return res.json({ "message": "Vehicle deleted successfully!" });
}
})
};
destroy
✓ should return successful deletion message
✓ should return 404 for non-existing vehicle id
✓ should return status 500 on server error
Update
describe('update', function () {
beforeEach(function () {
res = {
json: sinon.spy(),
status: sinon.stub().returns({ end: sinon.spy() })
};
expectedResult = req.body
});
it('should return updated vehicle obj', sinon.test(function () {
this.stub(Vehicle, 'findByIdAndUpdate').yields(null, expectedResult);
Controller.update(req, res);
sinon.assert.calledWith(Vehicle.findByIdAndUpdate, req.params.id, req.body, { new: true });
sinon.assert.calledWith(res.json, sinon.match({ model: req.body.model }));
sinon.assert.calledWith(res.json, sinon.match({ manufacturer: req.body.manufacturer }));
}));
it('should return 404 for non-existing vehicle id', sinon.test(function () {
this.stub(Vehicle, 'findByIdAndUpdate').yields(null, null);
Controller.update(req, res);
sinon.assert.calledWith(Vehicle.findByIdAndUpdate, req.params.id, req.body, { new: true });
sinon.assert.calledWith(res.status, 404);
sinon.assert.calledOnce(res.status(404).end);
}));
it('should return status 500 on server error', sinon.test(function () {
this.stub(Vehicle, 'findByIdAndUpdate').yields(error);
Controller.update(req, res);
sinon.assert.calledWith(Vehicle.findByIdAndUpdate, req.params.id, req.body, { new: true });
sinon.assert.calledWith(res.status, 500);
sinon.assert.calledOnce(res.status(500).end);
}));
});
Controller.update = function (req, res) {
return Vehicle.findByIdAndUpdate(req.params.id, req.body, { new: true }, function (err, vehicle) {
if (err) {
return res.status(500).end();
}
else if (!vehicle) {
return res.status(404).end();
}
else {
return res.json(vehicle);
}
})
};
update
✓ should return updated vehicle obj
✓ should return 404 for non-existing vehicle id
✓ should return status 500 on server error
Conclusion
We’ve successfully unit tested our vehicle controller for five common Create, Read, Update and Delete (CRUD) APIs. The final passing result looks like this:
Vehicle Controller
create
✓ should return created vehicle obj
✓ should return status 500 on server error
index (get all)
✓ should return array of vehicles or empty array
✓ should return status 500 on server error
get
✓ should return vehicle obj
✓ should return 404 for non-existing vehicle id
✓ should return status 500 on server error
destroy
✓ should return successful deletion message
✓ should return 404 for non-existing vehicle id
✓ should return status 500 on server error
update
✓ should return updated vehicle obj
✓ should return 404 for non-existing vehicle id
✓ should return status 500 on server error
13 passing (80ms)
And code coverage report summary (ideal because APIs were very simple):
=============================== Coverage summary ===============================
Statements : 100% ( 144/144 )
Branches : 100% ( 16/16 )
Functions : 100% ( 34/34 )
Lines : 100% ( 144/144 )
================================================================================
To see the detailed coverage report open /coverage/lcov-report/index.html in browser.
Once again, you can get the full code from the github repository .
For further examples, check part 2.
See also
- Node JS Mongo Client for Atlas Data API
- SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
- Exactly Same Query Behaving Differently in Mongo Client and Mongoose
- JavaScript Unit Testing JSON Schema Validation
- AWS Layer: Generate nodejs Zip Layer File Based on the Lambda's Dependencies
- In Node JS HTML to PDF conversion, Populate Images From URLs
- Convert HTML to PDF in Nodejs