Saturday, November 19, 2011

Standalone Route Testing in Backbone.js

‹prev | My Chain | next›

Last night I was able to convert my jasmine test suite from the jasmine ruby gem to a standalone (loaded directly from the filesystem):

(note the file:///home/cstrom/repos/calendar/spec/SpecRunner.html URL)

I am pleasantly surprised that I can still simulate network operations with sinon.js even though I am no longer loading from the jasmine ruby gem's server. Nearly all of those specs include something along the lines of, if the Backbone.js application POSTs to the server with this data, then respond with that data. And it all works.

Except... in order to get things to work, I had to disable my default route in the Backbone application itself:
  var Routes = Backbone.Router.extend({
    routes: {
      "": "setDefault",
      // ...
    },

    setDefault: function() {
      console.log("[setDefault]");
      var month = Helpers.to_iso8601(new Date).substr(0,7);
      //window.location = '/#month/' + month;
    },
    // ...
  });
If I uncomment that line and reload my spec, the default route kicks in and redirects the browser to file:///#month/2011-11:


If that were an HTTP URL, then the window.location would include the hostname in the URL, meaning that the new window.location would be something like http://example.com/#2011-11. That would work just fine with push state and the Backbone router. Since I am file based, however, window.location is empty and window.location gets assigned to /#2011-11, which Firefox interprets as the root directory on my machine.

I can see working around this in a couple of ways. First, my spec could assign a window.location that would by-pass the default route. Second, I might alter my code to skip the default route if window.location is empty (e.g. the application is running under test).

Today, however, I would like to see if I can stub out that call to Router#setDate. To do so, I would need to use the spyOn() method from jasmine. Something along the lines of:
spyOn(Routes.prototype, 'setDefault').andCallFake(function() {
  console.log("fake Routes#setDefault");
  window.calendar.application.setDate("2011-11");
});
That establishes a spy on the setDefault() function of the Routes prototype. In other words, it spies on the setDefault() method of any Routes object—like the one that is used in my application. When that spy sees the setDefault() method called, it performs the application.setDate() call that would have been applied by the Route. So I am doing what the route would normally do—pretending to be the route, but without setting window.location to a potentially bogus file-based URL.

Except that does not work. I am encapsulating the router and everything else in my application inside my namespaced application:
window.Cal = function(root_el) {
  var Models = (function() {
    // ...
    return {Appointment: Appointment};
  })();

  var Collections = (function() { /* ... */ })();

  var Views = (function() { /* ... */ })();

  var Routes = Backbone.Router.extend({ /* ... */  });

  // Initialize the app and routes
  // ...
  new Routes({application: application});
  try {
    Backbone.history.start();
  } catch (x) {
    console.log(x);
  }

  return {
    Models: Models,
    Collections: Collections,
    Views: Views,
    Helpers: Helpers,
    appointments: appointments,
    application: application
  };
};
Neither the Routes class nor the instance are returned as properties of my Cal application object. Even if the instance were returned, it would be too late to spy on it—the instance would have already fired up the default route.

Similarly, I cannot export the Routes class as window.Routes because it would not be defined until the application is instantiated. Creating a new instance of the application class would create the routing instance as well as defined the class. So again, I cannot spy on the route that way.

The only avenue that I see open to me is to move the routing definition outside of the application class definition:

window.Cal = function(root_el) {
  // ...

  new Routes({application: application});

  // ...
};

window.Routes = Backbone.Router.extend({ /* ... */  });
With that, I can spy on the setDefault() function of the Routes prototype. However, I cannot invoke the application view's setDate() method:
  beforeEach(function() {
    // ...

    spyOn(Routes.prototype, 'setDefault').andCallFake(function() {
      console.log("fake Routes#setDefault");
      window.calendar.application.setDate("2011-11");})();
    });

    window.calendar = new Cal($('#calendar'));

    // ...
  });
If I run my specs like that, I received an undefined "calendar" error:


This is because my calendar variable is not assigned until the new Cal instance is defined. Unfortunately, before it is completely instantiated, the routing is instantiated, which prematurely fires the Routing spy.

So this leads to a second compromise, which is to do nothing when the spy sees a setDefault() call. I can then explicitly call application.setDate() after the calendar Backbone application is instantiated (and after the routing is ready):
    spyOn(Routes.prototype, 'setDefault');

    window.calendar = new Cal($('#calendar'));
    window.calendar.application.setDate("2011-11");
With that, I have my test passing again:


I am none too convinced that this is a good approach. It is nice to know that it can be done, but defining the routes as a global variable just to support testing feels wrong. I may simply be probing the point at which a Backbone application needs to move from standalone specs to a jasmine server. Still, I have other options for how to deal with this. I will pick back up tomorrow exploring those.


Day #210

No comments:

Post a Comment