Unit testing with Meteor

 If you like to just dive in, head over to our GitHub repo and checkout the RTD example project. Otherwise read on.

We're creating a continuous delivery pipeline for Meteor apps. Our first challenge is doing proper unit test. The success criteria is making every single line of code that we've written accessible to unit tests,  without having to run the meteor app.

Now we don't want to test Meteor itself, so the approach we've taken is to create a Meteor stub that acts as "bedding" for the Meteor app, so that when the application code is loaded, it has all the methods and helpers that Meteor normally provides. The sneaky thing this stub does is captures and exposes any code that you normally give to Meteor and allows you to test it in isolation.

To give you an example of what this means, consider the Meteor code from the leaderboard example application. There is some code that runs on the server that looks like this:

Meteor.startup(function () {
    if (Players.find().count() === 0) {
        var names = ["Ada Lovelace",
            "Grace Hopper",
            "Marie Curie",
            "Carl Friedrich Gauss",
            "Nikola Tesla",
            "Claude Shannon"];
        for (var i = 0; i < names.length; i++)
            Players.insert({name: names[i], score: Math.floor(Random.fraction()*10)*5});
    }
});

If you try to run this without Meteor, you'll very quickly realize that Meteor is null and will get an error.

You'll also notice that the code depends on Player having been defined, which looks like this in the same leaderboard example: 

Players = new Meteor.Collection("players");

So we created a stubs.js file that has creates the methods needed: 

// SEE NOTE 1
var Meteor = {
    // SEE NOTE 2
    startup: function (newStartupFunction) {
        Meteor.startup = newStartupFunction;
    },
    // SEE NOTE 3
    Collection: function (collectionName) {
        Meteor.instantiationCounts[collectionName] = Meteor.instantiationCounts[collectionName] ?
            Meteor.instantiationCounts[collectionName] + 1 : 1;
    },
    instantiationCounts: {}
};
// SEE NOTE 4
Meteor.Collection.prototype = {
    insert: function () {},
    find: function () {},
    findOne: function () {},
    update: function () {},
    remove: function () {},
    allow: function () {},
    deny: function () {}
};

Notes: 

  1. We now have a Meteor object. Hoorah.
  2. When Meteor.Startup is called, the function is used to replace itself. This means in a unit test, if you refer to Meteor.startup, you'll get access to the startup function and can test it, as you can see in the server unit test below.    
  3. The collection constructor exists now and it keeps track of how many times a collection is created so you can make assertions, like in the model test below.
  4. A prototype containing all the collection methods as per the Meteor documentation. This will allow you to add spies with frameworks such as Jasmine or Mocha which expect the methods to exist before spying.    

With this stub, we can now do some real unit testing. In the leaderboard app the players list is created only once, so a simple test like this is all that's needed:

describe("Players model", function() {
    it("is only added once to the Meteor.Collection", function() {
        // EXECUTE & VERIFY
        expect(Meteor.instantiationCounts.players).toBe(1);
    });
});

 And for the server, this unit test does the required magic: 

describe("Meteor startup", function () {

    it("inserts players into the Players collection if it's empty", function () {
        // SETUP
        spyOn(Players, 'find').andReturn({ count: function () { return 0; } });
        spyOn(Players, 'insert');
        // EXECUTE
        Meteor.startup();
        // VERIFY
        expect(Players.find.calls.length).toEqual(1);
        expect(Players.insert.calls.length).toEqual(6);
    });

    it("doesn't insert players into the Players collection if there are players in there already",
        function () {
            // SETUP
            spyOn(Players, 'find').andReturn({ count: function () { return 1; } });
            spyOn(Players, 'insert');
            // EXECUTE
            Meteor.startup();
            // VERIFY
            expect(Players.find.calls.length).toEqual(1);
            expect(Players.insert.calls.length).toEqual(0);
    });

});

 Now you can see the basic approach, let's move onto the exciting stuff with templates. Consider this code from the example: 

Template.leaderboard.players = function () {
    return Players.find({}, {sort: {score: -1, name: 1}});
};

 By some voodoo magic, when you define a template called leaderboard in HTML, Meteor creates an object called leaderboard inside Template. Now for unit testing, we're not interested in the HTML markup (at least not in this article), so we'd like to grab the code that actually does stuff. To do this, we add the following to the stubs file: 

var TemplateClass = function () {
};

TemplateClass.prototype = {
    stub: function (templateName) {
        TemplateClass.prototype[templateName] = {
        };
    }
};
var Template = new TemplateClass();

Now we have a Template object with a method called stub. Calling the stub method makes the leaderboard template exist before any app code requires it, which in turn allows the app code above to set the players function, thus allowing us to test it. Like this:

// THIS LINE CREATES A STUB FOR OUR LEADERBOARD TEMPLATE
Template.stub('leaderboard');

describe("Template.leaderboard.players", function() {

    it("asks for the players to be primarily in descending score order, then in alphabetical order
        and returns as is", function() {
            var someLocalCollectionCursor = {};
            // NOTE WE'RE NOT USING SPIES HERE. NO SPECIAL REASON, BUT JUST TO DEMONSTRATE
            // DIFFERENT STYLES OF UNIT TESTING. THERE IS NO RIGHT OR WRONG WAY, SO LONG AS
            // YOU GET THE ASSERTIONS RIGHT AND GOOD COVERAGE
            Players.find = function(selector, options) {
                expect(options.sort.score).toBe(-1);
                expect(options.sort.name).toBe(1);
                return someLocalCollectionCursor;
            };
            expect(Template.leaderboard.players()).toBe(someLocalCollectionCursor);
    });
});

Tada! We can unit test template attributes and functions. But what about events? The leaderboard app uses the following code to add events:

Template.leaderboard.events({
    'click input.inc': function () {
        Players.update(Session.get("selected_player"), {$inc: {score: 5}});
    }
});

Well… let's make that stub a bit more advanced and extend the stub method to also capture event code and allow us to unit test:  

var TemplateClass = function () {
};

TemplateClass.prototype = {
    eventMap: {},
    stub: function (templateName) {
        TemplateClass.prototype[templateName] = {
            events: function (eventMap) {
                for (var event in eventMap) {
                    TemplateClass.prototype.eventMap[event] = eventMap[event];
                }
            },
            fireEvent: function (key) {
                TemplateClass.prototype.eventMap[key]();
            }
        };
    }
};
var Template = new TemplateClass();

 The code for events is captures in the eventMap object and now you can access the event code and test it as follows: 

describe("Template.leaderboard [click input.inc] event", function() {

    it("updates the player score by 5 when input.inc is clicked", function() {
        // THE SESSION HAS BEEN STUBBED, MORE ON THAT BELOW
        Session.set('selected_player', 1234);
        Players.update = function(selector, options) {
            expect(selector).toBe(1234);
            expect(options.$inc.score).toBe(5);
        };
        Template.leaderboard.fireEvent('click input.inc');
    });

});

Oh wait… you'll notice there's a Session object in there. We decided to add a session object in the map as it's simple enough to fake:

var Session = {
    store: {},
    get: function (key) {
        return this.store[key];
    },
    set: function (key, value) {
        this.store[key] = value;
    },
    equals: function (key, value) {
        return this.store[key] === value;
    }
};

 So we're good to go? Not quite… As you may know, Meteor concatenates all of the JS files into one file and does not impose any rules on structuring your app. We've found however, that to be able to do unit testing with our stubbing approach you need to create some structure so that you can control loading order of the stubs, the unit tests and the app code. We came up with this: 

├── app
│ ├── client
│ │ ├── leaderboard.html
│ │ └── leaderboard.js
│ ├── css
│ │ └── leaderboard.css
│ ├── models
│ │ └── Players.js
│ ├── public
│ │ ├── favicon.ico
│ │ └── robots.txt
│ └── server
│     └── leaderboard.js
└── test
│ ├── karma.conf.js
│ └── unit
│ │ ├── client
│ │ │ └── leaderboard.js
│ │ ├── models
│ │ │ └── Players.js
│ │ ├── server
│ │ │ └── leaderboard.js
│ │ └── stubs.js

The logic here should be self explanatory, and notice how the unit tests follow the exact same structure. You'll also notice that we are using the excellent Karma - Spectacular Test Runner for Javascript. In the karma.conf.js file, we're using the following loading order:

files = [
    JASMINE,
    JASMINE_ADAPTER,

    // SEE NOTE 1
    'test/unit/stubs.js',

    // SEE NOTE 2
    'test/unit/**/*.js',

    // SEE NOTE 3
    'app/models/**/*.js',
    'app/server/**/*.js',
    'app/client/**/*.js'
];

 Notes:

  1. Stubs are loaded first so they can be available when the application code expects Meteor to be there
  2. We load unit tests next because they setup stubs such as templates ahead of the app loading
  3. Now all the dependencies have been sorted, all the application files can be loaded

I think the above gives a good idea of the approach we're taking. It's still very much a work in progress and the stub we've got is getting more advanced as we hit snags, so head over to our GitHub project to download it. The project supports a whole bunch of other goodies like test coverage reports using Istanbul and end-to-end acceptance tests using PhantomJS/WebDriverJS, which we'll be writing about soon.

Feel free follow us on Twitter for updates and to leave any comments / questions you have.

:)