Sunday, November 20, 2011

Injecting Test Routes into Backbone.js

‹prev | My Chain | next›

Yesterday I was able to get my standalone Jasmine specs passing even when Backbone.js routing was active. In order to do so, I had to make the routing class a global class rather being entirely encapsulated inside my application.

Global classes in Javascript are still global variables:
window.Routes = Backbone.Router.extend({ /* ... */ });
Defining classes globally in other languages might be standard operating procedure, but in Javascript the global variable thing feels like a bad idea. So I revert that change:
➜  calendar git:(jasmine-standalone) git revert 8f79d0d

Revert "Make routes global so that they can be tested / spied upon."

Globals are a bad idea.

This reverts commit 8f79d0d20875568f5fdacdade1ec5c39c6d532a0.
My goal remains the same as yesterday. Specifically, I would like to be able to run my application under test without the routing kicking in to send the application off to places that it should not go. This is especially problematic when running filesystem-based / standalone specs.

So instead of trying to hook into the router from my specs as I did yesterday, today I try to make my code slightly spec-aware.

My fist attempt is to change the setDefault() router method to perform a check for the jasmine variable:
  var Routes = Backbone.Router.extend({
    routes: {
      "": "setDefault",
      // ...
    },

    setDefault: function() {
      var env = (typeof('jasmine') == 'undefined') ? 'Production' : 'Test';
      return this['setDefault' + env]();
    },

    setDefaultProduction: function() {
      console.log("[setDefault]");
      var month = Helpers.to_iso8601(new Date).substr(0,7);
      window.location = '/#month/' + month;
    },

    setDefaultTest: function() {
      console.log("[setDefaultTest]");
    },
    //...
  });
If jasmine is not present, the router can call the usual routing method, now renamed as setDefaultProduction(). If jasmine is defined, then the routing object will invoke the setDefaultTest() method, which is a simple no-op.

And that works:

That works, but, wow, do I dislike production code that serves no purpose other than to support testing. I can do a bit better.

Instead of explicitly tying my application code to testing code, I can provide a mechanism for the test to inject a default route. To support this, I add a second options argument for my Backbone application's constructor:
window.Cal = function(root_el, options) {
  // ...
  var defaultRoute = (options || {}).defaultRoute;

  var Routes = Backbone.Router.extend({
    // ...
    routes: {
      "": "setDefault",
      // ...
    },

    setDefault: function() {
      if (typeof(defaultRoute) == "function") {
        return defaultRoute.call(this);
      }
      
      return this.setDefaultProduction();
    },
    //...
  });
};
If that options argument contains a "defaultRoute" attribute, I set it to a local defaultRoute variable. Then, when my router tries to call setDefault() I can add a guard clause that will call the provided routing method.

Back in my test setup, I can then inject a NOP default route:
  beforeEach(function() {
    // ...
    window.calendar = new Cal($('#calendar'), {
      defaultRoute: function() {
        console.log("[defaultRoute] NOP");
      }
    });
With that, I have my tests passing again. I have introduced only a little new code and none of it explicitly references my testing code. I do have a new construct that is only useful for test support at the moment. But, better a slight violation of YAGNI than including testing code in my production code.


Day #210

1 comment:

  1. this seems to be a side-effect of running jasmin via the browser. Maybe it's worth trying to run jasmin on the command line so that each test can have an isolated environment?

    Also, window.location is not the right way to set the route. Use Backbone.history.navigate('#month/1') (no leading slash!)

    ReplyDelete