35

I'm new to Node and Express and I'm trying to unit test my routes/controllers. I've separated my routes from my controllers. How do I go about testing my routes?

config/express.js

  var app = express();
  // middleware, etc
  var router = require('../app/router')(app);

app/router/index.js

  module.exports = function(app) {
    app.use('/api/books', require('./routes/books'));
  };

app/router/routes/books.js

  var controller = require('../../api/controllers/books');
  var express = require('express');
  var router = express.Router();

  router.get('/', controller.index);

  module.exports = router;

app/api/controllers/books.js

// this is just an example controller
exports.index = function(req, res) {
    return res.status(200).json('ok');
};

app/tests/api/routes/books.test.js

  var chai = require('chai');
  var should = chai.should();
  var sinon = require('sinon');

  describe('BookRoute', function() {

  });
cusejuice
  • 10,285
  • 26
  • 90
  • 145
  • 1
    Possible duplicate of [How does one unit test routes with Express?](http://stackoverflow.com/questions/9517880/how-does-one-unit-test-routes-with-express) – gnerkus Feb 06 '16 at 11:56
  • 2
    Not a duplicate, the linked question is for older Express that had a different API. – Kos May 20 '16 at 10:08
  • Use this [Link](http://www.designsuperbuild.com/blog/unit_testing_controllers_in_express/) or this [Link](http://www.chrisstead.com/archives/1128/unit-testing-express-routing/). – Pravesh Khatri May 20 '16 at 10:13
  • In addition to the links given you by @PraveshKhatri, you might want to take a look at the [chai-http](https://github.com/chaijs/chai-http) plugin – BadIdeaException May 20 '16 at 10:29
  • 6
    I wouldn't write unit tests for routes. Keep routes logic-less. Have your logic away from the routes & write unit tests for them. Functional or integration tests can be added for routes. –  May 21 '16 at 05:44
  • I fully agree with @xyz. Seperate your logic from your endpoints and you'll be able to fully test them without actually having to use supertest (or equivalents). – Lars de Bruijn May 23 '16 at 12:17
  • 7
    There is testing on routes that only applies to routes. So saying "have logic away from the routes" doesn't actually solve the problem. It minimizes it, but routes still require testing, IMO. You could accidentally bind the wrong call to a route. You might have a typo in the route. So there is still "pure" route related code to test that's specific to your application, IMO. – JohnOpincar Jun 05 '18 at 13:09

5 Answers5

13

If you just want to unit test the route's presence and its method, you can do something like this:

auth.router.js

import { Router } from 'express';

const router = Router();

router.post('/signup', signupValidation, signupUser);
router.post('/login', loginValidation, loginUser);
router.post('/reset', resetValidation, setPasswordReset);

export default router;

auth.router.spec.js

test('has routes', () => {
  const routes = [
    { path: '/signup', method: 'post' },
    { path: '/login', method: 'post' },
    { path: '/reset', method: 'post' },
  ]

it.each(routes)('`$method` exists on $path', (route) => {
  expect(router.stack.some((s) => Object.keys(s.route.methods).includes(route.method))).toBe(true)
  expect(router.stack.some((s) => s.route.path === route.path)).toBe(true)
})

Note: The use of $variables in the example test name will only work with Jest ^27.0.0

Edit: Thanks to Keith Yeh for his suggestion to put this into an each() statement. I have updated the code accordingly & the old code is below:

auth.router.spec.js (OLD)

import router from '../auth.router';

test('has routes', () => {
  const routes = [
    { path: '/signup', method: 'post' },
    { path: '/login', method: 'post' },
    { path: '/reset', method: 'post' }
  ]

  routes.forEach((route) => {
    const match = router.stack.find(
      (s) => s.route.path === route.path && s.route.methods[route.method]
    );
    expect(match).toBeTruthy();
  });
});
Jamie
  • 3,105
  • 1
  • 25
  • 35
  • 4
    Upvoted. The only answer to perform a unit test. Other answers are doing integration tests. – ikhvjs Apr 22 '22 at 13:16
  • Use [test.each](https://jestjs.io/docs/api#testeachtablename-fn-timeout) for each route to get a better debug message. See https://stackoverflow.com/a/53211296/2341419 – Keith Yeh May 04 '22 at 04:06
  • This worked beautifully thank you. – Andrew Aug 09 '22 at 00:51
10

Code:

config/express.js

var app = express();
// middleware, etc
var router = require('../app/router')(app);

module.exports = app;

app/tests/api/routes/books.test.js

var chai = require('chai');
var should = chai.should();
var sinon = require('sinon');
var request = require('supertest');
var app = require('config/express');

describe('BookRoute', function() {
    request(app)
        .get('/api/books')
        .expect('Content-Type', /json/)
        .expect('Content-Length', '4')
        .expect(200, "ok")
        .end(function(err, res){
           if (err) throw err;
        });
});

Considerations:

If your server requires an initial state at the beginning of a set of tests (because you're executing calls which mutate server state), you'll need to write a function that will return a freshly configured app and the beginning of each group of tests. There is an NPM library: https://github.com/bahmutov/really-need that will allow you to require a freshly instantiated version of your server.

ironchefpython
  • 3,409
  • 1
  • 19
  • 32
  • 17
    This is an integration test, not a unit test. Supertest spins up a running server on a listening port. As far as I can tell, Express has no way to map a route to a (req, res) function to test the underlying logic instead of pulling in a whole server. Insanity. – Petrus Theron Oct 04 '19 at 09:59
  • @PetrusTheron it's debatable whether that matters and where the line between a unit test and integration test is. Talking to a local HTTP server isn't very heavyweight, certainly not as heavy as launching your whole application and integration testing it. I guess theoretically funkiness in the OS networking configuration could interfere with the test, but at least in this case effects of request/response serialization are tested. – Andy Dec 10 '19 at 21:35
  • 1
    @Andy this launches your whole application on a fresh port for each test and tests over an HTTP connection. It is a heavy-weight approach that stems from limitations in Express' design. It makes tests run really slowly. – Petrus Theron Dec 11 '19 at 07:30
  • I see, I haven't tested things at a scale where I would realize it's slow going over local loopback. FWIW, maybe there would be some gotcha mocking the Node `http.ClientRequest` and `http.ServerResponse` classes that Express relies upon? – Andy Dec 12 '19 at 08:24
  • FWIW, Hapi allows testing without a roundtrip over TCP. It uses its own request/response classes instead of exposing the core node ones, so I guess that's part of why it's able to test without a real server. – Andy Dec 12 '19 at 08:32
6

This is interesting because you've separated out your controllers from your routers. The other StackOverflow article mentioned in the comments is a good way to test your controllers, I think. The thing to keep in mind with unit tests is what are you testing exactly. You shouldn't need to write tests to test the express library because presumably it has its own unit tests. So you just need to test your calls to the library. So for the books route, you just need to test this one line of code:

router.get('/', controller.index);

I looked around to see if there was an obvious way to get a list of routes from the express library, but I didn't see one. You can probably just look at the library itself and check its internals to see if you set a route correctly. Another option though is to mock it up and just check that you are calling it correctly.

This is going to get pretty complicated because you need to mock up a some fundamental parts of Javascript in order to test this one line of code. Here's how I did it:

describe('BookRoute', function() {
  it("should route / to books controller index", function() {
    var controller = require('../../../api/controllers/books');
    var orig_this = this;
    var orig_load = require('module')._load;
    var router = jasmine.createSpyObj('Router', ['get']);
    var express = jasmine.createSpyObj('express', ['Router']);
    express.Router.and.returnValues(router);
    spyOn(require('module'), '_load').and.callFake(function() {
      if (arguments[0] == 'express') {
        return express;
      } else {
        return orig_load.apply(orig_this, arguments);
      }
    });
    require("../../../router/routes/books");
    expect(router.get).toHaveBeenCalledWith('/', controller.index);
  });
});

What's going on here is I used Jasmine's spyOn function to spyOn the _load function in module.js which is what handles all of the require calls. This is so that when we require the books router and it calls require('express') we can return our express SpyObj that we created with jasmine.createSpyObj. Once we have replaced express with our spy object, then we can have it return our Router SpyObj which will let us spy on router.get. Then we can check to make sure it is called with '/' and controller.index.

This could probably be made into some sort of utility if you wanted to use this a lot.

I usually avoid a lot of this thing by using a more object oriented approach and either I'm passing around some object everywhere that I can mock for tests or you could use some kind of dependency injection like Angular uses.

Peter Haight
  • 1,906
  • 14
  • 19
2

I found this blog incredibly insightful when testing my own servers endpoints.

In the blog he addresses:

  • How to use the endpoint testing library supertest.

  • How to programmatically spin up and tear down an express server with your needed routes before and after each endpoint test. (he also explains why you would want to do this).

  • How to avoid a common gotcha, require caching your modules required in your unit tests, leading to unintended consequences.

Hope this helps. Good luck and if you have any further questions let me know.

Patrick Motard
  • 2,650
  • 2
  • 14
  • 23
  • 6
    The blog point has some nice examples on how to use supertest to test API endpoints. However, I feel some comments are needed as I don't fully agree with the author. Starting and stopping the server between each test is a bad design. If it's an RESTful API you're testing the server should be stateless anyway. If it's an application with state, you could destroy the session between each test to achieve the same. Also, it seems the author doesn't understand how the JavaScript Stack works which leads him to incorrect conclusions. – Lars de Bruijn May 23 '16 at 12:15
0

This answer is an improvement of @jamie:

import router from "../payment";

describe("has routes", () => {
  function findRouteByName(routes: any, path: any) {
    return routes.find(
      (layer: any) => layer.route && layer.route.path === path
    );
  }

  const routes = [
    { path: "/", method: "post" },
    { path: "/presale", method: "post" },
    { path: "/deny", method: "post" },
    { path: "/cancel", method: "post" },
    { path: "/refund", method: "post" },
    { path: "/issuer/info", method: "get" },
    { path: "/latest", method: "get" },
    { path: "/history", method: "get" },
    { path: "/issuer/:transactionId", method: "get" },
    { path: "/:paymentId", method: "post" },
    { path: "/wallet/cancel", method: "post" },
    { path: "/wallet/refund", method: "post" },
    { path: "/corporate/payment-amount", method: "get" },
    { path: "/corporate/history", method: "get" },
  ];

  it.each(routes)("`$method` exists on $path", (route) => {
    const expectedMethod = route.method;
    const singleRouteLayer = findRouteByName(router.stack, route.path);
    const receivedMethods = singleRouteLayer.route.methods;

    // Method control
    expect(Object.keys(receivedMethods).includes(expectedMethod)).toBe(true);

    // Path control
    expect(router.stack.some((s) => s.route.path === route.path)).toBe(true);
  });
});
Hasan Gökçe
  • 501
  • 6
  • 6