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.