Using IoC to simplify testing express routes

with jest, sinon, and supertest

·

4 min read

Recently I was writing a small express application where I was needing to be able to quickly stub a service I was using when I was writing tests, to avoid creating any database connections. I was using jest, sinon and supertest for my test setup.

The Problem

Originally I had done the basic express setup, which is usually seen online and even generated in the Express application generator .

My route file located at routes/chsa.js looked something like this:

const express = require("express");
const router = express.Router();

const {
  isBritishColumbia,
  findHealthServiceArea,
} = require("../services/geo");

router.get("/chsa", async function (req, res) {

...

  const isBC = await isBritishColumbia();

...

  const serviceArea = await findHealthServiceArea();
...

});

module.exports = router;

Where I was calling two functions defined in my geo service, while

my app file located at app.js looked something like this:

const express = require("express");
const chsaRouter = require("./routes/chsa");

const app = express();
app.use("/chsa", chsaRouter);

Because of this setup, my test file was looking something like this with jest.doMock()

const supertest = require("supertest");
const sinon = require("sinon");

describe("GET /chsa", function () {
  let app, request, route;

  beforeEach(() => {
    jest.resetModules();

    app = express();
  });

  it("should redirect with missing long/lat", function (done) {
    jest.doMock("../services/geo", () => {
      return {};
    });

    route = require("./chsa");
    route(app);
    request = supertest(app);
    request.get("/chsa").expect(302).expect("Location", "/").end(done);
  });

  it("should redirect with long/lat not in BC", function (done) {
    jest.doMock("../services/geo", () => {
      return {
        isBritishColumbia: sinon.stub().returns(Promise.resolve(false)),
      };
    });

    route = require("./chsa");
    route(app);
    request = supertest(app);

    request
      .get("/chsa")
      .query({ latitude: "48.8277", longitude: "-123.711" })
      .expect(302)
      .expect("Location", "/")
      .end(done);
  });

  it("should return 200 within BC", function (done) {
    jest.doMock("../services/geo", () => {
      return {
        isBritishColumbia: sinon.stub().returns(Promise.resolve(true)),
        findHealthServiceArea: sinon
          .stub()
          .returns(Promise.resolve({ name: "Test Service Area" })),
      };
    });

    route = require("./chsa");
    route(app);
    request = supertest(app);

    request
      .get("/chsa")
      .query({ latitude: "48.8277", longitude: "-123.711" })
      .expect(200)
      .end(done);
  });
});

While I was trying to stub out my service for testing, I had to keep redefining my service mock using jest.doMock so I could return what was needed to create the test setup. This was definitely not looking great and there's definitely a better way to do this! In come IoC.

The Solution

Inversion of control is a method I first came across as a student intern in 2009 using Java's Spring framework. Using this setup for an express router changes how a service is imported and utilised, which allows for much cleaner testing.

Using IoC principles, my updated router at routes/chsa.js looked something like this:

const express = require("express");

module.exports = function (app, geoService) {
  const router = express.Router();

  app.use("/", router);

  router.get("/chsa", async function (req, res) {

  ...

    const isBC = await geoService.isBritishColumbia();

  ...

    const serviceArea = await geoService.findHealthServiceArea();
  ...

  });
};

while my app file located at app.js was updated to this:

const express = require("express");
const chsaRouter = require("./routes/chsa");

const app = express();
chsaRouter(app, require("./services/geo"));

This results in a much cleaner test file where it is very easy to stub out the service as needed. Each service mock is passed as an object now to the express route when it is setup. This method also allows you to easily swap the service out in your production application code, which allows refactors and A/B tests to be more easily achieved.

const express = require("express");
const supertest = require("supertest");
const sinon = require("sinon");
const route = require("./chsa");

describe("GET /chsa", function () {
  let app, request;

  beforeEach(() => {
    app = express();
  });

  it("should redirect with missing long/lat", function (done) {
    route(app, {});
    request = supertest(app);

    request
      .get("/chsa")
      .expect(302)
      .expect("Location", "/")
      .then(() => {
         ...
        done();
      })
      .catch(done);
  });

  it("should redirect with long/lat not in BC", function (done) {
    route(app, {
      isBritishColumbia: sinon.stub().returns(Promise.resolve(false)),
    });
    request = supertest(app);

    request
      .get("/chsa")
      .query({ latitude: "48.8277", longitude: "-123.711" })
      .expect(302)
      .expect("Location", "/")
      .then(() => {
        ...
        done();
      })
      .catch(done);
  });

  it("should return 200 within BC", function (done) {
    route(app, {
      isBritishColumbia: sinon.stub().returns(Promise.resolve(true)),
      findHealthServiceArea: sinon
        .stub()
        .returns(Promise.resolve({ name: "Test Service Area" })),
    });
    request = supertest(app);

    request
      .get("/chsa")
      .query({ latitude: "48.8277", longitude: "-123.711" })
      .expect(200)
      .end(done);
  });
});

I've found this method to create much cleaner and more reusable and testable code.

Did you find this article valuable?

Support Naomi Aro by becoming a sponsor. Any amount is appreciated!