Link to wealthfront.com

Fork me on GitHub

Monday, January 31, 2011

Test-driving your Javascript for fun and profit

With web-based UIs growing more complicated everyday, testing your Javascript code is just as vital as testing any other part of your stack. At Wealthfront we use jsTestDriver to provide test-coverage of our javascript code. We like its similarity to JUnit and the ease with which it integrates with Hudson. In this article we'll drum up a quick example by writing a class that sorts a list, then we'll write our tests with jsTestDriver. After that we'll take a quick look at how easy it is to integrate our tests into Hudson to make jsTestDriver a part of your continuous integration infrastructure.

Sorter.js

A list sorter is a great example of something that should be test driven, it’s a DOM manipulation (ordering of list elements) that represents the result of a computation (a sort). Here’s a quick Sorter class in Javascript that accepts a jQuery selector that should point to a <ul> element.
function Sorter(selector) {
  this.list = $(selector);
}

Sorter.prototype.sort = function() {
  var elements = $.makeArray(this.list.children());
  elements.sort(function(itemA, itemB) {
      var contentA = itemA.innerHTML,
          contentB = itemB.innerHTML;
      if(contentA < contentB ) { return -1;}
      if(contentA == contentB) { return 0; }
      if(contentA > contentB ) { return 1; }
  });
  for(var i=0; i < elements.length; i++) {
      this.list.append(elements[i]);
  }
}

When you call sort() on the object it uses a custom comparator function that looks at the innerHTML of the list element and uses that value for the basis of the comparison. It then takes the sorted list of elements and uses jQuery’s append() to append each element in sorted order, resulting in a reordered list.

Writing our tests

As you might expect we’ve got a bug in the code above. If it’s not obvious yet don’t worry, the tests we write are going to catch it.

Let’s define sorter_test.js, which will contain our tests. Test suites in jsTestDriver inherit from the library’s TestCase Javascript class, so the first thing we do is define a SorterTest class.

SorterTest = TestCase("Sorter Test")

You define individual tests by adding them to SorterTest’s prototype. jsTestDriver will run any function you add to the prototype that starts with “test”. Here’s one example that makes sure we sort a list of names correctly.

SorterTest.prototype.testSortingStrings = function() {
   /*:DOC += <ul id="sort-me">
               <li>Biggie</li>
               <li>Run DMC</li>
               <li>Wu-Tang Clan</li>
               <li>Dr. Dre</li>
               <li>Mos Def</li>
               </ul>*/


   var mySorter = new Sorter("#sort-me");
   mySorter.sort();

   var listItems = $("#sort-me").children();
   var expected = ["Biggie", "Dr. Dre", "Mos Def", "Run DMC", "Wu-Tang Clan"];
   assertEquals("The number of list elements", 5, listItems.length);
   for(var i=0; i<5; i++) {
       assertEquals(expected[i], listItems[i].innerHTML);
   }
}

The first thing you’ll notice is that magical comment at the top of our test. jsTestDriver takes a comment beginning with ":DOC +=" and adds the contents of the comment as a DOM fragment to the page, allowing us to test against it. It’s a simple and effective way of providing DOM fixtures to test any code that does DOM manipulation.

Our next two lines create the sorter and sort the list, followed by our actual assertions. Since this is all Javascript we can use jQuery in our test methods too. In the test above it’s a handy way to access the elements in our list and see that they correspond to our expected ordering.

Getting things running

Your jsTestDriver.conf file defines a few configuration options we’ll need to specify before we can run our tests. First you have to provide the address and port the test server will bind to, then you’ll need to specify which files you want to load during the tests. When enumerating the files you can be explicit or use a wildcard, however explicitly naming each file allows you to have control over the order they’re loaded in. In this case we want to make sure jQuery is loaded before our sorter class because jQuery is a dependency. Here’s how our jsTestDriver.conf might look:

server: http://localhost:4223
load:
 - src/jquery-1.4.4.min.js
 - src/sorter.js
 - src-test/sorter_test.js

Note that our config assumes that the jsTestDriver jar is in some root directory and that our source code is in the src and src-test directories contained therein.

With that settled we can run our test. First start up the jsTestDriver server:

java -jar JsTestDriver-1.2.2.jar --config jsTestDriver.conf --port 4223

Next we have to capture a browser. Visit http://localhost:4223 with your browser and click “capture browser” to get it ready for testing. You can do this with any number of browsers, jsTestDriver will run your tests on all of them and report back the results. This makes it trivial to test code against many different browsers simultaneously and spot browser-specific bugs. Once the browser is captured you don’t need to interact with it anymore, jsTestDriver will handle the rest.

With the test server running and the browser captured it’s time to actually run the tests:

java -jar JsTestDriver-1.2.2.jar --config jsTestDriver.conf --tests all

It will come back with the test results which should hopefully look something like this:

.
Total 1 tests (Passed: 1; Fails: 0; Errors: 0) (8.00 ms)
 Firefox 3.6.13 Mac OS: Run 1 tests (Passed: 1; Fails: 0; Errors 0) (8.00 ms)

Congratulations! Tests pass. But we have a bug lurking in our code that we haven’t caught. Let’s write another test case that sorts a list of numbers.

SorterTest.prototype.testSortingNumbers = function() {
   /*:DOC += <ul id='sort-me'>
               <li>200</li>
               <li>101</li>
               <li>100</li>
               <li>1001</li>
               </ul>*/


   var mySorter = new Sorter("#sort-me");
   mySorter.sort();

   var listItems = $("#sort-me").children();
   var expected = [100, 101, 200, 1001];

   assertEquals("The number of list elements", 4, listItems.length);
   for(var i=0; i<4; i++) {
       assertEquals(expected[i], listItems[i].innerHTML);
   }
}

You’ll notice our code isn’t very DRY here, so while we’re at it let’s abstract out our list element assertion. You can easily define functions inside your jsTestDriver tests that act as helpers. In this case we’ll define an assertListOrder.

SorterTest.prototype.assertListOrder = function(expected, listItems) {
   assertEquals("Number of list elements", expected.length, listItems.length);
   for(var i=0; i < listItems.length; i++) {
       assertEquals(expected[i], listItems[i].innerHTML);
   }
}

Now our test case can look like this:

SorterTest.prototype.testSortingNumbers = function() {
   /*:DOC += <ul id='sort-me'>
               <li>200</li>
               <li>101</li>
               <li>100</li>
               <li>1001</li>
               </ul>*/


   var mySorter = new Sorter("#sort-me");
   mySorter.sort();

   var listItems = $("#sort-me").children();
   var expected = [100, 101, 200, 1001];

   this.assertListOrder(expected, listItems);
}

Much better, let’s go ahead and run tests again:

.F
Total 2 tests (Passed: 1; Fails: 1; Errors: 0) (12.00 ms)
 Firefox 3.6.13 Mac OS: Run 2 tests (Passed: 1; Fails: 1; Errors 0) (12.00 ms)
   Sorter Test.testSortingNumbers failed (3.00 ms): expected 101 but was "1001"
     /src-test/sorter_test.js:6
     /src-test/sorter_test.js:36

Tests failed.

As you can see our second test failed. The contents of our list items are actually strings when they’re passed to the comparison function. Because of this our list gets sorted alphabetically instead of numerically. For the purposes of our toy example let’s just attempt to cast our list items to floats. If it succeeds we’ll compare the items numerically, if not we’ll compare alphabetically.

function Sorter(selector) {
   this.list = $(selector);
}

Sorter.prototype.sort = function() {
   var elements = $.makeArray(this.list.children());
   elements.sort(function(itemA, itemB) {
       var contentA = itemA.innerHTML,
           contentB = itemB.innerHTML;
       contentA = parseFloat(contentA) || contentA;
       contentB = parseFloat(contentB) || contentB;

       if(contentA < contentB ) { return -1;}
       if(contentA == contentB) { return 0; }
       if(contentA > contentB ) { return 1; }
   });
   for(var i=0; i < elements.length; i++) {
       this.list.append(elements[i]);
   }
}

If we run again we’ll see the tests pass as expected.

..
Total 2 tests (Passed: 2; Fails: 0; Errors: 0) (5.00 ms)
 Firefox 3.6.13 Mac OS: Run 2 tests (Passed: 2; Fails: 0; Errors 0) (5.00 ms)

Integrating with Hudson

At Wealthfront we integrated jsTestDriver with our Hudson build by adding a new build target that kicks off the tests. We keep a jsTestDriver server running at all times, along with a few captured browsers inside of VNC sessions so that the Hudson task only needs to issue the “run test” command.

As an added bonus, jsTestDriver will write out test results using JUnit’s test report format if you specify the --testOutput flag. Add the “Publish JUnit test result report” post-build action in Hudson to watch for jsTestDriver’s reports and Hudson will use the XML output to provide information about historical trends as well as a nice web-based UI to browse test results.

Other Options

In addition to jsTestDriver you can explore options like Pivotal’s Jasmine, jQuery’s Qunit or JSpec to find the framework that best fits your needs.