arches.io Testing Callbacks with Jasmine Spies

17 Dec 2012

(Note: if you aren't using jasmine and jasmine-jquery to test your javascript go take care of that)

Suppose we're working on a sliding tile game, and we want to log the moves a player has made. We could create a Logger object to collect the moves, and use a callback in the Game method to update the logger. One of the simplest ways to implement a callback is with standalone function:

Game.after_move = function() {
  for (var i = 0; i < this.callbacks.length; i++) {

    // it's a function - just execute it, passing the move
    this.callbacks[i](this.last_move);
  }
}

When the move is finished, the Game object will loop through all its callback functions and execute them. Now we can add our logger as a callback:

Logger.log = function(last_move) {
  this.moves.push(last_move);
}

var game = new Game();
var logger = new Logger();

game.add_callback(logger.log); // not quite!

We've lost our scope. When we passed the logger.log function to the game, there's nothing to tie it to the logger instance we created. We can use a closure to maintain the scope:

var game = new Game();
var logger = new Logger();

game.add_callback(function(move){

  // we still have access to the outer scope
  // in here, so the logger variable still
  // holds the Logger we made
  logger.log(move);
});

We want to test that our callbacks are being called. Normally a jasmine spy looks like this:

describe("when the user submits the form", function(){
  it("submits via ajax", function(){
    spyOn(jQuery, 'ajax');
    $("form").submit();
    expect(jQuery.ajax).toHaveBeenCalled();
  });
});

Notice that the spy consists of two parts, an object (jQuery) and a method (ajax). We're going to need the same thing. And we have it, kinda:

describe("after moving the tile", function(){
  it("calls the callback", function(){
    var game = new Game();
    var logger = new Logger();
    spyOn(logger, log);

    // we have to use the same closure technique as
    // above, to maintain scope
    game.add_callback(function(){
      logger.log();
    });
    game.after_move();

    expect(logger.log).toHaveBeenCalled();
  });
});

However, we shouldn't use Logger in our Game test. It introduces an unnecessary dependency. Game observers could be anything. So let's replace the logger with an object just for this test:

describe("after moving the tile", function(){
  it("calls the callback", function(){
    var game = new Game();
    var observer = {callback: function(){}};

    // spies always need an object and a method
    spyOn(observer, callback);

    // closure to maintain scope inside game
    game.add_callback(function(){
      observer.callback();
    });
    game.after_move("...description of move...");

    expect(observer.callback).toHaveBeenCalled();
  });
});

Anything less than this in terms of the observer is insufficient. It must be an object and it must have a method. So there we have it! A simple callback, a relatively straightforward and isolated test, and all scope is maintained by somewhat verbose but easily readable closures. A very serviceable implementation, and one which I use regularly.