Bullet-proof Meteor applications with Velocity, Unit Testing, Integration Testing and Jasmine

EDIT: 16/10/2014 – Thanks to the community that pointed out some problem, I have updated the tutorial. I have also updated it to reflect changes for the latest version of API.

In my previous post, I have explained how to develop applications using Behaviour Driven Development (BDD) using velocity with jasmine and jasmine-unit packages. Times has since brought many changes and Meteor 0.9.* world is a different place. I have decided to update my tutorial to reflect the latest changes and improve the previous design. In the next version of the tutorial I’ll look into the future creating apps with Meteor 1.0 and ES6 – Harmony.

In this post, I follow the same example as in the last post, yet we improve the application design for the latency compensation, using client database operations with mini-mongo, rather than server calls. (if you do not understand what I mean do not worry and just trust me that what you do is good and efficient). Therefore, in this tutorial we develop a “bullet-proof” Meteor application for the registration of students to their tutorial groups. You can find complete code on https://github.com/tomitrescak/BulletProofMeteor.

I apologise for any grammatical errors which may appear.

Prologue

In our application students can register for their tutorials. Tutorials have a name and a maximum capacity. Students cannot register if tutorials have reached a maximum capacity. Also, students have to be logged in to register for the tutorial. Tutorials are added to the system only by admins, that is users with a role “admin”. Admins cannot delete tutorials with active registrations.

We will take the BDD way of developing our application. For testing we use the jasmine package, which uses the (jasmine 2.0 syntax)[http://jasmine.github.io/2.0/introduction.html].

If you would like to learn more about the process of BDD or TDD (Test driven development, please check out the following sources). I am also adding some sources on pre-release version of books on Meteor testing

Tutorial Outline

  1. Develop the TutorialDataModel for tutorials and create unit and integration tests as they come. This data model is then used with the transform option of the collection, where each individual record from the Tutorial collection is automatically transformed to the TutorialDataModel.
  2. Develop server methods and create appropriate server unit tests.
  3. Develop templates and write integration tests for template rendering.
  4. Develop event handling and write tests for event functionality.

Chapter 1: Preparing Application

We start by creating an empty Meteor application and adding all necessary packages needed to run our application. If you have downloaded our code from GitHub, you can skip this whole chapter.

$ meteor create Tutorials
$ cd Tutorials
$ rm Tutorials*

Configure Meteor app and add standard Meteor packages and packages for system maintenance

$ meteor remove autopublish
$ meteor remove insecure
$ meteor add accounts-password
$ meteor add alanning:roles
$ meteor add iron:router

Now, we add package for unit testing sanjo:jasmine, package for viewing results of our unit tests in browser velocity:html-reporter, package for internationalisation tap:i18n and other well known packages for “pretifying” our application.

$ meteor add sanjo:jasmine
$ meteor add velocity:html-reporter
$ meteor add sacha:spin
$ meteor add ian:bootstrap-3
$ meteor add ian:accounts-ui-bootstrap-3
$ meteor add tap:i18n

Now, we create standard directories in our application:

$ mkdir -p tests/jasmine/server/unit
$ mkdir -p tests/jasmine/client/integration
$ mkdir client
$ mkdir server
$ mkdir lib

Basic configuration is ready, we now proceed with the definition of the tutorial collection and its data model for the data handling: TutorialDataModel.

Chapter 2: Data Model

In this chapter, we deliver the functionality for the data operations in our app. We follow the specification of the functionality of our application. We start with the initial requirement of our model: Admin can create tutorial.

This tutorial follows the red-green-refactor principle of TDD and BDD, meaning:

  1. We write a failing test which turns the test suite “red”.
  2. We make the test pass with the simplest implementation possible, turning the test suite “green”
  3. We refactor the existing code, repairing broken tests and repeat this process.

First, we place a new file inside the tests/server/unit folder named tutorialDataModelSpec.js. The *Spec.js notation is standard to Jasmine tests. Since the data model applies to both client and server we can use server unit tests to test it.

// file: tests/server/unit/tutorialDataModelSpec.js

"use strict";
describe("Tutorial", function () {
    it("should be created with name and capacity", function () {
        spyOn(Tutorials, "insert").and.callFake(function(doc, callback) {
            // simulate async return of id = "1";
            callback(null, "1");
        });

        var tutorial = new Tutorial(null, "Tutorial 1", 20);

        expect(tutorial.name).toBe("Tutorial 1");
        expect(tutorial.capacity).toBe(20);

        tutorial.save();

        // id should be defined
        expect(tutorial.id).toEqual("1");
        expect(Tutorials.insert).toHaveBeenCalledWith({name: "Tutorial 1", capacity: 20}, jasmine.any(Function));
    });
});

It’s quite a lot that we have covered with this test. We assumed the structure of the Tutorials collection having a create and capacity. Also, we have assumed existence of the Tutorial data model having the constructor with parameters name and capacity and a save() function, which should save data to database calling mongo function Tutorials.insert. We check if insert function has been correctly call with assumed parameters and a callback function, which could be any function: jasmine.any(Function). After insert, a newly assigned id should be defined in the tutorial object. To simulate the return of the id we use the spy and its and.callFake function.

When we run the meteor application with $ meteor command we will get the famous red dot (the image is only for demonstration purposes and the stack trace is different from what you see on the screen).

Red dot

Test error says Failed: ReferenceError: Tutorials is not defined. It is time to define this collection.

//file: lib/tutorialCollection.js
Tutorials = new Meteor.Collection("tutorials");

When we save this file, our app reloads automatically, yet we are still in red with the following error: ReferenceError: Tutorial is not defined. Of course! We still miss our data model, so let’s create it. To save time, let’s implement also the public properties of our model. I like to use the getters/setters approach, since it correctly encapsulates object’s public properties. For the details see here.

//file: lib/tutorialCollection.js
Tutorials = new Meteor.Collection("tutorials");

// A Tutorial class that takes a document in its constructor
Tutorial = function (id, name, capacity, owner) {
    this._id = id;
    this._name = name;
    this._capacity = capacity;
    this._owner = owner;
};

Tutorial.prototype = {
    get id() {
        // readonly
        return this._id;
    },
    get owner() {
        // readonly
        return this._owner;
    },
    get name() {
        return this._name;
    },
    set name(value) {
        this._name = value;
    },
    get capacity() {
        return this._capacity;
    },
    set capacity(value) {
        this._capacity = value;
    }
};

Still in red, now we are dealing with the following error Failed TypeError: Object [object Object] has no method ‘save’. Let’s implement it!

//file: lib/tutorialCollection.js
...
Tutorial.prototype = {
    ...
    save: function() { }
};

Well, we are still in red, now with the following error: *Failed: Expected spy insert to have been called with [ { name : ‘Tutorial 1’, capacity : 20 }, ] but it was never called. *. We need to implement the body of the function.

//file: lib/tutorialCollection.js
Tutorial.prototype = {
    ...
    save: function() {
        // remember the context since in callback it is changed
        var that = this;
        var doc = {name: this.name, capacity: this.capacity};

        Tutorials.insert(doc, function(error, result) {
            that._id = result;
        });
    }
};

Finally in green! We have completed our very first Meteor functionality and bullet-proofed it with the unit test!

Please note that we took a different approach for implementing data models that the one suggested by the meteor team. The original approach depends on using the underscore library and package, which extends the original mongo objects with the prototype functions (see below). Since underscore is automatically stubbed, it would be difficult for us to use it in our unit tests with no actual performance gain.

// meteor approach
// A Tutorial class that takes a document in its constructor
Tutorial = function (doc) {
  _.extend(this, doc);
};
_.extend(Tutorial, {
  save: function () {
  }
});

In our specification, we require that tutorials can only be created by admins. Since we are optimising our app for the latency compensation, we create tutorials on the client side. Therefore, we need to secure the Tutorials collection on the server side using the Collection.allow function. To test this functionality, we implement our very first integration test. But why can’t we just do that with unit test, since it is much faster?

The answer is two-fold, First, in unit tests we test only our new functionality and we assume that everything else works. Since to secure our application we only call standard Meteor API, we do not need to test it, yet we need to test if we are correctly calling it in our implementation. Second, in unit tests all Meteor functionality is stubbed, therefore there is not even possible to get down to nuts and bolts. With this in mind, let us write the test.

// file: tests/client/integration/tutorialIntegrationSpec.js

"use strict";
describe("Tutorial", function () {
    it("should be created by admins", function (done) {
        // login to system and wait for callback
        Meteor.loginWithPassword("admin@tutorials.com", "admin3210", function(err) {
            // check if we have correctly logged in the system
            expect(err).toBeUndefined();

            // create a new tutorial
            var tut = new Tutorial();

            // save the tutorial and use callback function to check for existence
            var id = tut.save(function(error, result) {
                expect(error).toBeUndefined();

                // delete created tutorial
                Tutorials.remove(id);

                Meteor.logout(function() {
                    done();
                })
            });
        });
    });
});

Now, that’s one chunky test. Yet, its internals are quite simple. First, we login as admins and check, if the login was correct. Then, we create a new tutorial and try to save it, specifying the callback function (not yet implemented) to check for the save result. Then, we delete the newly created tutorial in order to keep the database clean. When all is done we call the Jasmine 2.0 done() function to announce the completion of the test with asynchronous calls. Refreshing the page in the browser we see, that our integration tests are now in red with Failed: Expected Error: User not found [403] to be undefined. Let us create an account for admin user and student (normal) user.

//file: server/fixtures.js
Meteor.startup(function() {
   if (Meteor.users.find().count() == 0) {
       var users = [
           {name:"Normal User",email:"normal@tutorials.com",roles:[], password: "normal3210"},
           {name:"Admin User",email:"admin@tutorials.com",roles:['admin'], password: "admin3210"}
       ];

       _.each(users, function (user) {
           var id = Accounts.createUser({
               email: user.email,
               password: user.password,
               profile: { name: user.name }
           });

           if (user.roles.length > 0) {
               Roles.addUsersToRoles(id, user.roles);
           }
       });
   };
});

We need to restart our meteor instance in order to create these accounts. Note, that you can call $ meteor reset to clean your database during development. Next, we need to allow database insertion for admins.

//file: lib/tutorialCollection.js

Tutorials.allow({
insert: function (userId, doc) {
// the user must be logged in, and the document must be owned by the user
return (userId && doc.owner === Meteor.userId() && Roles.userIsInRole(userId, "admin"));
}
});

Running the meteor again we see that our tests are still in red, when after longer interval we receive following error Failed: Error: Timeout – Async callback was not invoked within timeout specified by jasmine.DEFAULT_TIMEOUT_INTERVAL. The reason for this error is that done() function never gets called, since our Tutorial.save() function does not implement the callback. Also, we add a new property owner to the tutorial record. Let’s implement this (note the addition of owner: Meteor.userId() in doc.

//file: lib/tutorialCollection.js
Tutorial.prototype = {
    ...
    save: function(callback) {
        ...
        var doc = {name: this.name, capacity: this.capacity, owner: Meteor.userId()};
        Tutorials.insert(doc, function(error, result) {
            ...
        });
    }
};

Oh no! We have broken the unit test: Error: Expected spy insert to have been called with [ { name : ‘Tutorial 1’, capacity : 20 }, ] but actual calls were [ { name : ‘Tutorial 1’, capacity : 20, owner : null }, Function ]. A simple fix, adding the owner to the test:

// file: tests/server/unit/tutorialDataModelSpec.js
...
expect(Tutorials.insert).toHaveBeenCalledWith({name: "Tutorial 1", capacity: 20, owner: null}, jasmine.any(Function));

We are in green! To make our application really bullet-proof, we create also inverse test in which we validate that Tutorial insertion fails for non-admin user. First we make this test fail by expecting the error to be undefined expect(error).toBeUndefined(); and then making it green by expecting the access denied code 403 expect(error.error).toBe(403);

// file: tests/client/integration/tutorialIntegrationSpec.js

"use strict";
describe("Tutorial", function () {
    ...
    it("should not be created by non admins", function (done) {
        // login to system and wait for callback
        Meteor.loginWithPassword("normal@tutorials.com", "normal3210", function(err) {
            // check if we have correctly logged in the system
            expect(err).toBeUndefined();

            // create a new tutorial
            var tut = new Tutorial();

            // save the tutorial and use callback function to check for existence
            var id = tut.save(function(error, result) {
                expect(error.error).toBe(403);

                Meteor.logout(function() {
                    done();
                })
            });
        });
    });
});

We follow the same approach and create tests and implementation for update and delete of Tutorials. You can find the complete listing for the unit test, integration test and an implementation of the Tutorial data model functionality.

Looking at the specification we are still missing one more business requirement: Admins cannot delete tutorials with active registrations. Since this functionality requires checking for data on the server, let’s move the tutorial delete method to server. So let’s write a new test for the server method:

it("should not be possible to delete tutorial with active registrations", function () {
        spyOn(Roles, "userIsInRole").and.returnValue(true);
        spyOn(Tutorials, "remove");
        spyOn(TutorialRegistrations, "find").and.returnValue({count: function() { return 2 }});

        try
        {
            Meteor.methodMap.removeTutorial("1");
        }
        catch (ex) {
            expect(ex).toBeDefined();
        }

        expect(Meteor.methodMap.removeTutorial).toThrow();
        expect(TutorialRegistrations.find).toHaveBeenCalledWith({tutorialId: "1"});
        expect(Tutorials.remove).not.toHaveBeenCalled();
    });

We have decided to keep registrations in a separate collection TutorialRegistrations. Therefore we need to add this collection to our application and extend the functionality of removeTutorial function.

//file: lib/tutorialRegistrationsCollection.js
TutorialRegistrations = new Mongo.Collection("tutorialRegistrations");

Now we are ready to implement the new server method

//file: lib/tutorialCollection.js
...
if (Meteor.isServer) {
    Meteor.methods({
        removeTutorial: function(id) {
            if (!Meteor.user() || !Roles.userIsInRole(Meteor.user(), "admin")) {
                throw new Meteor.Error(403, "Access Denied");
            }
            if (TutorialRegistrations.find({tutorialId: id}).count() > 0) {
                throw new Meteor.Error(406, "Tutorial has registrations");
            }
            Tutorials.remove(id);
        }
    });
}

All green! Now, we would like to automatically instantiate this data model whenever we operate on data from the Tutorials collection. Meteor allows us to do that easily, using the transform function, which is a part of the collection definition and can be overridden of find operation. Let’s implement this!

//file: lib/tutorialCollection
Tutorials = new Mongo.Collection("tutorials", {
    transform: function(doc) {
        return new Tutorial(doc._id, doc.name, doc.capacity, doc.currentCapacity, doc.owner);
    }
});

Looks like we are done with basic CRUD operations, let’s extend the functionality of the model with business rules.

// file: tests/server/unit/tutorialDataModelSpec.js
...
it("Should not save when name is not defined", function() {
    var model = new Tutorial(null, "", 10);
    expect(function() { model.save(); }).toThrow();
});

it("Should not save when capacity is not defined", function() {
    var model = new Tutorial(null, "Name", 0);
    expect(function() { model.save(); }).toThrow();
});

And here is the updated data model

//file: lib/tutorialCollection
...
save: function() {
    if (!this.name) {
        throw new Meteor.Error("Name is not defined!")
    }

    if (!this.capacity) {
        throw new Meteor.Error("Capacity has to be defined or bigger than zero!")
    }
    ...
}

Since we have covered all the functionality of Admin, we now proceed to functionality for students. Our first requirement is that students can register for a tutorial which is not over its capacity. We will include all the tutorial related functionality in the data model. Let’s write the unit test.

it("should allow students to register for the tutorial", function() {
    var model = new Tutorial("1", "Name", 10, 5);
    var studentId = "2";

    spyOn(TutorialRegistrations, "insert");

    model.registerStudent(studentId);

    expect(model.currentCapacity).toBe(2);
    expect(TutorialRegistrations.insert).toHaveBeenCalled();
    expect(TutorialRegistrations.insert.calls.mostRecent().args[0]).toEqual({ tutorialId : '1', studentId : '2' });
});

While making this test, we found that this design has flaw in having to publish all registrations to client. We realise that it is a good idea to add a bit of redundant information to the tutorial about the number of registrations it currently has active. When a new registration is added or removed, this number is changed. Her is the the implementation of the data model function.

//file: lib/tutorialCollection.js
registerStudent: function(studentId) {
    if (this.currentCapacity >= this.capacity) {
        throw "Capacity of the tutorial has been reached!";
    }
    var that = this;
    TutorialRegistrations.insert({tutorialId: this._id, studentId: studentId}, function (err, id) {
        if (!err) {
            that._currentCapacity += 1;
        }
    });
}

In the previous tutorial we have kept the functionality on server, but since in this tutorial we aim to take advantage of the latency compensation we will keep the functionality on the client (we secured the operations on the collection level using the allow method). Following is a list of unit tests.

// file: tests/server/unit/tutorialDataModelSpec.js
it("should not be possible to register while at maximum capacity", function() {
    var tutorial = new Tutorial(1, "Name", 5, 5);

    expect(function() { tutorial.registerStudent(1); }).toThrow("Capacity of the tutorial has been reached!");
});

it("should not be possible to register if registration is present", function() {
    spyOn(TutorialRegistrations, "findOne").and.returnValue({});

    var tutorial = new Tutorial(1, "Name", 5, 4);
    expect(function() { tutorial.registerStudent(1); }).toThrow("Student already registered!");
});


it("should not be possible to de-register if registration not present", function() {
    spyOn(TutorialRegistrations, "findOne").and.returnValue();
    var tutorial = new Tutorial(1, "Name", 5, 4);
    expect(function() { tutorial.removeRegistration(1); }).toThrow("Student not registered!");
});

it("should be possible to de-register if registration exists", function() {
    spyOn(TutorialRegistrations, "findOne").and.returnValue({});
    var tutorial = new Tutorial("1", "Name", 5, 4);

    spyOn(TutorialRegistrations, "remove");
    spyOn(Tutorials, "update");

    tutorial.removeRegistration("2");

    expect(TutorialRegistrations.remove).toHaveBeenCalledWith({tutorialId: "1", userId: "2"});
    expect(Tutorials.update).toHaveBeenCalledWith({_id: "1"}, {$inc: {currentCapacity : -1}});
});

and following is the implementation

//file: lib/tutorialCollection
registerStudent: function(studentId) {
    if (this.currentCapacity >= this.capacity) {
        throw "Capacity of the tutorial has been reached!";
    }

    // check for existing registrations
    if (TutorialRegistrations.findOne({studentId: studentId}) != null) {
        throw "Student already registered!";
    }

    var that = this;
    TutorialRegistrations.insert({tutorialId: this._id, studentId: studentId}, function (err, id) {
        if (!err) {
            that._currentCapacity += 1;

            // update database
            Tutorials.update({_id: that.id}, { $inc: { currentCapacity: 1 }});
        }
    });
},
removeRegistration: function(studentId) {
    var tutorialRegistration = TutorialRegistrations.findOne({tutorialId: this.id, userId: studentId});

    if (tutorialRegistration == null) {
        throw "Student not registered!";
    }

    TutorialRegistrations.remove({tutorialId: this.id, userId: studentId});
    Tutorials.update({_id: this.id}, { $inc: { currentCapacity: - 1 }});
}

Looking at the specification of our system, we should be done with the client and server functionality. Let’s start with templates!

Chapter 4: Templates

Testing templates consists of two difference processes. With unit tests we test template behaviours such as template hooks and events. With *integration tests we test template rendering and the behaviour of HTML controls.

First, we setup (iron) router with all the routes (this is done incrementally during the development). We setup three different routes. First, the welcome route, accessible to all users. Then, route for authorised students, where they can register for tutorials. And the last route for admins, where they can administer tutorials. We also add some standard templates for showing the loading process or page not found. In the router configuration, we also add hooks for security.

//file:lib/router.js
Router.configure({
    layoutTemplate: 'layout',
    loadingTemplate: 'loading',
    notFoundTemplate: 'template404',
    waitOn: function() {
        //return Meteor.subscribe('practicals');
    }
});

Router.map(function() {
    this.route('introduction', {
        path: '/'
    });
});

Router.map(function() {
    this.route('tutorials', {
        path: '/tutorials',
        waitOn: function() {
            return [Meteor.subscribe('tutorials'), Meteor.subscribe('registrations')];
        }
    });
});

Router.map(function() {
    this.route('admin', {
        path: '/admin',
        waitOn: function() {
            return Meteor.subscribe('tutorials');
        }
    });
});

Router.onBeforeAction('loading');

// security (check the full file on [GitHub](https://github.com/tomitrescak/BulletProofMeteor))
...

The waitOn parameter of the route allows Meteor application to pause render until all data from subscription are made available on client. Adding the “loading” hook renders the “loading” template. In order for subscriptions to work, we need to add server publications.

//file:server/publications.js
Meteor.publish('tutorials', function() {
    return Tutorials.find();
});

Meteor.publish('registrations', function() {
    return TutorialRegistrations.find({userId: this.userId});
});

Now we are ready to start developing our templates. Following is our layout template, this template requires no testing (stored in /client/views/application/layout.html).

<br />    {{> header}}
    {{> yield}}

Header Template

First template that requires testing is the header template. We need to make sure that link for admins only appears to admins and that link for tutorials appears only to registered users. This is our first template test that we will write.

//file:/tests/jasmine/client/integration/headerTemplateNomocksSpec.js
describe("Header template - No Mocks", function() {
    it("should not show tutorial link to anonymous user", function () {
        var div = document.createElement("DIV");
        Blaze.render(Template.header, div);

        expect($(div).find("#tutorialsLink")[0]).not.toBeDefined();
    });

    it("should be able to login normal user", function (done) {
        Meteor.loginWithPassword('normal@tutorials.com', 'normal3210', function (err) {
            expect(err).toBeUndefined();
            done();
        });
    });

    it("should show tutorial link to registered user", function () {
        var div = document.createElement("DIV");
        Blaze.render(Template.header, div);


        expect($(div).find("#tutorialsLink")[0]).toBeDefined();
    });

    it("should be able to logout", function (done) {
        Meteor.logout(function (err) {
            expect(err).toBeUndefined();
            done();
        });
    });

    it("should be able to login normal user", function (done) {
        Meteor.loginWithPassword('admin@tutorials.com', 'admin3210', function (err) {
            expect(err).toBeUndefined();
            done();
        });
    });

    it("should show admin link to admins user", function () {
        var div = document.createElement("DIV");
        Blaze.render(Template.header, div);

        expect($(div).find("#adminLink")[0]).toBeDefined();
    });

    it("should be able to logout", function (done) {
        Meteor.logout(function (err) {
            expect(err).toBeUndefined();
            done();
        });
    });

    it("should not show admin link to non-admins", function () {
        var div = document.createElement("DIV");
        Blaze.render(Template.header, div);

        expect($(div).find("#adminLink")[0]).not.toBeDefined();
    });
});

And following is the header template itself.

<br /><br /><div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container-fluid">
<div class="navbar-header">
                <a class="navbar-brand" href="{{pathFor 'introduction'}}">The Great Clara</a>
            </div>
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav navbar-right">
                    {{> loginButtons }} <!-- here -->
                </ul>
<ul class="nav navbar-nav navbar-right">
                    {{#if currentUser}}

<li><a class="brand" href="{{pathFor 'tutorials'}}" id="tutorialsLink">Tutorials</a></li>
                    {{/if}}
                    {{#if isInRole 'admin'}}

<li><a class="brand" href="{{pathFor 'admin'}}" id="adminLink">Administration</a></li>
                    {{/if}}
                </ul>
</div>
            <!--/.nav-collapse -->
        </div>
</div>

Tutorials Template

In tutorials template we list all tutorials or display that there are no tutorials available (please see the use of Blaze.renderWithData). We display tutorial name, current number of registrations and maximum capacity. Available tutorials are painted with white background, while tutorials with full capacity display with red background. We also display following buttons:

  • Register button is displayed on every available tutorial.
  • Modify button is displayed only for admins on every tutorial
  • Delete tutorial is displayed only for admins on every tutorial which has zero registrations
  • Add tutorial link button is displayed for admins on the bottom on the page

First, we handle template rendering and write integration tests in jasmine. The order of the it specs is given by order of implementation given by the previous specification.

In the first test we check that we render tutorial lines according to the data supplied to the template. Please note the use of Blaze.renerWithData.

//file:/tests/jasmine/client/tutorials-template-spec.sj
describe("Tutorials template", function() {
    it("should show a list of tutorials when there are some available", function () {
        var div = document.createElement("DIV");
        var data = {tutorials: [{}, {}]};
        data.tutorials.count = function() { return 2; }

        var comp = Blaze.renderWithData(Template.tutorials, data);

        Blaze.insert(comp, div);

        expect($(div).find(".tutorialLine").length).toEqual(2);
    });
});

In the second test, within the same file, we check that we display the warning if no tutorials currently exists in the system:

it("should show a warning when no tutorials are available", function () {
    var div = document.createElement("DIV");
    var comp = Blaze.renderWithData(Template.tutorials, {tutorials: {count: function() { return 0; }}});

    Blaze.insert(comp, div);

    expect($(div).find("#noTutorialsWarning")[0]).toBeDefined();
});

Whenever you want to see how your rendering is doing, you can log it with console.log(div) and see the result in your console.

Next, we want to have all tutorials sorted by title. Creating test for this is going to be a bit tricky, since we want to assign and sort the data in the router and not in the template helper. Here is our test spec proposal:

//file:/tests/jasmine/client/tutorials-template-spec.js
it ("should sort tutorials by name", function() {
    var route = _.findWhere(Router.routes, {name: "tutorials"});
    spyOn(Tutorials, "find").and.returnValue({});

    // call the function "data()"
    var data = route.options.data();

    expect(Tutorials.find).toHaveBeenCalled();
    expect(Tutorials.find.calls.mostRecent().args[0]).toEqual({});
    expect(Tutorials.find.calls.mostRecent().args[1].sort.name).toEqual(1);
    expect(data).toEqual({tutorials: {}});
});

And following is the modification of the router

file:/lib/route.js
Router.map(function() {
    this.route('tutorials', {
        path: '/tutorials',
        waitOn: function() {
            return [Meteor.subscribe('tutorials'), Meteor.subscribe('registrations')];
        },
        data: function() {
            return {tutorials: Tutorials.find({}, {sort: {name: 1}}) };
        }
    });
});

If we would use a template helper to obtain our data, instead of using iron router’s data() function, creating the test would be easier:

//file:/client/views/tutorials/tutorials.js
Template.tutorials.tutorials = function() {
    return Tutorials.find({}, {sort: {name: 1}}
};

//file:/tests/jasmine/client/tutorials-template-spec.js
it ("should sort tutorials by name", function() {
    spyOn(Tutorials, "find").and.returnValue({});

    // call the function "data()"
    var data = Template.tutorials.tutorials();

    expect(Tutorials.find).toHaveBeenCalled();
    expect(Tutorials.find.calls.mostRecent().args[0]).toEqual({});
    expect(Tutorials.find.calls.mostRecent().args[1].sort.name).toEqual(1);
    expect(data).toEqual({tutorials: {}});
});

This is the template we have so far:

<br /><br /><h1>Tutorials</h1>
    {{#if tutorials.count}}
        {{#each tutorials}}

<div class="tutorialLine">Line</div>
        {{/each}}
    {{else}}

<p class="well bg-warning" id="noTutorialsWarning">There are no tutorials available ...

    {{/if}}

Next, we will implement all admin buttons that allow us to create and modify tutorials. We have decided to create only one test case for all buttons. This test is using stubs for authorisation checks.

//file:/tests/jasmine/client/integration/tutorialsTemplateSpec.js
it("should show create, modify and delete button to admin user", function () {
    spyOn(Blaze._globalHelpers, "isInRole").and.returnValue(true);

    var data = {tutorials: [{}, {}]};
    data.tutorials.count = function() { return 2; };

    Blaze.renderWithData(Template.tutorials, data, div);

    expect($(div).find("#createTutorial")[0]).toBeDefined();
    expect($(div).find(".modifyTutorial").length).toEqual(2);
    expect($(div).find(".removeTutorial").length).toEqual(2);
});

it("should not show create, modify and delete button to non-admin user", function () {
    spyOn(Blaze._globalHelpers, "isInRole").and.returnValue(false);

    var data = {tutorials: [{}, {}]};
    data.tutorials.count = function() { return 2; };

    Blaze.renderWithData(Template.tutorials, data, div);

    expect($(div).find("#createTutorial")[0]).not.toBeDefined();
    expect($(div).find(".modifyTutorial")[0]).not.toBeDefined();
    expect($(div).find(".removeTutorial")[0]).not.toBeDefined();

    console.log(div.innerHTML);
});

And this is the template we have so far:

<h1>Tutorials</h1>
    {{#if tutorials.count}}
        {{#each tutorials}}

<div class="row tutorialLine">
                {{name}} <span class="badge">{{currentCapacity}} / {{capacity}}</span>

                {{#if isInRole 'admin'}}
                    <span class="glyphicon glyphicon-trash"></span> Delete
                    <a class="btn btn-info pull-right modifyTutorial" href="{{pathFor 'admin'}}"><span class="glyphicon glyphicon-edit"></span> Edit</a>
                {{/if}}
            </div>
        {{/each}}
    {{else}}

<p class="well bg-warning" id="noTutorialsWarning">There are no tutorials available ...

    {{/if}}

    {{#if isInRole 'admin'}}

<hr />
        <a href="{{pathFor 'admin'}}" class="btn btn-primary" id="createTutorial">Create Tutorial</a>
    {{/if}}

Looks like our template is ready! Well, not entirely. One of the requests we had, was that admins cannot delete tutorials which have at least one registration. It is a good practice to hide all actions that are not currently possible for clarity. For this we implement a new template helper, starting with the template (integration) test of this helper.

//file:/tests/jasmine/client/integration/tutorialsTemplateSpec.js
it("function canDelete should return true only when tutorial has no registrations", function () {
    expect(Template.tutorials.canDelete.call({currentCapacity: 0})).toBe(true);
});

it("function canDelete should return false when there are registrations", function () {
    expect(Template.tutorials.canDelete.call({currentCapacity: 1})).toBeFalsy();
});

These test provide a very good example of how we can easily use the prototype.call function to call a template function with a specific context. It is time to implement our simple helper method to make this test green.

Template.tutorials.helpers({
    canDelete: function() {
        return this.currentCapacity == 0;
    }
});

We can now use this helper in our tutorials template to hide all delete buttons and prohibit deleting tutorials with active registrations. We write following test, in which we pass data with two tutorials, from which only one should be able to delete:

it("function canRegister should return true when capacity is available and student is not yet registered", function () {
    // stub values called with accessor "this.currentCapacity"
    spyOn(TutorialRegistrations, "find").and.returnValue({count: function() { return 0; }});
    expect(Template.tutorials.canRegister.call({currentCapacity: 1, capacity: 2})).toBeTruthy();
});

it("function canRegister should return false when reached capacity is available and student is not yet registered", function () {
    expect(Template.tutorials.canRegister.call({currentCapacity: 2, capacity: 2})).toBeFalsy();

    spyOn(TutorialRegistrations, "find").and.returnValue({count: function() { return 1; }});
    expect(Template.tutorials.canRegister.call({currentCapacity: 1, capacity: 2})).toBeFalsy();
});

We adjust the template accordingly using the {{#if canDelete}}...{{/if}}. This makes our test green, but breaks another test, in which we test if all admin buttons are displayed. We adjust this test to include information on the capacity of data:

it("should show create, modify and delete button to admin user", function () {
    spyOn(Blaze._globalHelpers, "isInRole").and.returnValue(true);

    var data = {tutorials: [{currentCapacity: 0}, {currentCapacity: 0}]};
...

Following the very same approach we add the button for student registration for tutorials. This button is visible for tutorials which have available capacity and student is not yet registered for them. To achieve this we write a unit test for the helper method and an integration test for testing the button visibility (Since there is nothing new to show, please see the complete code on GitHub).

Phew! So far we have covered a lot, and yet this is still not the end! Currently, we have a working client and server functionality and our templates render what we need. Yet we miss all the page interactivity. Therefore, we need to add and test template events. We contemplate the tutorial registration event, the rest can be found on GitHub. Let’s start with our unit test.

In this test, we cover clicking on the button with class “.registerForTutorial”, which should trigger call of the registerStudent() from our view model. Here it goes:

var data = new Tutorial();

spyOn(data, "registerStudent");
spyOn(Blaze, "getData").and.returnValue(data);

Template.tutorials.__eventMaps[0]["click .registerForTutorial"].call({templateInstance: function() {}}, {preventDefault : function() {}});

expect(data.registerStudent).toHaveBeenCalled();
});

This is a highly hacked version of how to directly call the template event and hopefully we will soon see a simpler method.

We know, that in helper we will be calling the this.registerStudent(). Since in unit test the context of this varies from the real application, the safest way to stup and test this action is by adding it to the Object. Now, to make this test green, we implement it like this:

//file:/client/views/tutorials/tutorials.js
Template.tutorials.events({
    "click .registerForTutorial": function(e) {
        e.preventDefault();
        this.registerStudent();
    }
});

But, this has a major flaw! Context of “this” is within cursor of the Tutorials collection. In our template we browse lines of the collection directly, we do not instantiate our model. We need to adjust this by creating a new helper:

//file:/client/views/tutorials/tutorials.js
Template.tutorials.helpers({
    ...
    tutorialModel: function() {
        return TutorialViewModel.create(this._id, this.name, this.capacity, this.currentCapacity);
    }
});

and adjusting our template

<br /><br /><h1>Tutorials</h1>
    {{#if tutorials.count}}
        {{#each tutorials}}
            {{#with tutorialModel}}
            ...

With this, we have exhausted all the examples we could draw during development of our applications. Please download the full code from GitHub.

Advertisements

33 thoughts on “Bullet-proof Meteor applications with Velocity, Unit Testing, Integration Testing and Jasmine

  1. Chat says:

    Hi,

    Thanks for the great tutorial. Would you mind explaining the use of
    try
    {
    Meteor.methodMap.removeTutorial(“1”);
    }
    catch (ex) {
    expect(ex).toBeDefined();
    }

    thanks,
    Chat.

  2. michael says:

    Thank you for creating this content, I am following through it now. I noticed in the the beginning of section 2, Data Model, that you state the file, tutorialDataModelSpec.js, should be placed in /tests/server/unit but in fact you place it in tests/jasmine/server/unit. Just thought I’d point it out before I forget. 🙂

  3. Doug says:

    Hello, first thanks for the great looking tutorial. Very kind of you to do this work and share it. I’m trying to follow along using Meteor 0.9.4 and I’m getting syntax errors in tests/jasmine/server/unit/package-stubs.js the message is:

    W20141015-19:07:50.479(-7)? (STDERR) The file “…tests/jasmine/server/unit/package-stubs.js” has syntax errors. [TypeError: Cannot read property ‘prototype’ of null]

    Any idea how to go about fixing this? Or even where the problem comes from?

    • Doug, the version 0.9.4 is fairly new and jasmine is not yet stable with this. The last confirmed version to work is 0.9.3. Please create your app with this version such as ” meteor create applicationName –release 0.9.3.1″

  4. Hello Tomas,

    I also would like to thank you for great tutorial. It’s a big job done very right.

    I’m new to TDD which is “the must” nowadays. I have problems with simple pattern/anti-pattern like: “Item should be created with name” and “Item should not be created without name”?

    Could you suggest the way to test it?

    Thank you,

    Alexei

    • Hello Alexei, Thanks! Soon I will prepare a new ‘next’ version.

      Concerning your question, I would create two tests one for pattern and one for anti pattern. Do not put both of them in the same test!

      • that’s what I thought. I must be doing something wrong because I can write a bush of tests, like this :

        // File: tests/jasmine/client/integration/framework-stubs.js

        describe(“Framework Template Test”, function () {
        it(“Look for #active-frameworks”, function () {
        console.log(“Hello World!”);
        // var div = document.createElement(“DIV”);
        Blaze.render(Template.frameworks);
        expect($(“#active-frameworks”).length).toBe(1);
        });

        it(“Look for #completed-frameworks”);
        });

        but as soon as I add expect($(“#active-frameworks”).length).toBe(1); line I got an error that jQuery is undefined

  5. Mathias Hansen says:

    Nice tutorial! I got the code from here: https://github.com/tomitrescak/BulletProofMeteor. I tried to run the code, but the test dosn’t complete. 11 specs have run, but I counted 26 specs. So it seems like the specs in the unit folder completes, but not the ones in integration. I tried it with meteor 0.9.2.2, 0.9.3.1 and 0.9.4. Have you any ideas why it’s like that?

    • Thanks a lot Mathias! I’ve been quite busy lately but I am planning to do a general review of the tutorial when Meteor 1.0 will come out (only couple of days left! woo hoo!). Also the velocity team has been extremely busy introducing many cool changes which I want to include in my tutorial.

  6. Cool guide! However, I have a problem getting Jasmine/Velocity up and running in the first place: The HTML viewer is stuck on “loading”. The files get registered but neither the console nor the html viewer seems to be running any test. Is there anything that I am missing

    • Valentin, the problem is probably with Velocity, looking at their issue tracker, they had some problems recently to get it running on all platforms. Please do the “`meteor update“` and let me know if the problem persists.

  7. Hi in the expectation
    it(“should allow students to register for the tutorial”, function() {
    var model = new Tutorial(“1”, “Name”, 10, 5);
    ….
    model.registerStudent(st);
    expect(model.currentCapacity).toBe(2); // It is correct??
    ….
    });
    In github code it is correct
    expect(model.currentCapacity).toBe(6);

    that line confused me for a moment

    • Ha! I did not know that they approved my Pull request, so yes! It’s better to use the ‘ Meteor.callInContext()’ since it will possibly be maintained by Velocity team when API changes. Good catch.

  8. Where there was darkness, you have shone light!
    This is a great article.

    I was beginning to think I’d never start testing, there’s an overwhelming amount of stuff to get on board before starting to test effectively – but then I read this article.

    ONE PROBLEM:
    I am a member of Bitbucket, but I get access denied for the links mentioned in this line
    “You can find the complete listing for the unit test, integration test and an implementation of the Tutorial data model functionality.”

    This means that I am stuck after about only a third of the article.
    And presumably anybody else who is not a member of Bitbucket cannot follow the links anyway.

      • Thank you so much.
        I have one other question which I think is very relevant to velocity in Meteor: how do you test/observe the tests?
        The tests are happening in the mirror, right?
        I spent 3/4 hours trying to work out why the userIds I was observing in Chrome tools were not the same as either of the two user accounts (admin and normal) we put in during Meteor.start(), and why in meteor mongo I couldn’t see the Tutorials we were inserting in the tests.
        I presume the answer is: it’s all happening in the mirror and you can’t see it.
        Or did I miss something?

  9. I don’t want to take up too much of your time, but I suppose other people might have the same questions as me, so here goes:

    I’ve finally worked out that callInContext is a server function, and will give errors if you try to use it on the client. I can’t find any documentation anywhere about callInContext(name, context), nor about methodMap. I wish there was something detailed about them. I would in particular like to see an _example_ of callInContext, because I can’t work out what ‘context’ to pass to it.

    Could you also give an indication of where you would put the code for “should not be possible to delete tutorial with active registrations”. I instinctively put it with the client code that had been shown before it, and that didn’t work.

  10. Great tutorial, but I’ve an issue with mergebox. I’ve 2 different publications of the same collection and returned transform object always has undefined fields, as it’s always using the documents from the publication with less fields. This doesn’t happen without transform 😦

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s