Skip to content

Instantly share code, notes, and snippets.

@isuftin
Last active December 11, 2017 11:33
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save isuftin/172cf87a4db494269e17 to your computer and use it in GitHub Desktop.
Save isuftin/172cf87a4db494269e17 to your computer and use it in GitHub Desktop.
Write Testable BackboneJS Applications using RequireJS

In a previous write-up I described how I was able to bring together RequireJS with Jasmine to create modular, testable applications. Now I'd like to describe taking it a step further and bringing BackboneJS into the mix.

Our dev shop has been slowly proliferating BackboneJS applications over the last year or two. Personally, I like the structure BackboneJS provides without being overbearing and over-prescriptive in how your applications should be built. It gives just enough skeleton and event and routing to be useful without you having to constantly battle writing your app the way BackboneJS's authors want you to.

Let's see how we can get BackboneJS running inside of a RequireJS modular framework and then test the result using Jasmine and SinonJS.

The goals for this write-up are:

  1. Describe the project structure
  2. Explain how RequireJS can load your entire BackboneJS application
  3. Show how we can test BackboneJS using Jasmine, Sinon and RequireJS
  4. Discuss road blocks
  5. Conclusion

The project: https://github.com/isuftin/amd-jasmine-mvn/tree/0.0.1-backbone

Project Structure

Before I go into the project's structure, I want to immediately confess that I suck at BackboneJS. While I've worked on a few BackboneJS projects, they've been mainly hit-and-run and I've not been on them very long. This will be reflected in the project I introduce you to. However, the point is not to build an amazing BackboneJS/RequireJS application here. Rather, it's to show you how you can build an amazing BackboneJS/RequireJS application.

I began with the project I ended up with in my previous write-up. It is a Maven WAR project with Jersey acting as the back-end JAX-RS REST layer. On the front, I am using WebJars to pull in all of the javascript dependencies. I've wired the project loading through RequireJS and am testing using Jasmine. Again, all of this can be seen here.

For this write-up, I decided to continue from where I left off and created a new branch from the previous project. The project now also pulls in the WebJars for Jasmine as well as SinonJS. I use them in concert to perform testing for the new backbone elements.

The project is laid out in directories named for the major parts of BackboneJS.

  • collections
  • models
  • templates
  • views

The directory structure looks like the following:

directory structure image

I got the idea on how to lay it out through this very informative blog by Jakub Kozisek. That blog also went fairly deep on how to get BackboneJS working with RequireJS and helped me out quite a bit in getting this project off the ground. It is definitely worth the time to read through it.

When the project is loaded, the user is shown a welcome screen and given a choice to either see all of the availble gardens or all of the available plants. Each garden has a name and three plants. Each plant has a name, a color and a boolean denoting whether it is a fruiting plant or not.

Exciting, right?

Loading Backbone Through RequireJS

Loading BackboneJS through RequireJS turns out to be fairly straightforward. At version 1.1.1, the authors of BackboneJS have made it fully compatible with AMD loaders like RequireJS, registering itself as a module. So all we need to do is load it as part of our RequireJS configuration. In my app's entry point, index.jsp, I create a RequireJS configuration object that includes, among other things, BackboneJS and its dependency UnderscoreJS:

<script>
  var require = {
      baseUrl: "<%=contextPath%>/scripts/",
      paths: {
          "jquery": ["<%=contextPath%>/webjars/jquery/<%= getProp("version.jquery")%>/jquery"],
          "backbone": ['<%=contextPath%>/webjars/backbonejs/<%= getProp("version.backbone")%>/backbone'],
          "underscore": ['<%=contextPath%>/webjars/underscorejs/<%= getProp("version.underscore")%>/underscore'],
          "text": ['<%=contextPath%>/webjars/requirejs-text/<%= getProp("version.require.text")%>/text']
      }
  };
</script>

I've talked about the JSP expression tags in the previous write-up, but as a brief reminder, these are used to dynamically set the proper URL for these dependencies which includes dynamically getting the application's context path as well as the versions of each library. The configuration can be seen here.

The output looks like:

<script>
    var require = {
        baseUrl: "/amd-jasmine-mvn/scripts/",
        paths: {
            "jquery": ["/amd-jasmine-mvn/webjars/jquery/2.1.4/jquery"],
            "backbone": ['/amd-jasmine-mvn/webjars/backbonejs/1.2.1/backbone'],
            "underscore": ['/amd-jasmine-mvn/webjars/underscorejs/1.8.3/underscore'],
            "text": ['/amd-jasmine-mvn/webjars/requirejs-text/2.0.14/text']
        }
    };
</script>

You'll also notice that I've included the text RequireJS plugin. This is a very convenient plugin that allows me to pull in template text from HTML files as though they were RequireJS modules. This is very handy when creating BackboneJS views. Using the text plugin means I don't have to make asynchronous calls for template text or write it into the BackboneJS view within the javascript itself using string concatenation (DON'T DO THAT).

Now that I have my RequireJS configuration all set up, I load the main module as part of the tag that loads RequireJS.

[...]
<script data-main="main" src="<%=contextPath%>/webjars/requirejs/<%= getProp("version.require")%>/require.js"></script>
[...]

The main module brings in the first BackboneJS requirement, the router. It instantiates the router and begins the Backbone history by calling the Backbone.history.start() function. In the History constructor params I also add the context path of the application. Notice my use of the RequireJS moduleconfig here to attain configuration information from the JSP into my main module to define the context path.

I set the context path as part of the require configuration in index.jsp:

var require = {
  config: {
      'main': {
          'contextPath' : "<%=contextPath%>/ui/"
      }
  }
[...]

Then when the main module is loaded, I use the special dependency "module" in main.js to import the require configuration and pull the context path out to set it to the History object:

define([
    'router',
    'backbone',
    'module' // import it here
], function (
    Router,
    Backbone,
    module // delcare it here
    ) {
    new Router();
    Backbone.history.start({
      root: module.config().contextPath // use it here
    });
});

Using this RequireJS pattern, we don't need to create configuration objects that pollute the global namespace. This is one of the reasons we use RequireJS in the first place!

The router I created is a Backbone module that also has dependencies on all of our required views:

define([
    'backbone',
    'views/GardenView',
    'views/GardensView',
    'views/PlantsView',
    'views/PlantView',
    'views/DefaultView'
], function (Backbone, GardenView, GardensView, PlantsView, PlantView, DefaultView) {
[...]

The router module creates an a Backbone.Router extension, defines its routes and initialization function and returns the router object back to the caller. The routes defines the url routes that the application has. The router's initialization function creates hooks to each of the routes. This is a standard Backbone pattern for creating a Router.

Each of the Views pulls in an Underscore template using the RequireJS text plugin:

define([
    'jquery',
    'underscore',
    'backbone',
    'collections/GardenPlantsCollection',
    'text!templates/Garden.html' // import the template
], function ($, _, Backbone, GardenPlantsCollection, tpl) {
    var GardenView = Backbone.View.extend({
        el : $('#container'),
        template: _.template(tpl), // set the template to the view
    [...]
    render: function () {
      var plants = this.collection.toJSON();
      var name = this.collection.id;

      // make use of the template
      var compiledTemplate = this.template({
        name : name,
        plants : plants
      });
      this.$el.html(compiledTemplate);
    }

Of course this can also be a Mustache template, Handlebars template or any other javascript templating system you prefer. Backbone is agnostic to the backing templating dependency. I chose Underscore templating because BackboneJS already has a dependency on Underscore so it essentially comes "free" when using BackboneJS. To use something like Handlebars, you would just declare the dependency in the require config object and import it into your module as you would any other dependency with RequireJS.

The rest of the project structure is unremarkable and follows the patterns of RequireJS and Backbone. Each View (except the default View) has either a Model or a Collection. Each Collection contains a Model and each Model has a URL from which it attains data:

var GardensModel = Backbone.Model.extend({
  url: "data/garden",
  idAttribute: 'name',
  [...]

On the Java end, I've created REST endpoints for attaining that data. The data that is spit back is stock and is either a JSON representation of a Plant Java object or a Garden Java object which has a name and an array of plants. The objects are translated from Java to JSON using GSON and sent back to the calling client.

So what does the application look like?

Intro page:

Intro page

Gardens Page:

Gardens Page

Garden Page:

Garden Page

Plants Page:

Plants Page

Plant Page:

Plant Page

Breath-taking, isn't it?

In case you're interested in the loading times and what gets loaded, this is Chrome's developer tools showing me loading http://localhost:8080/amd-jasmine-mvn/ui#plants:

Loading View

146ms to load everything including a round-trip to the server to get the names of all of the available plants. Not bad.

Testing Backbone

Creating the tests for Backbone was where I spent most of my time reading through blogs to get my head around what to test and how it should be tested. The blog from Jim Newbury has helped me a great deal.

Testing The Router

First, let's take a look at the router spec. In order to test the router, I bring in SinonJS into my test module. I use this to create a spy on the router object in the beforeEach function. I bind the spy to the router's routing triggers:

define([
    "backbone",
    "sinon",
    "router"
], function (Backbone, sinon, Router) {
    describe("Router", function () {
        beforeEach(function () {
            this.router = new Router();
            this.routeSpy = sinon.spy();
            this.router.bind("route:showDefault", this.routeSpy);
            this.router.bind("route:showGardens", this.routeSpy);
            this.router.bind("route:showGarden", this.routeSpy);
            this.router.bind("route:showPlants", this.routeSpy);
            this.router.bind("route:showPlant", this.routeSpy);
[...]

Later, in the tests, when I navigate my router to URLs, I look to the spy object in order to ensure that the router did, indeed, trigger the proper route.

I also start the Backbone.history object. Because the history object may have already been started, I do this within a try-catch block. I also avoid the initial Backbone routing check by passing { silent : true } to the history object:

[...]
try {
    Backbone.history.start({silent: true});
} catch (e) {
}
this.router.navigate("elsewhere");
[...]

You'll also notice that I do an initial navigation step here. This is done to ensure that on any future navigation, the router will see me as coming to the URL I'm testing fresh.

Now I am free to begin testing routes!

I start by testing a route I know does not exist:

it("does not fire for unknown paths", function () {
    this.router.navigate("unknown", true);
    expect(this.routeSpy.notCalled).toBeTruthy();
});

I make sure that the spy was not called. I am interrogating SinonJS specific properties of the spy object. Feel free to browse through the SinonJS API for more useful properties.

Now I want to test that I call my default path properly:

it("fires the default root with a blank hash", function () {
    this.router.navigate("", true);
    expect(this.routeSpy.calledOnce).toBeTruthy();
    expect(this.routeSpy.calledWith(null)).toBeTruthy();
});

What's also nice here is that I am able to test that the route was called with or without a parameter. The default route should not be called with any parameter. However, calling the plant/ route does contain a parameter:

it("fires the plant path with a test plant", function () {
    this.router.navigate("plant/testPlant", true);
    expect(this.routeSpy.calledOnce).toBeTruthy();
    expect(this.routeSpy.calledWith("testPlant")).toBeTruthy();
});

This is the extent of my testing the router. The router really doesn't do much more in my application aside from parsing incoming paths and creating the proper views. I do talk about trying to go a bit further with router testing in section 4.

Testing Models

Testing Models is also pretty straightforward. In some tests I check that setting and getting properties on a model works properly:

it("should accept accept two strings and a boolean", function () {
    var pType = {
        'name': 'test name',
        'color': 'test color',
        'fruit_bearing': false
    }
    var exp = new Plant(pType);
    expect(exp).not.toBe(null);
    expect(exp.get('name')).toBe(pType.name);
    expect(exp.get('color')).toBe(pType.color);
    expect(exp.get('fruit_bearing')).toBe(pType.fruit_bearing);
});

I use SinonJS to create a spy to bind to the Model's change event and update the model to test that the change event has fired:

it("should trigger change events when properties change", function () {
    var pType = {
        'name': 'test name'
    };
    var spy = sinon.spy();
    var exp = new Plants(pType);
    exp.bind('change', spy);

    exp.set('name', 'different name');
    expect(spy.called).toBeTruthy();
});

I also check for validation:

it("should not validate with incorrect arguments", function () {
    var pType = {
        'namez': 'test name'
    };

    var exp = new Plants(pType);
    expect(exp.isValid()).toBeFalsy();
    expect(exp.validationError).toBe("name is required");
});

Testing Collections

In order to test Collections, I use Sinon to create a fake server so the Collection can attain data and I interrogate the results. In the beforeEach function, I create the fake server:

var gardenPlants = [
    {
        "name": "olbrich",
        "plants" : new Plants([{name : 'plant1'},{name : 'plant2'},{name : 'plant3'}])
    },
    {
        "name": "arboretum",
        "plants" : new Plants([{name : 'plant4'},{name : 'plant5'},{name : 'plant6'}])
    },
    {
        "name": "rotary",
        "plants" : new Plants([{name : 'plant7'},{name : 'plant8'},{name : 'plant9'}])
    }];
beforeEach(function () {
    this.server = sinon.fakeServer.create();
    this.server.respondWith("GET", "data/garden", [
        200,
        {"Content-Type": "application/json"},
        JSON.stringify(gardenPlants)
    ]);
    this.server.respondWith("GET", "data/garden/olbrich", [
        200,
        {"Content-Type": "application/json"},
        JSON.stringify(gardenPlants[0])
    ]);
    this.server.respondWith("GET", "data/garden/arboretum", [
        200,
        {"Content-Type": "application/json"},
        JSON.stringify(gardenPlants[1])
    ]);
    this.server.respondWith("GET", "data/garden/rotary", [
        200,
        {"Content-Type": "application/json"},
        JSON.stringify(gardenPlants[2])
    ]);
});

Then in the tests, I just call against the URLs I defined in the fake server and test:

describe("creating a Garden that fetches", function () {
    it("should make a call out to the server", function () {
        var exp = new GardenPlants({ id: 'olbrich' });
        exp.fetch();
        this.server.respond(); // Remember to use the server's respond function
        expect(this.server.requests.length).toEqual(1);
        expect(this.server.requests[0].method).toEqual("GET");
        expect(this.server.requests[0].url).toEqual("data/garden/olbrich");
        expect(exp.get('olbrich')).not.toBe(undefined);
        expect(exp.get('olbrich').get('plants')[0]).not.toBe(undefined);
        expect(exp.get('olbrich').get('plants')[0].name).toBe("plant1");
        expect(exp.get('olbrich').get('plants')[1]).not.toBe(undefined);
        expect(exp.get('olbrich').get('plants')[1].name).toBe("plant2");
        expect(exp.get('olbrich').get('plants')[2]).not.toBe(undefined);
        expect(exp.get('olbrich').get('plants')[2].name).toBe("plant3");
    });
});

I also restore the test server at the end of each test:

afterEach(function () {
    this.server.restore();
});

Finally, I test creating Collections without calling the test server by using the pre-defined object I created at the top of the test suite:

it("should accept a Garden/Plants object", function () {
    var exp = new GardenPlants(gardenPlants);
    expect(exp).not.toBe(null);
    expect(exp.length).toBe(3);
    expect(exp.get('olbrich')).not.toBe(undefined);
    expect(exp.get('olbrich').get('plants')).not.toBe(undefined);
    expect(exp.get('olbrich').get('plants').keys().length).toBe(3);
    expect(exp.get('olbrich').get('plants').attributes[0].name).toBe('plant1');
})

Testing Views

Testing views was a bit trickier than the other tests I've written. Views have collections that need to fetch data, as shown above. They also write to a specific container in the document object. The way I wrote the Views, they write to a DOM node with the id of "container".

In the beforeEach function on Jasmine specs for views, I not only create a server, but I also append a div to the document body with the id the view expects:

beforeEach(function () {
    var plant = {
        'name': 'test name',
        'color': 'test color',
        'fruit_bearing': true
    }
    $('body').append($("<div />").attr("id", "container"));
    this.server = sinon.fakeServer.create();
    this.server.respondWith("GET", "data/plant/test", [
        200,
        {"Content-Type": "application/json"},
        JSON.stringify(plant)
    ]);
});

The afterEach function cleans up both the server and the DOM:

afterEach(function () {
    $('#container').remove();
    this.server.restore();
});

Once we have the DOM for the view to append to, the View test is pretty straightforward:

describe("Instantiation", function () {
    it("should describe a test plant", function () {
        this.view = new PlantView({ id : 'test'});
        this.view.setElement('#container');
        this.view.model.fetch({reset: true});
        this.server.respond();
        expect(this.view.$el.html()).toContain("test name");
        expect(this.view.$el.html()).toContain("test color");
        expect(this.view.$el.html()).toContain("true");
    });
});

With all of the tests written, I can compile the project and have the jasmine-maven-plugin run all of my specs:

$  mvn clean install
[INFO] Scanning for projects...
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] Building amd-jasmine-mvn 0.0.1-SNAPSHOT
[INFO] ------------------------------------------------------------------------

[...]

-------------------------------------------------------
 J A S M I N E   S P E C S
-------------------------------------------------------
[INFO]
Plants
  creating Plants
    should be Backbone model
    should accept accept a name
    should not validate with incorrect arguments
    should trigger change events when properties change

GardenPlants Collection
  creating GardenPlants Collection
    should be Backbone collection
    should accept a Garden/Plants object
  creating a Garden that fetches
    should make a call out to the server

Gardens
  creating Gardens
    should be Backbone model
    should accept accept a name
    should not validate with incorrect arguments
    should trigger change events when properties change

Garden
  creating Garden
    should be Backbone collection
    should accept a Garden array
  creating a Garden that fetches
    should make a call out to the server

Plant
  creating Plant
    should be Backbone model
    should accept accept two strings and a boolean
    should not validate with incorrect arguments
    should trigger change events when properties change

DefaultView
  Instantiation
    should yield a welcome notification

PlantView
  Instantiation
    should describe a test plant

GardensView
  Instantiation
    should have a list of available gardens

Router
does not fire for unknown paths
fires the default root with a blank hash
fires the gardens path
fires the plant path with a test plant
fires the garden path with a test garden

Results: 26 specs, 0 failures, 0 pending

[...]

[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 37.176 s
[INFO] Finished at: 2015-07-26T17:15:45-05:00
[INFO] Final Memory: 27M/380M
[INFO] ------------------------------------------------------------------------

And of course I can still run the maven jasmine:bdd goal and test in-browser:

$  mvn jasmine:bdd
[INFO] Scanning for projects...
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] Building amd-jasmine-mvn 0.0.1-SNAPSHOT
[INFO] ------------------------------------------------------------------------
[INFO]
[INFO] --- jasmine-maven-plugin:2.0-beta-02:bdd (default-cli) @ amd-jasmine-mvn ---
[INFO] jetty-8.1.14.v20131031
[INFO] Started SelectChannelConnector@0.0.0.0:8234
[INFO]

Server started--it's time to spec some JavaScript! You can run your specs as you develop by visiting this URL in a web browser:

 http://localhost:8234

The server will monitor these two directories for scripts that you add, remove, and change:

  source directory: src/main/webapp/scripts

  spec directory: src/main/webapp/test/spec

Just leave this process running as you test-drive your code, refreshing your browser window to re-run your specs. You can kill the server with Ctrl-C when you're done.

The output:

Jasmine Pass

TODO

With the progress made here, you should be able to start building applications using Backbone, RequireJS, Jasmine and SinonJS. I did not do a very deep dive into testing here as it was made more as a proof of concept. However, there was a road block I ran into when writing these tests. When testing the router, I wanted to be able to spy on the View that the Router creates in order to make sure that it is properly creating it. The problem here is that when the spec that tests the Router imports the Router, the Router automatically imports the Views. Once the Views are loaded into the Router, there is no easy way to bind to them to create spies through Sinon. This is because the Views don't actually live in the Router object. They are created at run-time when the client triggers a route.

What is needed is dependency injection into RequireJS. Enter SquireJS!

SquireJS allows us to create mock RequireJS modules and re-import them as dependencies into objects via asynchronous require calls.

There is a great New York Times blog about how this can be used to mock out RequireJS modules to use in your tests. It does become a bit complex as it uses asynchronous loading and Jasmine's async testing methods to make it happen.

I was unable to get this working myself but I am guessing I was doing something wrong. Here's a great article on this.

Conclusion

Armed with a decent workflow for constructing modular, testable projects with Backbone serving as Model-View framework, I now plan on building out my knowledge on these libraries by actually applying them to projects I write on the job. I thoroughly enjoy the way projects are organized between these libraries and I feel that RequireJS really ties together the M-V framework and testing in an easy, intuitive manner.

Feel free to fork this Gist and make pull requests with any mistakes you find or improvements from your own experience.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment