Testing Asynchronous JavaScript Drupal Apps with QUnit
As one
blogger has put it: “we cannot avoid testing javascript
anymore”…especially when it comes to testing sophisticated
client-side web apps that integrate with our servers via various APIs. As we
begin to build more complex interactive functionality and web applications,
making sure that our JS code is working correctly becomes central to our QA
process. Tools like Drupal’s SimpleTest
implementation are great for unit testing our server-side code and basic
input-output expectations, but effective testing of advanced client-side
functionality requires a robust, fast, JavaScript based solution.
Following up on our recent post introducing
the Backbone module for Drupal, this post will cover how to test Drupal
JS apps using the QUnit module.
We’ve chosen QUnit in large part because its GPL lisence makes it a good
fit for Drupal development, and the QUnit module is a mature, well designed
implementation.
Basic QUnit tests are extremely easy to set up for Drupal, and
JavaScript’s functional roots make simple testing a breeze. Testing the
sort of asynchronous code used in our Backbone applications can present a few
trickier challenges, but some simple patterns using jQuery queues and callbacks
can help us get around them and efficiently test our apps within the Drupal
testing toolset.
A Quick QUnit Intro
If you’re just getting started with QUnit, I’d recommend looking
over the jQuery site’s QUnit
docs and some of the Drupal QUnit module’s tests.
The basic structure of a Drupal QUnit test is quite simple: we create a new
object within the Drupal.test namespace that specifies our test, something like
“Drupal.test.myTest”. This object has a “getInfo”
property (“Drupal.test.myTest.getInfo”) that returns an object with
name and description information, a “test” method that runs the
actual test, as well as “setup” and “teardown” methods
that run at the start and end of the test, respectively. Throughout all of
these we have access to a shared “this” object.
Within our tests, we can use test assertions provided from the QUnit library
like ok and equals to
validate that the javascript is functioning correctly, and an expect to check and
make sure the correct number of assertions have been run. If one of our
assertions fails, the test may exit early, and our expect will indicate that
the test did not execute properly.
An simple QUnit test, then, will look something like this:
Drupal.tests.myAppTest = {
getInfo: function() {
return {
name: 'App Test Example',
description: 'Example of a Drupal QUnit test.',
group: 'Test'
};
},
setup: function() {
this.testVar = "test";
},
test: function() {
expect(2);
ok(this.testVar, Drupal.t('Property "testVar" is defined".'));
equals(this.testVar, "test",
Drupal.t('Property "testVar" has correct value.'));
},
teardown: function() {
this.testVar = null;
}
};
The Gotcha
After reading over the basics, some programmers (such as this author) might
be inclined to write a test like the following one to validate some simple
behaviors in a Drupal Backbone app (the code is also available at this
gist):
// This does not work with asynchronous operations.
(function($) {
Drupal.tests.myBackboneApp = {
getInfo: function() {
return {
name: 'Backbone App Example',
description: 'Example of a Backbone test in Drupal QUnit',
group: 'BackboneApp'
};
},
setup: function() {
this.testNodeNew = new Drupal.Backbone.NodeModel({
'title': 'Backbone Test Node',
'type': 'page'
});
this.testNodeLoad = new Drupal.Backbone.NodeModel();
},
test: function() {
expect(3);
ok(this.testNodeNew.isNew(),
Drupal.t('New model isNew() is true before saving.'));
this.testNodeNew.save();
ok(!this.testNodeNew.isNew(),
Drupal.t('New model isNew() is false after saving.'));
this.testNodeLoad.set('nid', this.testNodeNew.get('nid'));
this.testNodeLoad.fetch();
equals('Backbone Test Node', this.testNodeLoad.get('title'),
Drupal.t('Title saved and retrieved correctly.'));
},
teardown: function() {
this.testNode.destroy();
}
};
})(jQuery);
If you have a bit of experience with asynchronous code you might see the problem
immediately: our call to this.testNode.save()
returns as soon as it has
finished making the request to the server to save, not when it has received
the server’s response. This means that there is no guaruntee
testNodeNew has received its nid from the server when we attempt to fetch it
into our testNodeLoad object. In fact, we might run through our entire test
suite before the first save operation has even completed!
A Messy Solution
In order to properly handle these asynchronous requests we need to do two
things: use callbacks to properly sequence our server-dependent operations, and
stop QUnit’s aysnchronous testing so it waits for our app tests to
complete.
Stopping QUnit’s other tests is fairly trivial: though QUnit’s
offers an asyncTest
object is not currently supported
by the Drupal QUnit module, we can use the stop() and start() commands to
give our test the time to run it’s course.
Refactoring our code to use callbacks is also not complicated: we just need
to pass an object with a “success” method that holds the next step
in the test to each async process. Note that as part of this we must set
create a closure variable to point to the tests “this” object, as
the scope of the callback functions is differen than the scope of the test. We
use the conventional “var self=this;” to do this.
Unfortunately, while the code below might work for small tests, for larger
tests it quickly becomes unusable for obvious reasons (complete code available
at this
gist revision):
// ...snip...
test: function() {
expect(3);
// First stop QUnit's async tests, run this test in serial.
stop();
// We need to use self instead of this since this will change in the callback scope.
var self=this;
ok(self.testNodeNew.isNew(),
Drupal.t('New model isNew() is true before saving.'));
// Now we nest each step in a callback.
self.testNodeNew.save({}, {
success: function() {
ok(!self.testNodeNew.isNew(),
Drupal.t('New model isNew() is false after saving.'));
self.testNodeLoad.set('nid', self.testNodeNew.get('nid'));
self.testNodeLoad.fetch({
success: function() {
equals('Async Test Node', self.testNodeLoad.get('title'),
Drupal.t('Title saved and retrieved correctly.')
);
// When the queue is done, we restart QUnits asynchronous tests.
start();
}
});
}
});
},
// ...snip...
With just 2 callbacks, we are already indenting our code up to 4
levels…imagine if we had 5 server operations…or even a wopping 7.
If we are using a tabstop of 2 spaces, after 20 calls we’d be “off
the page” if we’re trying to keep our code to 80 character
lines.
A Better Solution
Fortunately, this is quite a common challenge in JavaScript coding, and
there are many patterns and tools for addressing it. Like the choice of
testing libraries, however, our choices as Drupal developers are a bit more
limited if we want to stay within the world of GPL’d code that can be
easily integrated into the Drupal universe. Libraries like async are made for this situation,
but are only available under the MIT lisence. We can keep our dependencies to a
minimum by using jQuery’s core queue functionality to manage a queue of
asynchronous operations, each of which pops the next item of the stack once the
jQuery operation has completed.
This solution looks something like this (you can see the complete code in the
gist):
Drupal.tests.myBackboneApp = {
// ...snip...
setup: function() {
// ...snip...
// Our setup function needs to also set up a queue for all steps.
this.asyncQueue = $({});
},
test: function() {
expect(3);
stop();
var self=this;
// We queue each step in the test.
self.asyncQueue.queue('asyncQueue', function() {
ok(self.testNodeNew.isNew(),
Drupal.t('New model isNew() is true before saving.'));
self.testNodeNew.save({}, {
success: function() {
self.asyncQueue.dequeue('asyncQueue');
}
});
});
self.asyncQueue.queue('asyncQueue', function() {
ok(!self.testNodeNew.isNew(),
Drupal.t('New model isNew() is false after saving.'));
self.testNodeLoad.set('nid', self.testNodeNew.get('nid'));
self.testNodeLoad.fetch({
success: function() {
self.asyncQueue.dequeue('asyncQueue');
}
});
});
self.asyncQueue.queue('asyncQueue', function() {
equals('Backbone Test Node', self.testNodeLoad.get('title'),
Drupal.t('Title saved and retrieved correctly.'));
start();
});
// We start the test by deuqueing the first item once it's all set up.
self.asyncQueue.dequeue('asyncQueue');
},
// ...snip...
Here we create a jQuery object (this.asyncQueue) to hold our queued testing
operations as part of the setup function. We then queue callback functions for
each step in the test, each dequeing the next step as the success callback for
any asynchronous operations.
While this approach is certainly a bit more verbose than our nested format,
it can handle arbitrarily long callback tests gracefully, without the need to
unsustainably indent each subsequent call. Further, we can actually alleviate
some of the verbosity by using a simple factory function to produce our dequeue
functions. For an example of that, you can check out the Backbone module’s QUnit
tests. Newer versions of jQuery also offer improvements to this scheme,
allowing success and error callbacks to be defined as methods on the objects
returned by our Backbone server requests, further streamlining the code.
Ultimately, there may be some extensions that can be easily built into the
Drupal QUnit module for queing asynchronous tests, but for the time being
jQuery queue offers an out-of-the-box solution to complex asynchronous testing
that helps us make sure our apps are 100% awesome.
Tags: javascript