Here’s some background to a problem I faced recently: I have quite a few server-side Javascript scripts which I need to expand and refactor. Having moved from my usual comfort-zone of test-driven Java, I wanted to work in the same style to ensure the quality of the scripts I was writing.
The following describes how I reached a solution, with the many blind-alleys and wrong-turnings I took, ignored for simplicity.
Step 1: Find a Javascript unit-testing library
I needed a library that would work for server-side Javascript. The execution environment of the production code is Rhino, so I needed a compatible unit-testing framework. Unfortunately, what seem like the better Javascript testing frameworks, are focused on the problem of client-side multi-browser testing. These frameworks often adopt a server model, allowing tests to be submitted and run on different browsers. Even a headless browser would not match the specific environment (Rhino) so these frameworks were ruled out.
I found a couple of interesting projects that I began to look into:
https://github.com/stefanofornari/rhinounit-maven-plugin
https://github.com/stefanofornari/rhinounit
and, in the absence of documentation, a project that used these:
https://github.com/stefanofornari/subitosms-thunderbird-extension
Step 2: Get a simple test to run with Maven
By installing first RhinoUnit, and then the Maven plugin, in my local repository, I was able to incorporate Javascript tests into my build. I did this using the following Maven plugin in my pom.xml:
<plugin> <groupId>funambol</groupId> <artifactId>rhinounit-maven-plugin</artifactId> <version>1.0</version> <executions> <execution> <phase>test</phase> <goals> <goal>test</goal> </goals> </execution> </executions> <configuration> <testSourceDirectory>src/test/scripts</testSourceDirectory> <includes> <include>**/tools/datadictionary.lib.js</include> </includes> </configuration> </plugin>
Note: implicit in the Maven plugin are two directories:
- src/main/scripts, the directory relative to which the includes are applied
- src/main/js, where the tests reside. Can be overidden, as I have above, using the testSourceDirectory configuration option.
Given the pom.xml configuration above, to run a basic test I need the following…
1: A valid project structure, e.g.
[project] - pom.xml - src/main/scripts/my/tools/datadictionary.lib.js - src/test/scripts/DataDictionaryTestSuite.js
2: A valid pom.xml
3: DataDictionaryTestSuite.js contains a test such as:
function DataDictionaryTestSuite() { } DataDictionaryTestSuite.prototype.test1 = function test1() { assertTrue(true); }
Then everything should be ready to start automated testing.
Run ‘mvn test’, and you should see output like the following:
[INFO] [rhinounit:test {execution: default}] > Initializing... > Done initializing > Running test "test1" <testsuite time="0.003" failures="0" errors="0" tests="1" name="DataDictionaryTestSuite"> <testcase time="0" name="test1"> </testcase> </suite> > Done (0.005 seconds) -------------------------------------------------------------------------------- Tests run: 1, Failures: 0, Errors: 0 -------------------------------------------------------------------------------- [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESSFUL [INFO] ------------------------------------------------------------------------
Step 3: Write some real tests
The next step was to start writing some useful tests. I wanted to test the ‘delete’ action for a file management API. The API I was writing was dependent on an underlying API providing the raw functionality, this API was not Javascript, but Java objects injected into a Rhino as Javascript placeholders. As such, the underlying API needed to be stubbed. I decided to use a very comprehensive and test-framework-agnositic library for creating spies, mocks and stubs: Sinon.js. This library made the following tests possible, the first to test successful deletion, the second to test failed deletion (file not found):
DataDictionaryTestSuite.prototype.testDeletion = function testDeletion() { root.childByNamePath = sinon.stub().withArgs("/file1").returns(file); dataDictionaryDir.removeNode = sinon.spy().withArgs(file); var model = new dataDictionaryModel(); var message = model.deleteFile("file1"); assertEquals("File 'file1' deleted", message); assertTrue("removeNode(file) not called", dataDictionaryDir.removeNode.withArgs(file).calledOnce); } DataDictionaryTestSuite.prototype.testDeletionOfNonExistantFileReturns404Error = function testDeletionOfNonExistantFileReturns404Error() { root.childByNamePath = sinon.stub().withArgs("/file1").returns(null); dataDictionaryDir.removeNode = sinon.spy(); var model = new dataDictionaryModel(); try { model.deleteFile("file1"); fail("An exception should have been thrown"); } catch(error) { assertEquals(404, error["code"]); assertEquals("Unable to delete file 'file1' does not exist", error["message"]); assertTrue("removeNode(...) should not have been called", dataDictionaryDir.removeNode.callCount==0); } }
The assert… statements are provided by jsUnit and require no additional configuration. The stubs, spies and verifications are provided by Sinon.JS and are documented here.
I won’t go into the syntax of the tests above in too much detail, but essentially I am stubbing out the underlying API and ensuring the following:
- A ‘removeNode’ action occurs for a successful deletions
- A ‘removeNode’ action does not occur if the file could not be found
- The response messages are relevant to the outcome
To use sinon.js, I found I needed to do the following adjustments:
1: Remove some code from sinon.js that conflicts with Rhino. The following extract is from line 1601 of sinon-1.1.1.js. Remove this code, and several of the associated functions. I am not sure exactly what should be removed, and even less sure of whether I am undermining the sinon library, but removing a few functions worked for me.
sinon.timers = { setTimeout: setTimeout, clearTimeout: clearTimeout, setInterval: setInterval, clearInterval: clearInterval, Date: Date };
2: Locate sinon.js in /src/main/scripts and add the following include:
<include>sinon.js</include>
Step 4: Inject global mocks
You’ll notice that all the file API tests above refer to a variable ‘root’. This is a global variable which is injected into the Rhino context on the production system. For the Javascript in the includes directories to run, these global variables need to be present. I found it necessary to create a ‘global-mocks.js’ file in src/main/scripts containing the following:
var root = new Object();
Step 5: Run the tests
A failed test run, where the following assertion fails:
assertEquals("Unable to delete file 'file1' does not exist", error["message"]);
Will give you test output to indicate the nature of the failure:
[INFO] [rhinounit:test {execution: default}] > Initializing... > Done initializing > Running test "testDeletionOfNonExistantFileReturns404Error" testDeletionOfNonExistantFileReturns404Error failed [object Object] > Running test "testDeletion" <testsuite time="0.05" failures="1" errors="0" tests="2" name="DataDictionaryTestSuite"> <testcase time="0.031" name="testDeletionOfNonExistantFileReturns404Error"> <failure type="jsUnitException"> Expected Unable to delete file 'file1' does not exist (string) but was Unable to delete file 'file1' not found (string) </failure> </testcase> <testcase time="0.015" name="testDeletion"> </testcase> </suite> > Done (0.057 seconds) -------------------------------------------------------------------------------- Tests run: 2, Failures: 1, Errors: 0 -------------------------------------------------------------------------------- WARNING: There are test failures. --------------------------------------------------------------------------------
Finally, a successful build…
[INFO] [rhinounit:test {execution: default}] > Initializing... > Done initializing > Running test "testDeletionOfNonExistantFileReturns404Error" > Running test "testDeletion" <testsuite time="0.045" failures="0" errors="0" tests="2" name="DataDictionaryTestSuite"> <testcase time="0.027" name="testDeletionOfNonExistantFileReturns404Error"> </testcase> <testcase time="0.016" name="testDeletion"> </testcase> </suite> > Done (0.055 seconds) -------------------------------------------------------------------------------- Tests run: 2, Failures: 0, Errors: 0 --------------------------------------------------------------------------------
Conclusion
This was my first attempt at test-driven Javascript, and I’d love to get some feedback if there are cleaner ways to achieve this, or even just some alternatives.