With all my Javascript learnings going on, I’ve also been learning about testing it. Most of my website consists of pulling in data from other places—Flickr, Tumblr, Last.fm, and my Ninja Block—and doing something with it, and when testing I don’t want to be making actual HTTP calls to each service (for one thing, Last.fm has a rate limit and it’s very easy to run into that when running a bunch of tests in quick succession which then causes your tests to all fail).
When someone looks at a page containing (say) my photos, the flow looks like this:
Request for page → PhotosController → PhotosService → jsonService → pull data from Flickr’s API
PhotosController is just a very thin wrapper that then talks to the PhotoService which is what calls jsonService to actually fetch the data from Flickr and then subsequently formats it all and sends it back to the controller, to go back to the browser. PhotosService is what needs the most tests due to it doing the most, but as mentioned above I don’t want it to actually make HTTP requests via jsonService. I read a bunch of stuff about mocks and stubs and a Javascript module called Sinon, such but didn’t find one single place that clearly explained how to get all this going when using Sails.js. I figured I’d write up what I did here, both for my future reference and for anyone else who runs into the same problem! This uses Mocha for running the tests and Chai for assertions, plus Sinon for stubbing.
First of all, Sails needs to be raised before any of the tests, because it automagically wires up all the services and everything else. Inside the test/ directory you need a file called bootstrap.test.js:
var Sails = require('sails'); var sails; before(function(done) { // Increase the Mocha timeout so that // Sails has enough time to lift. this.timeout(5000); Sails.lift({ port: 9999 }, function(err, server) { sails = server; if (err) return done(err); done(err, sails); }); }); after(function(done) { Sails.lower(done); });
That will bring up the server before anything else, and shut it back down when the tests are all over.
The relevant bit in this example of my service, living at api/services/PhotosService.js that does all the heavy lifting, looks like this:
getRecentAndPopular: function(options, callback) { const page = options.page, perPage = options.perPage, type = options.type; const flickrApiUrl = baseRestUrlService.getUrl('flickr') .addQueryParam('method', 'flickr.photos.search') .addQueryParam('extras', 'original_format,date_upload') .addQueryParam('page', page) .addQueryParam('per_page', perPage); let paginationPath; if (type == 'popular') { paginationPath = '/photos/popular'; flickrApiUrl.addQueryParam('sort', 'interestingness-desc'); } else { paginationPath = '/photos/recent'; } jsonService.getJsonFromUrl(flickrApiUrl, function(err, data) { if (err) return callback(err); if (data.stat === 'fail') return callback(data.message); const photos = { photos: [] }; _.each(data.photos.photo, function(item) { const options = { farm: item.farm, server: item.server, photo_id: item.id, secret: item.secret, dateupload: item.dateupload }; const imageUrl = photosService.generateImageUrl(options); options.secret = item.originalsecret; options.size = 'original'; const originalImageUrl = photosService.generateImageUrl(options); const title = item.title ? item.title : 'Untitled'; photos.photos.push({ title: title, photoId: item.id, imageUrl: imageUrl, originalImageUrl: originalImageUrl }); }) photos.page = page, photos.lastPage = data.photos.pages; photos.paginationPath = paginationPath; return callback(null, photos); }) }
The jsonService.getJsonFromUrl() bit on line 18 is the bit that needs to be stubbed by Sinon, and as you’d guess from the name jsonService is also a service. Once this is all set up, instead of actually going out and hitting Flickr, it’ll simulate getting the same data back from jsonService without the network request.
The top of test/services/photosService.test.js looks like this:
var expect = require('chai').expect, sinon = require('sinon'), photosService = require('../../api/services/photosService'), getRecentAndPopularStub;
And one of the key parts of the test for getRecentAndPopular():
describe('getRecentAndPopular()', function() { before(function() { getRecentAndPopularStub = sinon.stub(sails.services.jsonservice, 'getJsonFromUrl'); }); after(function() { getRecentAndPopularStub.restore(); }); [...] });
The before() part says to use Sinon to stub the getJsonFromUrl() function within jsonService, and you refer to the actual service with sails.services.jsonservice (for a controller it’s sails.controllers.controllername and so on). Interesting thing to note, regardless of the capitalisation of your service name, the sails.service.* bit needs to be all lowercase or it won’t work. getRecentAndPopularStub.restore() removes the stub after you’re done.
Lastly, the test itself, and setting up what the stub replies to your service with:
it('"recent" type should run successfully if we get a valid response back from Flickr', function(done) { getRecentAndPopularStub.yields(null, { photos: { page: 1, pages: 3, perpage: 3, total: '3', photo: [ { id: '1', owner: 'owner', secret: '67890', server: '2', farm: 1, title: '', dateupload: '1450000000', originalsecret: '1234567890', originalformat: 'jpg' }, { id: '2', owner: 'owner', secret: '67890', server: '2', farm: 1, title: 'Photo title 2', dateupload: '1450000000', originalsecret: '1234567890', originalformat: 'jpg' }, { id: '3', owner: 'owner', secret: '67890', server: '2', farm: 1, title: 'Photo title 3', dateupload: '1450000000', originalsecret: '1234567890', originalformat: 'jpg' } ] }, stat: 'ok' }); photosService.getRecentAndPopular({page: 1, perPage: 3, type: 'recent'}, function(err, data) { expect(err).to.be.null; expect(data.photos).to.have.lengthOf(3); expect(data.photos[0].title).to.equal('Untitled'); expect(data.paginationPath).to.equal('/photos/recent'); expect(data.lastPage).to.equal(3); done(); }); });
The stub’s behaviour is set with stubname.yields(). I’m using Node’s standard error-first callback here so if there’s no error the first parameter should be null (which it is) and the second parameter contains the actual data that would be received back from Flickr had a full HTTP request been made, and then we can otherwise carry on with the testing (and this whole setup lets you test specific scenarios in the replies you’d be receiving). To test how you handle it when there is an error, you’d instead do recentAndPopularStub.yields('error message', null);
All in all it’s not particularly difficult, but I had to do a lot of reading all over the place to actually work out how to put all this together in the end.