Evented programming patterns: Testing event-driven apps

This post is part of a series on event-driven programming. The complete series is:

Thus far all the articles in this series have focused on methods for structuring applications to make them more modular and maintainable. They all help in their own way when correctly applied, but all of them leave one major area with something to be desired: scripting.

To be clear, it’s not that these techniques make code unscriptable. The problem is that even though the components may be well-designed and conceptually easy to plug together, writing quick scripts to manipulate an event-driven application can require a lot of boilerplate. One place this really shows up is in tests. Tests should, nay, must be easy to read. If they’re not, then their role as specifications and design/debugging tools is lost. And if tests are easy to write, you end up writing more tests, and you make better software. Wins all around.

But have you tried integration-testing a heavily event-driven app? I don’t care how fancy your test framework is, when every step in a test scenario needs some async work you’re going to end up with this:

ClientSpec = JS.Test.describe(Faye.Client, function() {
  before(function(resume) {
    var server = new Faye.NodeAdapter({mount: '/'})
    server.listen(8000)
    setTimeout(function() {
      resume(function() {
        var endpoint = 'http://0.0.0.0:8000'
        this.clientA = new Faye.Client(endpoint)
        this.clientB = new Faye.Client(endpoint)
      })
    }, 500)
  })

  it('sends a message from A to B', function(resume) {
    clientA.subscribe('/channel', function(message) {
      this.message = message
    }, this)
    setTimeout(function() {
      clientB.publish('/channel', {hello: 'world'})
      setTimeout(function() {
        resume(function() {
          assertEqual( {hello: 'world'}, message )
        })
      }, 250)
    }, 100)
  })
})

All this test does is make sure one client can send a message to another using a Faye messaging server. While working on Faye, I’ve ended up getting the tests into a state where that test reads like this:

Scenario.run('Two clients, single message send',
function() { with(this) {
  server( 8000 )
  client( 'A', ['/channel'] )
  client( 'B', [] )
  publish( 'B', '/channel', {hello: 'world'} )
  checkInbox( 'A', [{hello: 'world'}] )
  checkInbox( 'B', [] )
}})

This should be pretty self-explanatory: start a server on port 8000, make client A subscribe to /channel, make client B with no subscriptions, make B publish the message {hello: 'world'} to /channel, and make sure A and only A received the message.

Now the problem is, executing all those steps synchronously won’t work. There are network delays and other timeouts that need to happen between steps to make the test work. We need an abstraction that lets us write scripts at a very high level like this and hides all the inconsequential (and possibly volatile) glue code between the lines. This is pretty easy to do by breaking the script into two components, which here I’ll call a Scenario and a CommandQueue.

The job of the Scenario is to implement all the script steps involved in running the test. It should have a method for each type of command that accepts the right arguments, and also accepts a callback that it should call when the scenario is ready to continue running steps. For example, let’s get our Faye scenario class started:

Scenario = function() {
  this._clients = {};
  this._inboxes = {};
};

Scenario.prototype.server = function(port, resume) {
  this._port = port;
  var server = new Faye.NodeAdapter({mount: '/'});
  server.listen(port);
  setTimeout(resume, 500);
};

The server() command in our test just takes a port number, so our scenario method needs to take the port number and the resume callback function. It runs resume after a delay to let the server spin up so it’s ready to take requests before we continue our test.

Next up we need the client() method. This takes a name and a list of channels to subscribe to, and the callback as before.

Scenario.prototype.client = function(name, channels, resume) {
  var endpoint = 'http://0.0.0.0:' + this._port,
      client   = new Faye.Client(endpoint);

  channels.forEach(function(channel) {
    client.subscribe(channel, function(message) {
      this._inboxes[name].push(message);
    }, this);
  }, this);

  this._clients[name] = client;
  this._inboxes[name] = [];
  setTimeout(resume, 100);
};

A similar pattern here: we create a channel, make somewhere to store the messages it receives, set up subscriptions, then wait a little for the subscriptions to have time to register with the server. By now the publish() and checkInbox() methods should be fairly predictable:

Scenario.prototype.publish = function(name, channel, message, resume) {
  var client = this._clients[name];
  client.publish(channel, message);
  setTimeout(resume, 250);
};

Scenario.prototype.checkInbox = function(name, messages, resume) {
  assert.deepEqual(messages, this._inboxes[name]);
  resume();
};

Note how in the final method we still accept the resume callback even though the method is synchronous and calls the callback immediately. The job of this component is to provide a convention that in general, you should pass each method a callback and it decides when the program should continue. Here we’ve used timeouts, but it could be after an Ajax call, and animation, a user-triggered event, anything at all.

Now we’ve implemented all the steps, we need something that can run the test as we’ve written it, without callbacks. I’ll call this the CommandQueue: rather than executing commands immediately it stores them in a queue.

CommandQueue = function() {
  this._scenario = new Scenario();
  this._commands = [];
};

CommandQueue.prototype = {
  server: function(port) {
    this.enqueue(['server', port]);
  },
  client: function(name, channels) {
    this.enqueue(['client', name, channels]);
  },
  publish: function(name, channel, message) {
    this.enqueue(['publish', name, channel, message]);
  },
  checkInbox: function(name, messages) {
    this.enqueue(['checkInbox', name, messages]);
  },
};

This implements the API that we want to use in our test, but so far the script is inert: nothing actually gets run. We need a method to run the next command in the queue. This takes the next command off the queue, and adds a callback to the argument list that will run the next command once called. The methods in the Scenario will use that callback to resume the test when ready.

CommandQueue.prototype.runNext = function() {
  var command = this._commands.shift().slice(),
      method  = command.shift(),
      self    = this;

  var resume = function() { self.runNext() };
  command.push(resume);

  this._scenario[method].apply(this._scenario, command);
};

We also need the first command addition to trigger the execution of the queue, so we’ll implement enqueue() to deal with this. We start the execution with a timeout, since if we do it synchronously no more commands will have been added by the time the first command returns.

CommandQueue.prototype.enqueue = function(command) {
  this._commands.push(command);
  if (this._started) return;

  this._started = true;

  var self = this;
  setTimeout(function() { self.runNext() }, 100);
};

As the final piece of glue, we need the Scenario.run() function, which takes our test script and executes it using a CommandQueue to do the work.

Scenario.run = function(testName, block) {
  var commandQueue = new CommandQueue();
  block.call(commandQueue);
};

If you have a lot of test scenarios, you can use another command queue to help sequence them into a single test suite without too much bother. For a more complete example of some of these patterns, you can read through the Faye test suite, which also includes a variation on the above done in Ruby with EventMachine and Test::Unit.

If you’ve enjoyed this article, you might enjoy my recently published book JavaScript Testing Recipes. It’s full of simple techniques for writing modular, maintainable JavaScript apps in the browser and on the server.