Testing A Backbone App with Karma

In a previous article, I wrote about upgrading your javascript development workflow.  In this post, I’d like to go into more detail about testing, as it’s critical to stability and speed of development.  This will walk you through the beginning stages of setting up and writing your first tests.  The full testing stack is listed below, but if you want a summary of the features and benefits of the tools, be sure to check out my InfoWorld article that pairs with this post. Lets get right to it!

Lab Rat:  Granny’s address book
Test Runner: Karma
Test Framework: Jasmine
DOM fixtures: jasmine-jquery (version 1.7 important!)
Code Coverage: Istanbul
Faking the back-end: Sinon.js

First thing we need to do is install and setup our environment.

Environment

We’ll need bower, karma, and several plugins:


$ npm install -g karma
$ npm install -g karma-firefox-launcher
$ npm install -g karma-coverage
$ npm install -g karma-sinon
$ npm install -g bower

Now that we have bower, we can install the front-end libraries we’ll need to use:


$ bower install jquery
$ bower install requirejs
$ bower install require-handlebars-plugin
$ bower install backbone
$ bower install sinon
$ bower install jasmine-jquery#1.7

Setting up the Granny

First thing first, lets set up the basic directory structure for the project and get the libraries we’ll use to build the app.

Not that none of these files have any contents, I’ve just put them where I expect they’ll need to be so you can see what we’re dealing with.  The test directory is empty for now(more on that later).

 

Set up the Karma configuration file

Next we have to set up a karma config file in the directory we want to test.  We can write it out by hand, copy an existing configuration we use frequently, or use karma’s built in command, `karma init`.  

When you run karma init, it asks a series of questions:

Test Framework? - we’ll use Jasmine

Using Require.js? - yes

Browsers - Here is where you’ll get to tell karma which browsers you want to test in.  Chrome, Chrome Canary, and PhantomJS are supported out of the box, with plugins available for other browsers.  This is only a list of browsers you want to launch automatically when you run karma start.  You always have the freedom to manually connect using mobile devices and other browsers.  I’m going to run tests on Chrome, Chrome Canary, and Firefox.

Location of source and test files -  Here we define where to find our javascript files.

The source files - js/**/*.js and bower_components/**/*.js

The test files don’t exist yet, but I’m going to put them here - test/**/*Spec.js

NOTE: karma warns that there are no existing files that match that pattern. We know that already, so that’s ok.

File to include with the script tag: test-main.js

NOTE: doesn’t exist yet, but will soon

Files to ignore: **/*.swp

Run on change? : yes

 

This takes care of the basic configuration, but we’ll have to do a bit of custom configuration to get code coverage set up, as well as creating the test-main.js we referred to above.

 

test-main.js

Karma’s documentation has a template, so copy it and modify a few things:


baseUrl: 'base';

and update paths to point to the bower components, including our text plugin:


    'jquery': 'bower_components/jquery/dist/jquery',
    'underscore': 'bower_components/underscore/underscore',
    'backbone': 'bower_components/backbone/backbone',
    'handlebars': 'bower_components/handlebars/handlebars.amd',
    'hbs': 'bower_components/require-handlebars-plugin/hbs'

Setting up coverage, templates, and sinon

Now we’ll need to modify the generated config file to include code coverage. This boils down to adding ‘coverage’ to the reporters array:


  reporters: ['dots', 'coverage'],
//(NOTE: I’ve changed coverage to dots, for personal preference)

and adding a preprocessors object in the top level set object (peer to reporters). While we’re at it, we’ll prevent any preprocessing on our handlebars templates:


      // preprocessors to use
      preprocessors: {
        'js/**/*.js': 'coverage', // for coverage
        'templates/**/*.hbs': [] // prevent preprocessing on the templates
      },

Note that we’re not including our bower_components here, because we’re not trying to check our test coverage for external libraries (we’re not testing those). Make sure you’re not including these, or your coverage score will be inaccurate.

Then add ‘sinon’ to the `frameworks` array so that we have access to it in our tests.


    // frameworks to use
    frameworks: ['jasmine', 'requirejs', 'sinon'],

Starting Karma

We should be able to run `karma start` now in our project root.  It will launch the browsers we’ve selected, then run tests and check code coverage.  The browsers will display something like:

If you want to test on other browsers, you can always manually connect:

http://<local-ip-of-machine-running-karma>:9876

(Try this with phones, tablets, game consoles, etc.)

If you look in the newly generated `coverage` directory, you’ll see a subdirectory for each browser connected.  In a browser, open up index.html from one of those subdirectories and look around.  You should see 100% coverage, because there is no source code to test.

Start writing tests

Let’s write a test for the model we want to represent a single address in our book.


define(['js/models/address'], function(AddressModel){

  describe('A model', function(){
    it('should have certain defaults', function(){
      var address = new AddressModel;
      expect(address.get('firstName')).toBe('Jane');
    })
  });

});

Karma watches the filesystems for changes, so immediately on saving, karma runs the tests.  Since AddressModel is just an empty file, the test will fail:


 

Adding the Model

Now lets add the model, but not add the defaults, so we can see how the tests fail:

Now when you add defaults, a nice success message gets output to the console:

We want to flesh out some tests with more model data, so lets set up a quick fixture to give us a good model.  Create a directory in `tests` named `fixtures`, and save the following as `address.js`:


define({
  firstName: 'Jonathan',
  lastName: 'Freeman',
  twitter: '@freethejazz'
});

Now in our test, we can use the fixture like so:


define(['js/models/address', 'test/fixtures/address'], function(AddressModel, AddressFixture){

  describe('A model', function(){
    it('should have certain defaults', function(){
      var address = new AddressModel();
      expect(address.get("firstName")).toBe("Jane");
    })

    it('should take fixture json', function(){
      var address = new AddressModel(AddressFixture);
      expect(address.get('firstName')).toBe(AddressFixture.firstName);
      expect(address.get('lastName')).toBe(AddressFixture.lastName);
      expect(address.get('twitter')).toBe(AddressFixture.twitter);
    });
  });

});

This will help us keep hardcoded values out of the tests and keep the tests a bit less brittle.

Open up our code coverage report to see what we have.  Should still be 100%, but now we can drill down to the source code to see how the number is being calculated:

Click on models...

Click on address.js...

Pretty boring so far, so let’s add some validation to the model.  We’ll require first and last name using backbone’s suggested `validate` method (http://backbonejs.org/#Model-validate).  Normally we’d write the tests first, but to show the code coverage tool at work, let’s build the functionality in first:


define(['underscore', 'backbone'], function(_, Backbone) {

  var AddressModel = Backbone.Model.extend({
    defaults: {
      firstName: 'Jane',
      lastName: 'Doe'
    },
    validate: function(attrs) {
      if(!attrs.firstName){
        return "First Name Required";
      }
      if(!attrs.lastName){
        return "Last Name Required";
      }
    }
  });

  return AddressModel;

});

Alright, save that and look at the console output.  Tests are passing!  But now look at the code coverage report:

Drill down to `address.js` to find the following:

 

The orange means the function isn’t covered, and the red means the statement isn’t covered (hover over to see in the tooltip).

Now that we see we don’t have great coverage here, let’s write a test requiring first name:


define(['js/models/address', 'test/fixtures/address'], function(AddressModel, AddressFixture){

  describe('An address model', function(){
    it('should have certain defaults', function(){
      var address = new AddressModel();
      expect(address.get("firstName")).toBe("Jane");
    })

    it('should require a first name', function(){
      var address = new AddressModel(AddressFixture);

      address.unset('firstName');
      expect(address.isValid()).toBe(false);

      address.set('firstName', AddressFixtures.firstName);
      expect(address.isValid()).toBe(true);

    });
  });

});

(Note that I’ve gotten rid of the example test for using fixtures)

Go back and look at the code coverage.   Much better, but still room for improvement:

Another test to check for last name, and we’ll be set.  Since first and last name are both required fields, and we may have more in the future, I’m going to rearrange the tests a bit to group them in nested `describe` blocks.


define(['js/models/address', 'test/fixtures/address'], function(AddressModel, AddressFixture){

  describe('An address model', function(){
    it('should have certain defaults', function(){
      var address = new AddressModel();
      expect(address.get("firstName")).toBe("Jane");
    })

    describe('should have required fields: ', function(){
      it('first name', function(){
        var address = new AddressModel(AddressFixture);

        address.unset('firstName');
        expect(address.isValid()).toBe(false);

        address.set('firstName', AddressFixture.firstName);
        expect(address.isValid()).toBe(true);

      });
      it('last name', function(){
        var address = new AddressModel(AddressFixture);

        address.unset('lastName');
        expect(address.isValid()).toBe(false);

        address.set('lastName', AddressFixture.lastName);
        expect(address.isValid()).toBe(true);

      });
    });
  });

});

Now check the coverage report:

The Collection

Now that we’ve got a basic model together, lets create a collection for the addresses to live in.

I’ve added the file /tests/collections/addressesSpec.js, with the following contents:


define(['js/collections/addresses.js'], function(AddressCollection){
  
  describe('The address collection', function(){
  
    it('should exist', function(){
      expect(AddressCollection).toBeDefined();
    });

  });
});

 

And the tests start to fail. So let’s create a minimal collection:

 


define(['backbone'], function(Backbone){
  var AddressCollection = Backbone.Collection.extend();

  return AddressCollection;
});

Now we’ve built a RESTful interface for persistence of our address book that plays nicely with Backbone. It looks a bit like this:

 

Method

Url

Request Body

What it does

GET

/addresses

n/a

Returns a list of addresses

GET

/addresses/[id]

n/a

Returns an address with the id of [id]

POST

/addresses

JSON of new address

Saves a new address

In order to map all of this, all we have to do is add `url` and `model` properties to our collection.  So we’ll add the tests and the properties.


    it('should have a url mapped to it', function(){
      var addresses = new AddressCollection();
      expect(addresses.url).toBeDefined();
    });

and


  var AddressCollection = Backbone.Collection.extend({
    url: '/addresses' 
  });

Then:


    it('should have a model', function(){
      var addresses = new AddressCollection();
      expect(addresses.model).toBe(AddressModel);
    });

and


  var AddressCollection = Backbone.Collection.extend({
    url: '/addresses',
    model: AddressModel
  });

Faking the back-end

Now how do we test that our front-end code parses back-end responses properly without writing an integration test? We’ll fake the back-end with a library called sinon.js. We need a place to store sample back-end responses, so lets add a file in our fixtures directory with a good response:


define({
  GET: {
    addresses: [
      {
        firstName: 'Joe',
        lastName: 'Blow',
        twitter: '@bookofjoe'
      },
      {
        firstName: 'Jane',
        lastName: 'Doe',
        twitter: '@planejane'
      },
      {
        firstName: 'Jonathan',
        lastName: 'Freeman',
        twitter: '@freethejazz'
      },
    ]
  }
});

Now open up the test and configure the fake server:


    it('should be able to process a successful response from the server', function(){
      // set up the fake server
      var fakeServer = sinon.fakeServer.create();
      fakeServer.respondWith('GET',
        '/addresses',
        [ 200,
        { 'Content-type': 'application/json' },
        JSON.stringify(AddressesFixtures.GET.addresses)
        ]);

      var addresses = new AddressCollection();
      addresses.fetch();
      fakeServer.respond();
      expect(addresses.length).toBe(3);

      // tear down the fake server
      fakeServer.restore();
    });

That’s a pretty basic setup and teardown for the fake server, but we can always optimize as we need to.

Now you might notice that we don’t even really need to fake the server here. We could just pass our fixture directly into the collection constructor, like so:


      var addresses = new AddressCollection(AddressesFixtures.GET.addresses);

It’s a cleaner approach given that our server response is so straightforward. If your server response structure is customized, you will quickly see where you’ll really benefit from this.

Views

You should have enough information to keep customizing tests for models, but we need to look at views. We need to write a view for our single address. Here’s is a test it should pass:


    it('should render first name, last name, and twitter handle', function(){
      var address = new AddressModel(ModelFixtures);
      var addressView = new AddressView({ model: address});
      addressView.render();

      expect(addressView.$('.first-name').text()).toBe(ModelFixtures.firstName);
      expect(addressView.$('.last-name').text()).toBe(ModelFixtures.lastName);
      expect(addressView.$('.twitter').text()).toBe(ModelFixtures.twitter);
    });

Tests start to fail, so we start writing the view:


define([
  'backbone',
  'jquery',
  'hbs!templates/address'
], function(Backbone, $, addressTmpl){
  var AddressView = Backbone.View.extend({
    template: addressTmpl,
    render: function(){
      var modelJSON = this.model.toJSON();
      this.$el.html(this.template(modelJSON));
    
      return this;
    }
  });

  return AddressView;
});

Let’s make sure our view updates as the model changes. Since we already tested our render method, we’ll just create a spy with jasmine to make sure it gets called when the model changes:


    it('should render when the model changes', function(){
      var address = new AddressModel(ModelFixtures);
      var addressView = new AddressView({ model: address});

      address.set('firstName', 'foo');

      expect(addressView.$('.first-name').text()).toBe('foo');
      expect(addressView.$('.last-name').text()).toBe(ModelFixtures.lastName);
      expect(addressView.$('.twitter').text()).toBe(ModelFixtures.twitter);
    });

Fail, so update the view by creating an initialize method and `listenTo` changes on the model:



define([
  'backbone',
  'jquery',
  'hbs!templates/address'
], function(Backbone, $, addressTmpl){
  var AddressView = Backbone.View.extend({
    template: addressTmpl,
    initialize: function(){
      this.listenTo(this.model, 'change', this.render);
    },
    render: function(){
      var modelJSON = this.model.toJSON();
      this.$el.html(this.template(modelJSON));
    
      return this;
    }
  });

  return AddressView;
});

That should pretty much do it. Rinse and repeat for the rest of your model and view functionality. While you may want to unit test routers, that’s commonly left up to functional testing tools like Selenium, which is another post entirely. If you have any questions/comments/suggestions, feel free to comment or find me on twitter (@freethejazz)

Comments

Post new comment

  • Web page addresses and e-mail addresses turn into links automatically.
  • Allowed HTML tags: <a> <em> <strong> <cite> <code> <ul> <ol> <li> <dl> <dt> <dd>
  • Lines and paragraphs break automatically.

More information about formatting options

CAPTCHA
Are you for real?
Image CAPTCHA
Enter the characters shown in the image.