Mojo Test Framework
by Rob Tsuk, Principal Architect
Author's note: I'd like to thank Mark Bessey of the Palm engineering team for his contribution to this article.
Mojo.Test is a set of unit-testing facilities built into the framework. Development teams can use it for test-driven development as well as automated testing or continuous integration.
Mojo.Test is in the style of the xUnit family of testing frameworks. Tests are grouped into objects with individual testing methods. These testing methods can directly return test results, and exceptions during test method execution will automatically cause a test failure to be reported. Test objects can have optional before and after methods.
Where Mojo.Test diverges from the usual xUnit-type framework is in support for testing components that depend on asynchronous callbacks. Mojo.Test provides a callback function for results that can be used at the end of a chain of any number of callbacks from the component under test. It also maintains a test timer, which will cause the failure of any test that doesn't report a result within a configurable time limit.
While Mojo.Test provides a simple test runner UI, it can be run without one. Test results are returned as an array of JavaScript objects, which could easily be converted to JSON for processing by some other process.
A Sample Test Object
Test objects are created by test constructor functions whose prototype contains one or more methods whose name begins with test. Listing 1 provides an example.
Listing 1
function SimpleTests() { } SimpleTests.prototype.testAdd = function() { Mojo.requireEqual(2, 1+1, "one plus one must equal two."); return Mojo.Test.passed; };
When this test is run, the SimpleTests function will be used as a constructor function to construct a new test object once for every method in its prototype whose name begins with "test", in this case just testAdd. Each test method will be called once, and any return other than undefined will be treated as the test result. Only the object referenced by Mojo.Test.passed will be considered a passing test. Any string returned will be reported as a test failure.
The Mojo.require set of methods are convenient to use in tests, because the exceptions they generate when their requirements aren't met will cause the test to fail with a useful message. But anything that generates an exception will cause a test failure to be reported, as will returning a string containing a description of the test failure.
Adding Tests to Your Application
The recommended structure for unit tests is to have a tests directory at the same level as the app directory in your project. As illustrated in Listing 2, there should be one test file for each major area of functionality, and a test specification file named all_tests.json, which lists all of the tests.
This structure is required if you want to use the default test runner. If you intend not to use the framework's test runner, you may still choose to adopt this structure, but you'll be responsible for loading your tests yourself (see "Creating Your Own Test Runner" below).
Listing 2
MyApp/ app/ tests/ all_tests.json SimpleTests.js ComplexTests.js appinfo.json framework_config.json index.html sources.json icon.png
Test Specification File
By specifying your tests in the all_tests.json test specification file, you can avoid loading your test code until you actually run the tests. Listing 3 illustrates the format of the JSON test specification file.
Listing 3
[ { "title": "Cookie Tests", "source": "tests/cookie_tests.js", "testFunction": "CookieTests" }, { "title": "Timing Tests", "source": "tests/timing_tests.js", "testFunction": "TimingTests" }, { "title": "View Tests", "source": "tests/view_tests.js", "testFunction": "ViewTests" } ]
The source property is a path relative to the application's index.html file.
Running Unit Tests (the easy way)
Provided that you've structured and specified your tests as described in the previous sections, the built-in test runner can be invoked from your code by simply calling Mojo.Test.pushTestScene() from one of your stage assistants, passing the currently active stage controller:
Mojo.Test.pushTestScene(this.controller);
Alternatively, you can pass the mojoTest parameter to the Mojo SDK palm-launch program to launch your application with the testing UI already loaded:
palm-launch -p "{mojoTest:true}" com.mycompany.myapp
Asynchronous Testing Support
In the first example, the test method could run to completion and return a result. More often, though, testing JavaScript components requires waiting for a callback function to be called before one can validate that the component behaves as expected.
Callbacks
In order to allow testing a component that requires a callback, Mojo.Test passes a callback function as the first parameter to every test method, as shown in Listing 4.
Listing 4
function AsyncTests() { } AsyncTests.prototype.testSetTimeout = function(reportResults) { var timeoutFired = function() { reportResults(); }; window.setTimeout(timeoutFired, 200); };
Notice that the test method shown in Listing 4 doesn't return Mojo.Test.passed. Instead it returns nothing, causing undefined to be returned to Mojo.Test and indicating that the result of this test can't be known immediately. Instead, the result of this test is required to be passed as the first parameter of the reportResults callback function.
Assuming that window.setTimeout is working correctly, reportResults will be called after 200 milliseconds, and Mojo.Test will move on to the next test. If it doesn't, though, a timer started by Mojo.Test will abort the test and report a failure after one second.
reportResults accepts one parameter, either Mojo.Test.passed to indicate a test passed, or a string describing the failure if it failed. Calling reportResults with no arguments, as in this example, is a shortcut for indicating a test passed, since reportResults treats undefined the same as Mojo.Test.passed.
Controlling Timing
If the callback needed for testing might take longer than one second, the timeout value can be customized for a test object by adding a timeoutInterval property to the test object's constructor function. This property should be the number of milliseconds the test runner needs to wait before aborting the test. See Listing 5.
Listing 5
function AsyncTests(tickleFunction) { } AsyncTests.timeoutInterval = 5000 AsyncTests.prototype.testSetTimeout = function(reportResults) { var timeoutFired = function() { reportResults(); }; window.setTimeout(timeoutFired, 2000); };
The tickle function should be used if a test needs to wait for a series of callback functions and you don't want to set the timeout to an amount of time long enough for the entire series.
The tickle function is passed as the first parameter to the test object's constructor function. If the test wishes to use it, it should save a copy. Every time this tickle function is called, the test's timeout timer is reset. In this simple example, the tickle function was not used.
Validate
When an exception occurs during the initial call to the test function, it will be caught and the exception used to create the test result.
When a test is continuing execution after a callback, though, Mojo.Test can't establish an exception handler. In order to get the same convenient conversion of exceptions to test failures, use Mojo.Test.validate, as shown in Listing 6.
Listing 6
function AsyncTests() { } AsyncTests.prototype.testSetTimeout = function(reportResults) { var startTime = Date.now(); var validateTimePassed = function() { Mojo.require(Date.now() > startTime, "time must have passed.") }; var timeoutFired = function() { Mojo.Test.validate(reportResults, validateTimePassed); }; window.setTimeout(timeoutFired, 200); };
Using Mojo.Test.validate() allows Mojo.Test to establish an exception handler that will catch any exception, including the ones generated when a call to one of the require methods isn't satisfied by its parameters.
Before and After Methods
If a test object has a method named before, that method will be called immediately before each individual test is run. Likewise, if the test object has a method named after, that method will be called immediately after each test finishes. These methods are useful for doing a setup that will be common across the test and cleanup after a test.
Since it is very likely that test setup and cleanup will also require waiting for some asynchronous event, these functions are passed a callback function that they must call to allow testing to continue. This callback must complete within the timeout period specified by timeoutInterval.
If the before method doesn't need to do an asynchronous operation, it can return Mojo.Test.beforeFinished to indicate that the test can continue immediately.
There is no shortcut for the after method; if it is present, it will be called and it must call the callback function it is passed as its first parameter.
Creating Your Own Test Runner
If the test runner provided by the framework doesn't meet your needs, you can create your own test runner. The basic steps to do this are outlined in Listing 7.
Listing 7
//create an array of test specifications var tests = [ SimpleTests, ComplexTests ]; // create a test runner object this.runner = new Mojo.Test.CollectionRunner(tests); // start the tests, call "whenCompleted" when done this.runner.start(whenCompleted);
In the above example, whenCompleted is a function that you want called when the tests are all finished running. Note that in this example, the test function is specified with a reference to the function, rather than the name. This means that you'll need to have loaded the test code by some other method before running this code. The results are in the results field of the runner object.
The results are an array of test result objects, which have the format shown in Listing 8.
Listing 8
[ { passed: false, suite: "SimpleTests", method: "testAdd", message: "one plus one must equal two." } ]
A passed test will have passed equal to true and the message will be Passed.
Rob Tsuk is a principal architect at Palm working on current and future versions of HP webOS. Before Palm, Rob worked on other groundbreaking mobile platforms, including the Apple iPod.