6

In the following unit test code:

TestModel = Backbone.Model.extend({
    defaults: {
        'selection': null
    },
    initialize: function() {
      this.on('change:selection', this.doSomething);
    },
    doSomething: function() {
        console.log("Something has been done.");
    }
});

module("Test", {
    setup: function() {
        this.testModel = new TestModel();
    }
});

test("intra-model event bindings", function() {
    this.spy(this.testModel, 'doSomething');
    ok(!this.testModel.doSomething.called);
    this.testModel.doSomething();
    ok(this.testModel.doSomething.calledOnce);
    this.testModel.set('selection','something new');
    ok(this.testModel.doSomething.calledTwice); //this test should past, but fails.  Console shows two "Something has been done" logs.
});

The third ok fails, even though the function was effectively called from the backbone event binding, as demo'd by the console.

3rd test fails enter image description here

This is very frustrating and has shaken my confidence on whether sinon.js is suitable for testing my backbone app. Am I doing something wrong, or is this a problem with how sinon detects whether something has been called? Is there a workaround?

EDIT: Here's a solution to my specific example, based on the monkey patch method of the accepted answer. While its a few lines of extra setup code in the test itself, (I don't need the module function any more) it gets the job done. Thanks, mu is too short

test("intra-model event bindings", function() {
    var that = this;
    var init = TestModel.prototype.initialize;
    TestModel.prototype.initialize = function() {
        that.spy(this, 'doSomething');
        init.call(this);
    };

    this.testModel = new TestModel();
    . . . // tests pass!
}); 
B Robster
  • 40,605
  • 21
  • 89
  • 122

1 Answers1

12

Calling this.spy(this.testModel, 'doSomething') replaces the testModel.doSomething method with a new wrapper method:

var spy = sinon.spy(object, "method");

Creates a spy for object.method and replaces the original method with the spy.

So this.spy(this.testModel, 'doSomething') is effectively doing something like this:

var m = this.testModel.doSomething;
this.testModel.doSomething = function() {
    // Spying stuff goes here...
    return m.apply(this, arguments);
};

This means that testModel.doSomething is a different function when you bind the event handler in initialize:

this.bind('change:selection', this.doSomething);

than it is after you've attached your spying. The Backbone event dispatcher will call the original doSomething method but that one doesn't have the Sinon instrumentation. When you call doSomething manually, you're calling the new function that spy added and that one does have the Sinon instrumentation.

If you want to use Sinon to test your Backbone events, then you'll have to arrange to have the Sinon spy call applied to the model before you bind any event handlers and that probably means hooking into initialize.

Maybe you could monkey-patch your model's initialize to add the necessary spy calls before it binds any event handlers:

var init = Model.prototype.initialize;
Model.prototype.initialize = function() {
    // Set up the Spy stuff...
    init.apply(this, arguments);
};

Demo: http://jsfiddle.net/ambiguous/C4fnX/1/

You could also try subclassing your model with something like:

var Model = Backbone.Model.extend({});
var TestModel = Model.extend({
    initialize: function() {
        // Set up the Spy stuff...
        Model.prototype.initialize.apply(this, arguments);
    }
});

And then use TestModel instead of Model, this would give you an instrumented version of Model in TestModel without having to include a bunch of test-specific code inside your normal production-ready Model. The downside is that anything else that uses Model would need to be subclassed/patched/... to use TestModel instead.

Demo: http://jsfiddle.net/ambiguous/yH3FE/1/

You might be able to get around the TestModel problem with:

var OriginalModel = Model;
Model = Model.extend({
    initialize: function() {
        // Set up the Spy stuff...
        OriginalModel.prototype.initialize.apply(this, arguments);
    }
});

but you'd have to get the ordering right to make sure that everyone used the new Model rather than the old one.

Demo: http://jsfiddle.net/ambiguous/u3vgF/1/

mu is too short
  • 426,620
  • 70
  • 833
  • 800
  • Makes sense. Excellent answer, thanks! At this point, getting the unit tests working on the model itself should be sufficient. I'll post back if I end up doing something interesting to test callbacks for Views, etc., I'm glad I figured this out early. – B Robster Jun 02 '12 at 20:27
  • Related question that I just found: http://stackoverflow.com/questions/9113186/backbone-js-click-event-spy-is-not-getting-called-using-jasmine-js-and-sinon-js – B Robster Jun 02 '12 at 20:32
  • @Ben: I think monkey patching the `initialize` methods is probably your best bet, that would be fairly low impact and well isolated. I've added some demos to help illustrate the ideas. – mu is too short Jun 02 '12 at 20:37
  • Yep, I updated my test case based on your examples, in the simplest way I could think of, and it worked. See the update to my question, above. – B Robster Jun 02 '12 at 21:09
  • @Ben: That should work. If you had a collection that used a model and you wanted to spy on both then monkey patching the `initialize` methods would probably be the simplest approach. – mu is too short Jun 02 '12 at 21:46
  • I updated my solved problem to use the monkey path initialize method. It does seem simpler, I'd just overlooked it when trying it the first time. – B Robster Jun 02 '12 at 22:07