This post is part of a series on event-driven programming. The complete series is:
- Events: they’re not just for the DOM, you know
- Observable objects
- Deferrable values
- Asynchronous methods
- First-leg round-up and final remarks
- Object lifecycle
- Asynchronous pipelines
- Testing event-driven apps
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.