In part 1 of this Node.js tutorial we covered test tools and how to unit test your code in isolation. In this part, we will look at how to test your request routing, how to populate the database with test data, and how to set up basic end to end testing. The code for this Node.js tutorial is available in nodeTestProject on Gitlab

Routing tests

Verifying if the request routing works correctly is an important step when building a REST API, especially when access to some of the routes is restricted. This can be done using mocha and Supertest. We create a mocha test suite that will start our application and replace all its dependencies with stubs. We then use Supertest to send requests to each endpoint and check the response. The test cases for the different endpoints are quite similar. To minimize the redundancies we create the tests as a parametrized test suite using it-each and an array of input values.

unit_routing

We’ll start by replacing the functions of the only dependency, “userModule”.

Before we can load “userModule”, we need to register a dummy “dbConnector” module with Mockery. As already seen in part 1, this step is necessary to keep “userModule” from loading the real “dbConnector” which in turn would try to initialize a database connection. An empty stub object is sufficient as dummy since there won’t be any actual function calls to it.

    //enable mockery and register DB stub object
    mockery.enable({
      warnOnReplace: true,
      warnOnUnregistered: false,
      useCleanCache: true
    });

    mockery.registerMock('data/dbConnector', sinon.stub());

Next, we load “userModule” and iterate over its functions replacing them one by one with Sinon stubs. The stubs return a HTTP response containing the function name and the endpoint that was called. This allows us to assert whether the information was correct.

    //stub business logic module
    userModule = require("logic/userModule");

    //Use lodash function to get an array of all functions of userModule.
    //Replace all functions with stub functions that send a response back
    _.functions(userModule).forEach(function(element, index, array) {
      sinon.stub(userModule, element,
        function(req, res, next) {
          res.json({
            "method": req.method,
            "originalUrl": req.originalUrl,
            "baseUrl": req.baseUrl,
            "function": element
          });
        });

    });

Next we create an array of endpoint objects. The objects consists request path, request method, payload, headers, and expected result.

//Add API endpoints here
var endpoints = [{
  "path": "/users",
  "method": "POST",
  "payload": {
    "username": "john_smith",
    "userId": "12345"
  },
  "headers": {},
  "expected": {
    "status": 200,
    "function": "createUser"
  }
}, { ... }];

All that’s left is to write one general test case that is executed for each of the array entries. It consists of a switch statement with a case for each request method. Each switch case consists of a Supertest request and a callback function to assert the response.

  describe('successful request suite', function() {
    it.each(endpoints, 'should check %s-request for endpoint %s', ['method', 'path'],
      function(endpt, done) {
        switch (endpt.method.toString().toLowerCase()) {
          case "post":
            request(app).post(endpt.path).set(endpt.headers).send(endpt.payload).end(
              function(err, res) {
                if (err) {
                  assert.fail(err.status, endpt.expected.status);
                } else {
                  console.log(res.text);
                  expect(res.status).to.equal(endpt.expected.status);
                  expect(res.body.function).to.equal(endpt.expected.function);
                  //Additional tests go here...
                }
                return done();
              });
            break;
          ...
        }
      });
  });

Using the parametrized approach, as well as creating Sinon stubs by looping over all module functions, makes it fairly easy to extend the test suite for new endpoints. We simply add a new entry to the parameter array and add the related function to the business logic.

DB tests

Oftentimes it is necessary to test your database queries. Since the example application uses “dbConnector” as a facade to abstract from the underlying database, we can run our tests against “dbConnector”s public functions.

unit_data

The first step is to set up the database with the necessary test data. To ensure test independence the database needs to be reset before each test case. The easiest way to provide the test data is to generate a test data file and use it to populate the database before every test. For MongoDB, we can generate the test data by simply exporting our database to a json file using the mongoexport tool. Note that mongoexport generates “extended json” files. These files must be parsed with a special JSON parser like mongodb-extended-json, available via npm.

To get the data into the database before each test case, we use the initDB module from the lib folder, which reads the extended json file and inserts its content. For cleaning up the database after each test, the above mentioned  mocha-mongoose does a good job. Simply call clearDB() and your database will be emptied.

The test cases are straight forward. Call a “dbConnector” function with the appropriate test parameters. In the callback function, check, if the results came back as expected.

describe('db operations', function() {

  var myUsername = "john_smith";
  var myUserId = "12345";

  beforeEach(function(done) {
    clearDB(done);
  });
  
  ...
  
    it("gets a user", function(done) {
      initDB("users", "test/data/users.json", true, function(err) {
        if (err) {
          done(err);
        } else {
          db.getUser(myUserId,
            function(reason, value) {
              if (reason) {
                done(reason);
              } else {
                expect(myUsername).to.equal(value.username);
                expect(myUserId).to.equal(value.userId);
                done();
              }
            });
        }
      });
    });

 

End 2 end Integration tests

In order to test the full application flow we need to put all pieces together. Instead of fake dependencies we use real dependencies to see if they play together the way we expect. This includes external systems like the database which must be reachable and set up with the necessary test data.

unit_e2e

Similar to the routing tests, end to end testing can be done by sending Supertest requests to the server. This time, we create a separate test case for each request. As different external components might be involved in each test case, the setup steps may differ quite a bit from each other. For instance, the getUser-test requires the database to contain a user with userId “12345”, while the insertUser test would fail if this user already existed. Therefore, parameterized tests are not a good fit.

For each test case we define an endpoint, payload, and expected response. Further, if test data in the database is necessary, we use the above mentioned initDB module to populate it.

    it("retrieves new a user", function(done) {
      var endpoint = "/users";
      var userId = "12345";

      initDB("users", "test/data/users.json", true, function(err) {
        if (err) {
          done(err);
        } else {
          request(app).get(path.join(endpoint, userId)).end(
            function(err, res) {
              if (err) {
                assert.fail(err.status, endpt.expected.status);
              } else {
                expect(res.status).to.equal(200);
                expect(res.body.user.username).to.equal("john_smith");
                expect(res.body.user.userId).to.equal("12345");
              }
              return done();
            });
        }
      });
    });

Setting up a separate test case for all your functionality can quickly become cumbersome. There is a lot of boilerplate code you would write over and over again. This can be mitigated by sorting your tests by setup requirements and grouping them into parameterized sub test suites. Again, all test case specifics are placed in an array. This may include test data setup information and the actual evaluation function of the test case as shown in the code snippet below.

  describe('tests involving db setup', function() {
    beforeEach(function(done) {
      clearDB(done);
    });

    after(function(done) {
      clearDB(done);
    });

    var endpoints = [{
      "path": "/users",
      "method": "GET",
      "payload": "12345",
      "headers": {},
      "testData": {
        "collection": "users",
        "filepath": "test/data/users.json"
      },
      "expected": {
        status: 200,
        evaluate: function(res) {
          expect(res.status).to.equal(this.status);
          expect(res.body.user.username).to.equal("john_smith");
          expect(res.body.user.userId).to.equal("12345");
          //Additional tests go here...
        }
      }
    }];

    // perform async test
    it.each(endpoints, 'should check %s-request for endpoint %s', ['method', 'path'],
      function(endpt, done) {
        initDB(endpt.testData.collection, endpt.testData.filepath, true, function(err) {
          if (err) {
            done(err);
          } else {
            request(app).get(path.join(endpt.path, endpt.payload)).set(endpt.headers).end(
              function(err, res) {
                if (err) {
                  assert.fail(err.status, endpt.expected.status);
                } else {
                  endpt.expected.evaluate(res);
                }
                return done();
              });
          }
        });
      });
  });

 

Conclusion

Testing is an essential part of every software project. NodeJS projects are no exception. I hope this little Node.js tutorial series gives you a good starting point for testing your next Node application and helps you test with less pain.

The code for this article can be downloaded from nodeTestProject on Gitlab

Part 1 of this Node.js tutorial can be found here: Step by step testing your Node apps – part 1