Testing service objects in Node.js (Mocha)

Testing service objects in Node.js (Mocha)

Let’s continue our journey with service objects in Node.js. If you haven’t read a first part, here you have a change. Today we will show how to test them. But first of all - let me start with how to test in general.

Unit vs integration

There’s that ancient battle between unit tests and integration tests. Long story short:

Unit tests are testing particular units in isolation of other units. Personally, I wouldn’t consider "unit" as “class”, but often that’s the case. Service object is, well, an object, with one public method, and intentionally as functional as possible, so usually it’s obvious how to unit test it: create an instance, call the method, and see what’s returned.

Integration tests are testing a group of unit together, to check if they cooperate well. This approach is closer to real world - eventually, your code will work as a one big process, not separate classes/units. The communication between units is something that unit tests don’t verify.

There’s yet another huge advantage over unit tests. Integration tests give you more freedom to refactor. You can extract another service object, or change your adapter public API - unlike unit tests, integration tests would not need to change, so you can immediately check if the implementation is correct.

On the other hand, integration tests usually require more test cases to cover all the code. That’s one of the reasons why you need both unit and integration tests. I will give an example of each, based on the service object from last episode.

Mocking HTTP requests - nock

Note that in both cases we don’t want to call the real Facebook API in our tests, due to a few reasons (let me mention speed and predictability). Mocking the external API is one of the methods to achieve this - we use nock. Let me first explain how it’s used.

It would be annoying to call nock every time we want to mock a request. Low-level nock details live under test/fixtures/requestMocks/facebook/ directory. For example this is how notFound.js file looks like:

require('rootpath')();

var nock = require('nock');  
var config = require('config/config');

const DEFAULT_RESPONSE = {  
 error: {
   message: '(#803) Some of the aliases you requested do not exist: -1',
   type: 'OAuthException',
   code: 803,
   fbtrace_id: 'FndCuSJHV9c',
 },
};

module.exports = function (id, path, query, response) {  
 if (!response) {
   response = DEFAULT_RESPONSE;
 }
 query.access_token = config.FB_TOKEN;

 return nock('https://graph.facebook.com:443', { encodedQueryParams: true })
   .get(path)
   .query(query)
   .reply(404, response);
};

Note that those responses were not made by hand. Nock provides a way to listen to the outcoming requests, and tell you how to mock them. This is the helper:

var nock = require('nock');

module.exports = function () {  
 nock.recorder.rec({
   logging: function (content) {
     console.log(content);
   },
 });
};

Whenever you need to mock another request, simply put following line somewhere in the code (could be tests or the production code, note only that all requests will be printed on the console):

require('test/helpers/recordAPI')();  

Once we have the requests mock done, it’s nice to provide a nice interface for our tests - test/fixtures/requestMocks/page.js:

require('rootpath')();

var SuccessMock = require('test/fixtures/requestMocks/facebook/success');  
var NotFoundMock = require('test/fixtures/requestMocks/facebook/notFound');  
var WrongTypeMock = require('test/fixtures/requestMocks/facebook/wrongType');

module.exports = {  
 success: function (id, response) {
   var data = pageRequests(id);
   return SuccessMock(id, data[0].path, data[0].query, response);
 },

 notFound: function (id, response) {
   var params = pageRequests(id);
   return NotFoundMock(id, params[0].path, params[0].query, response);
 },

 wrongType: function (id, response) {
   var params = pageRequests(id);
   return WrongTypeMock(id, params[0].path, params[0].query, response);
 },

};

function pageRequests(id) {  
 var version = '/v2.8/';
 return [
   {
     path: version + id,
     query: {
       fields: 'name,about,link,location,posts.limit(5).fields(message,type)',
     },
   },
 ];
}

This allows us to mock a page-related request like this:

var pageMocks = require('test/fixtures/requestMocks/page');  
var facebookRequest = pageMocks.success('rstit');  

Unit tests

Once we have the external requests mocked, there are no obstacles to unit test the service:

require('rootpath')();

var chai = require('chai');  
var chaiAsPromised = require('chai-as-promised');  
chai.use(chaiAsPromised);  
var expect = chai.expect;  
var FetchPage = require('services/fetchPage');  
var pageMocks = require('test/fixtures/requestMocks/page');

describe('FetchPage', function () {  
 var expected = {
   name: 'RST Software House',
   city: 'Wroclaw',
   video_posts: [
     {
       id: '492672064193149_970677793059238',
       message: "We've just joined action #BECIAKI and supported Ania Tułecka financially in her fight against cancer.\n\nNow we'd like to invite our partners RST Software Masters, Publicon and Trans.eu System Polska to take action as well. Krzysztof, Szymon, Piotr - it's your turn. We count on you!",
       type: 'video',
     },
   ],
 };

 it('returns page data for numeric page id', function () {
   var pageId = '492672064193149';
   pageMocks.success(pageId);

   return expect(new FetchPage().run(pageId))
     .to.eventually.become(expected);
 });

 it('returns page data for alphanumeric page id', function () {
   var pageId = 'rstit';
   pageMocks.success(pageId);

   return expect(new FetchPage().run(pageId))
     .to.eventually.become(expected);
 });

 it('gets rejected for person id', function () {
   var markId = '4';
   pageMocks.wrongType(markId);

   return expect(new FetchPage().run(markId))
     .to.be.rejectedWith('Non-page object provided');
 });

 it('gets rejected for invalid id', function () {
   var invalidId = '-1';
   pageMocks.notFound(invalidId);

   return expect(new FetchPage().run(invalidId))
     .to.be.rejectedWith('(#803) Some of the aliases you requested do not exist: -1');
 });
});

Integration test

We can as well test the service object through routes. After all, this is its primary use case. The service itself can work perfectly in isolation, but the project will not work unless the contract between routes and service is working fine.

Note how the expected results differ from the ones in unit tests. Nothing’s wrong with that - the route translates the domain-related response of the service object to the HTTP-related language of the whole application. That was the main idea behind service objects.

We test the local app with chai-http package.

require('rootpath')();

var chai = require('chai');  
var chaiHttp = require('chai-http');  
chai.use(chaiHttp);  
var expect = chai.expect;  
var config = require('config/config');  
var pageMocks = require('test/fixtures/requestMocks/page');

var app = require('index');  
var endpoint = '/facebook/page/';

describe(endpoint, function () {  
 var expected = {
   name: 'RST Software House',
   city: 'Wroclaw',
   video_posts: [
     {
       id: '492672064193149_970677793059238',
       message: "We've just joined action #BECIAKI and supported Ania Tułecka financially in her fight against cancer.\n\nNow we'd like to invite our partners RST Software Masters, Publicon and Trans.eu System Polska to take action as well. Krzysztof, Szymon, Piotr - it's your turn. We count on you!",
       type: 'video',
     },
   ],
 };

 it('successfully returns page data for numeric page id', function (done) {
   var pageId = '492672064193149';
   var facebookRequest = pageMocks.success(pageId);

   chai.request(app)
     .get(endpoint + pageId)
     .end(function (err, response) {
       expect(facebookRequest.isDone()).to.be.true;
       expect(response).to.have.status(200);
       expect(response.res.body).to.deep.equal(expected);
       done();
     });
 });

 it('successfully returns page data for alphanumeric page id', function (done) {
   var pageId = 'rstit';
   var facebookRequest = pageMocks.success('rstit');

   chai.request(app)
     .get(endpoint + pageId)
     .end(function (err, response) {
       expect(facebookRequest.isDone()).to.be.true;
       expect(response).to.have.status(200);
       expect(response.res.body).to.deep.equal(expected);
       done();
     });
 });

 it('fails with 422 for person id', function (done) {
   var markId = '4';
   var facebookRequest = pageMocks.wrongType(markId);

   chai.request(app)
     .get(endpoint + markId)
     .end(function (err, response) {
       expect(facebookRequest.isDone()).to.be.true;
       expect(response).to.have.status(422);
       expect(response.res.body).to.have.property('error', 'Non-page id provided');
       done();
     });
 });

 it('fails with 404 for invalid id', function (done) {
   var invalidId = '-1';
   var facebookRequest = pageMocks.notFound(invalidId);

   chai.request(app)
     .get(endpoint + invalidId)
     .end(function (err, response) {
       expect(facebookRequest.isDone()).to.be.true;
       expect(response).to.have.status(404);
       expect(response.res.body).to.have.property('error', 'Page not found');
       done();
     });
 });
});

Grap a repo

https://github.com/fernandokokocha/service-objects-for-node

Conclusions

Ease of testing service objects is a huge benefit of this pattern. Also, it seems suitable for TDD. Along with other value they provide, this makes service objects very advisable.