Getting started

This tutorial covers getting a project set up, writing tests and running them with Node.js, before getting the tests running in a browser and on other server-side platforms.

We’ll start by creating a directory structure to host our new project:

project/
    source/
    test/
        specs/
        runner.js
    vendor/
        jsclass/
            core.js
            package.js
            test.js
            (etc)

JS.Test does not require any particular project layout, but I tend to lay out my files like this. Our source directory contains the source code for the project we’re building. The test/specs directory contains a spec file for each source file; this is where we’ll be writing our tests. Finally, test/runner.js is the script we’ll run from the command line or load into a browser; its job is to load up all our code and tests and run the test suite.

Let’s add some code to runner.js. We need to load the JS.Class framework, tell it where to find our tests, then run the test suite.

// test/runner.js

JSCLASS_PATH = 'vendor/jsclass';
require('../' + JSCLASS_PATH + '/loader');

JS.Packages(function() { with(this) {
    autoload(/.*Spec$/, {from: 'test/specs'});
}});

JS.require('JS.Test', function() {
    JS.require('UserSpec', JS.Test.method('autorun'));
});

That autoload statement tells JS.Packages it should look for any object whose name matches /.*Spec$/ in the test/specs directory, for example UserSpec should be in test/specs/user_spec.js. Finally we load JS.Test, then load our specs and use the JS.Test.autorun() method to run them all.

Let’s try running it:

We’ve not created the spec file yet, so Node is complaining. Create a blank file at test/specs/user_spec.js, then run the tests again:

This error happens because test/specs/user_spec.js doesn’t contain any code yet: the next step is to start writing this spec.

Writing specs

Specs are organized using the nested context style popularized by RSpec. Contexts are delimited using the describe method, and each test is created using the it method. Within each context we can use before and after hooks to set up and tear down any state the tests need. Assigning properties to this in a before block makes them appear as local variables in the tests.

Let’s write a simple spec for our User class.

// test/specs/user_spec.js

JS.ENV.UserSpec = JS.Test.describe('User', function() { with(this) {
    before(function() { with(this) {
        this.user = new User('James');
    }});

    it('has a name', function() { with(this) {
        assertEqual('James', user.getName());
    }});
}});

We need to make UserSpec a global variable so that JS.Packages can find it. Accessing the global scope requires different code on different platforms, but you can use JS.ENV to refer to it across all platforms.

If we run the test again we start to get meaningful output:

We’ve got two errors, one from the before block because the User class doesn’t exist, and one from the test because the user variable was never created. To fix this, we need to create the class, and tell JS.Packages where to find it. Add this code to source/user.js:

// source/user.js
JS.ENV.User = new JS.Class('User');

Change test/runner.js to say that UserSpec requires User, and tell it where to find the User class:

// test/runner.js

JSCLASS_PATH = 'vendor/jsclass';
require('../' + JSCLASS_PATH + '/loader');

JS.Packages(function() { with(this) {
    autoload(/^(.*)Spec$/, {from: 'test/specs', require: '$1'});

    file('source/user.js')
        .provides('User')
        .requires('JS.Class');
}});

JS.require('JS.Test', function() {
    JS.require('UserSpec', JS.Test.method('autorun'));
});

Let’s run our tests again:

Just one error this time: our class doesn’t have the method we’re testing. Let’s finish writing our class so it passes the tests:

// source/user.js

JS.ENV.User = new JS.Class('User', {
    initialize: function(name) {
        this._name = name;
    },

    getName: function() {
        return this._name;
    }
});

One last test run:

Finally we’ve got a passing test. We can continue adding tests like this to build up our project. See the list of assertions below; they offer a rich set of tests to verify the behaviour of your code.

Running tests in the browser

To run our tests in a web browser we need a web page to host the tests, and we need to separate the platform-specific script-loading code from the platform-agnostic test-running code. Take the platform-specific code out of test/runner.js and put it in test/console.js as shown below. We’re also going to add a constant called ROOT to specify where the root of the project is relative to the test page.

You should have two files that look like this:

// test/console.js

JSCLASS_PATH = 'vendor/jsclass';
require('../' + JSCLASS_PATH + '/loader');
require('./runner');
// test/runner.js

JS.Packages(function() { with(this) {
    var ROOT = JS.ENV.ROOT || '.'; 

    autoload(/^(.*)Spec$/, {from: ROOT + '/test/specs', require: '$1'});

    file(ROOT + '/source/user.js')
        .provides('User')
        .requires('JS.Class');
}});

JS.require('JS.Test', function() {
    JS.require('UserSpec', JS.Test.method('autorun'));
});

You should still be able to run the tests using node test/console.js in the terminal. Now we just set up a web page that does the same job as test/console.js, but in the browser:

<!-- test/browser.html -->

<!doctype html>
<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
        <title>Test runner</title>
    </head>
    <body>
        <script type="text/javascript">ROOT = '..'</script>
        <script type="text/javascript" src="../vendor/jsclass/loader.js"></script>
        <script type="text/javascript" src="./runner.js"></script>
    </body>
</html>

Save this as test/browser.html and open it in any browser to see the test results.

When running in the browser, JS.Test will automatically notify TestSwarm if you’re using it, so you can easily use JS.Test for your continuous integration setup.

Running tests on other platforms

We’ve got tests that run in the browser and on the server using Node. But what happens if we run them with another tool, say Rhino?

~/project $ rhino test/console.js 
js: uncaught JavaScript runtime exception:
ReferenceError: "require" is not defined.

Rhino (and other shells like V8 and SpiderMonkey) don’t support the require() function to load files, they use load(). Let’s change the require() calls in test/console.js to accommodate this:

// test/console.js

JSCLASS_PATH = 'vendor/jsclass';

if (typeof require === 'function') {
    require('../' + JSCLASS_PATH + '/loader');
    require('./runner');
} else {
    load(JSCLASS_PATH + '/loader.js');
    load('test/runner.js');
}

Now you should be able to run the tests on Rhino, V8, SpiderMonkey, Narwhal and Node. You don’t need to change any other code; JS.require() works across platforms so you only need to change the code that does the initial loading.

However, some platforms such as RingoJS don’t add variables assigned without var to the global scope. JSCLASS_PATH must be a global variable but we don’t yet have JS.ENV loaded to help us out. If you want to run on these platforms, change your JSCLASS_PATH line to the following:

// test/console.js

(function() {
    var $ = (typeof this.global === 'object') ? this.global : this;
    $.JSCLASS_PATH = 'vendor/jsclass';
})();

Your tests will now run a wide range of server-side platforms, but not on Windows Script Host. If you need to support this platform, you need to define a load() function before doing anything else. Your final test/console.js file will look as follows, at which point it will run on all supported platforms:

// test/console.js

//================================================================
// Set up load() function for Windows Script Host

if (this.ActiveXObject)
    load = function(path) {
        var fso = new ActiveXObject('Scripting.FileSystemObject'),
            file, runner;

        try {
            file   = fso.OpenTextFile(path);
            runner = function() { eval(file.ReadAll()) };
            runner();
        } finally {
            try { if (file) file.Close() } catch (e) {}
        }
    };

//================================================================
// Set up JSCLASS_PATH variable

(function() {
    var $ = (typeof this.global === 'object') ? this.global : this;
    $.JSCLASS_PATH = 'vendor/jsclass';
})();

//================================================================
// Load the JS.Class package manager and test runner

if (typeof require === 'function') {
    require('../' + JSCLASS_PATH + '/loader');
    require('./runner');
} else {
    load(JSCLASS_PATH + '/loader.js');
    load('test/runner.js');
}