32

So I've just started to write tests for my in-progress javascript app, using sinon.js & jasmine.js. Works pretty well overall, but I need to also be able to test my routers.

The routers, in their current state, will trigger an number of views and other stuff, terminating the current jasmine.js test by invoking Backbone.navigate dependent on application state and UI itneraction.

So how could I test that routing to different locations would work, while keeping the routers "sandboxed" and not allowing them to change route?

Can I set up some sort of mock function that will monitor pushState changes or similar?

ggozad
  • 13,105
  • 3
  • 40
  • 49
Industrial
  • 41,400
  • 69
  • 194
  • 289

6 Answers6

38

Here's a low-levelish way of doing it with jasmine, testing that pushState works as expected and that your router sets up things properly... I assume a router that has been initialized and has a home route mapped to ''. You can adapt this for your other routes. I also assume you've done in your app initialization a Backbone.history.start({ pushState: true });

    describe('app.Router', function () {

        var router = app.router, pushStateSpy;

        it('has a "home" route', function () {
            expect(router.routes['']).toEqual('home');
        });

        it('triggers the "home" route', function () {
            var home = spyOn(router, 'home').andCallThrough();
            pushStateSpy = spyOn(window.history, 'pushState').andCallFake(function (data, title, url) {
                expect(url).toEqual('/');
                router.home();
            });
            router.navigate('');
            expect(pushStateSpy).toHaveBeenCalled();
            expect(home).toHaveBeenCalled();
            ...
        });
    });  

You can effectively achieve similar things by doing Backbone.history.stop(); it's meant for this reason.

UPDATE: Browsers with no pushState:

This of course will work fine if your browser you test on has support for pushState. If you test against browsers that don't, you can conditionally test as follows:

it('triggers the "home" route', function () {
    var home = spyOn(router, 'home').andCallThrough();

    if (Backbone.history._hasPushState) {
        pushStateSpy = spyOn(window.history, 'pushState').andCallFake(function (data, title, url) {
            expect(url).toEqual('/');
            router.home();
        });
        router.navigate('', {trigger: true});
        expect(pushStateSpy).toHaveBeenCalled();
        expect(home).toHaveBeenCalled();

    } else if (Backbone.history._wantsHashChange) {
        var updateHashSpy = spyOn(Backbone.history, '_updateHash').andCallFake(function (loc, frag) {
            expect(frag).toEqual('');
            router.home();
        });
        router.navigate('', {trigger: true});
        expect(updateHashSpy).toHaveBeenCalled();
        expect(home).toHaveBeenCalled();
    }
});

If you are on IE6, good luck.

ggozad
  • 13,105
  • 3
  • 40
  • 49
  • Hi! That looks nice, but I can't see how that prevents the actual logic from inside `router.home()` from invoking, as mentioned above – Industrial Feb 15 '12 at 22:28
  • The key is to stop history from changing your location. This you get to do by spying on window.history, verifying that it works and then calling the router without changing your location. Then in the above example you can check that home() has created your views, altered the DOM or whatever else you are doing there. – ggozad Feb 15 '12 at 23:04
  • If you also want to step inside the login in router.home() you can of course by: homeSpy = spyOn(router, 'home').andCallFake(...); But without the pushStateSpy you'll never get there. – ggozad Feb 16 '12 at 07:03
  • Oh. But what about getting the eventual URI arguments for a route method using this solution? – Industrial Feb 16 '12 at 09:19
  • you can check for everything in your spies no? – ggozad Feb 16 '12 at 15:32
  • But is this way of testing routers really appropriate? Now the `pushStateSpy` becomes responsible for calling the appropriate router method for a URI, instead of the router itself. Right? – Industrial Feb 17 '12 at 10:18
  • Well, if you DO NOT replace pushState, it will just trigger your route (change your url if you like). Your tests are now gone ;) So yes, this is correct if you do want to inspect that pushState will work appropriately. Alternatively as I mention DO have a look at Backbone.history.stop(). The only purpose of it being there is for test runners to behave. If you are not interested in observing window.history just stop it, remove the pushStateSpy and keep the rest. – ggozad Feb 17 '12 at 10:33
  • In terms of "appropriate": it is a ver common pattern in jasmine to andCallFake to do exactly that. Test that something would work and do it manually avoiding complexities. It is essentially mocking – ggozad Feb 19 '12 at 07:48
  • This works great in IE9, but fails in IE8 due to window.history.pushState being undefined. Is there a clean way to test pushState only if the browser supports it? – Dave Cadwallader Jul 31 '12 at 17:20
  • @ggozad: Forgot to say thanks for that last snippet! This was a huge help in getting my test specs up and running for my Backbone.Subroute project. – Dave Cadwallader Aug 28 '12 at 16:34
  • @ggozad Thanks for this answer. I've created a helper function with a variant of your solution which also mocks out ``getHash`` to ensure that the tests have a consistent understanding of what hash is in play. – Andy Armstrong Sep 22 '15 at 21:49
9

When I'm testing a backbone router, what I care about is that the routes I provided are invoking the functions I specify with the correct arguments. A lot of the other answers here aren't really testing that.

If you need to test the functionality of some routes, you can test those functions by themselves.

Assuming you have a simple router:

App.Router = Backbone.Router.extend({
  routes: {
    '(/)':'index',
    '/item/:id':'item'
  },
  index: {
    //render some template
  }, 
  item: {
    //render some other template, or redirect, or _whatever_
  }
});

Here's how I do it:

describe('Router', function() {

  var trigger = {trigger: true};
  var router

  beforeEach(function() {
    // This is the trick, right here:
    // The Backbone history code dodges our spies
    // unless we set them up exactly like this:
    Backbone.history.stop(); //stop the router
    spyOn(Router.prototype, 'index'); //spy on our routes, and they won't get called
    spyOn(Router.prototype, 'route2'); 

    router = new App.Router(); // Set up the spies _before_ creating the router
    Backbone.history.start();
  });

  it('empty route routes to index', function(){
    Backbone.history.navigate('', trigger);
    expect(router.index).toHaveBeenCalled();
  });

  it('/ routes to index', function(){
    router.navigate('/', trigger);
    expect(router.index).toHaveBeenCalled();
  });

  it('/item routes to item with id', function(){
    router.navigate('/item/someId', trigger);
    expect(router.item).toHaveBeenCalledWith('someId');
  });
});
Chris Jaynes
  • 2,868
  • 30
  • 29
  • This is exactly what I was looking for. Thanks for the inline comments explaining your reasoning behind this as well! – broox Jun 10 '14 at 03:33
4

Here's what I ended up using myself. I made a mock version of the router by extending it and overriding the methods with a blank method to prevent it from invoking any further logic when being called:

describe("routers/main", function() {

    beforeEach(function() {

        // Create a mock version of our router by extending it and only overriding
        // the methods
        var mockRouter = App.Routers["Main"].extend({
            index: function() {},
            login: function() {},
            logoff: function() {}
        });

        // Set up a spy and invoke the router
        this.routeSpy = sinon.spy();
        this.router = new mockRouter;

        // Prevent history.start from throwing error
        try {
            Backbone.history.start({silent:true, pushState:true});
        } catch(e) {

        }

        // Reset URL
        this.router.navigate("tests/SpecRunner.html");
    });

    afterEach(function(){
        // Reset URL
        this.router.navigate("tests/SpecRunner.html");
    });

    it('Has the right amount of routes', function() {
        expect(_.size(this.router.routes)).toEqual(4);
    });

    it('/ -route exists and points to the right method', function () {
        expect(this.router.routes['']).toEqual('index');
    });

    it("Can navigate to /", function() {
        this.router.bind("route:index", this.routeSpy);
        this.router.navigate("", true);
        expect(this.routeSpy.calledOnce).toBeTruthy();
        expect(this.routeSpy.calledWith()).toBeTruthy();
    });

});

Note that sinon.js is used above to create the spy, along with underscore.js to provide the size function.

Industrial
  • 41,400
  • 69
  • 194
  • 289
  • I know this is an older question, but it's high up in Google results, and I think people can do better. See my answer below. – Chris Jaynes Mar 01 '14 at 05:08
2

There is a very good tutorial about testing backbone:

http://tinnedfruit.com/2011/04/26/testing-backbone-apps-with-jasmine-sinon-3.html

Michal
  • 608
  • 1
  • 5
  • 12
  • Thanks. Already seen that, but it won't help me to get around the fact that my routers invoke redirections and so on... – Industrial Feb 13 '12 at 10:37
1

You have to mock Backbone.Router.route which is the function that is internally used to bind the functions on to Backbone.History.

Thats the original function:

route : function(route, name, callback) {
  Backbone.history || (Backbone.history = new Backbone.History);
  if (!_.isRegExp(route)) route = this._routeToRegExp(route);
  Backbone.history.route(route, _.bind(function(fragment) {
    var args = this._extractParameters(route, fragment);
    callback.apply(this, args);
    this.trigger.apply(this, ['route:' + name].concat(args));
  }, this));
}

you could to something like this, which simply call the functions when the router will be initialized:

Backbone.Router.route = function(route, name, callback) {
    callback();
}

You could also save the callbacks in a object and with the route as name and call same steps by step:

var map = {}
Backbone.Router.route = function(route, name, callback) {
    map[route] = callback();
}

for(i in map){
    map[i]();
}
Andreas Köberle
  • 106,652
  • 57
  • 273
  • 297
  • Hi Andreas. Still can't get it working with your code - the logic inside the router method is still not halted – Industrial Feb 13 '12 at 13:38
0

I started out using ggozad's solution of spying on _updateHash which partially worked for me. However, I discovered that my tests were confused because the hash never updated, so code that relied upon calls to getHash or getFragment were failing.

What I ended up with is the following helper function that spies on both _updateHash and getHash. The former records the request to update the hash, and the latter returns the last hash that was passed to _updateHash. I call this helper function in my tests before I start the Backbone history.

    /**
     * Prevent Backbone tests from changing the browser's URL.
     *
     * This function modifies Backbone so that tests can navigate
     * without modifying the browser's URL. It works be adding
     * stub versions of Backbone's hash functions so that updating
     * the hash doesn't change the URL but instead updates a
     * local object. The router's callbacks are still invoked
     * so that to the test it appears that navigation is behaving
     * as expected.
     *
     * Note: it is important that tests don't update the browser's
     * URL because subsequent tests could find themselves in an
     * unexpected navigation state.
     */
    preventBackboneChangingUrl = function() {
        var history = {
            currentFragment: ''
        };

        // Stub out the Backbone router so that the browser doesn't actually navigate
        spyOn(Backbone.history, '_updateHash').andCallFake(function (location, fragment, replace) {
            history.currentFragment = fragment;
        });

        // Stub out getHash so that Backbone thinks that the browser has navigated
        spyOn(Backbone.history, 'getHash').andCallFake(function () {
            return history.currentFragment;
        });
    };
Community
  • 1
  • 1
Andy Armstrong
  • 688
  • 6
  • 8