Unit Testing Expressjs Controller (Part 1)

Stepwise Test Driven Development of Express Mongoose CRUD APIs

So far, I haven’t 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’m 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 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 .