Fleegix.org

Logan, client- and server-side JavaScript test runner

2010-07-17 22:28:00

Some of the work I'm doing at Yammer involves writing JavaScript code that runs in different environments. We're using geddy-model code in the browser, as well as with TheRubyRacer (which embeds the V8 JavaScript interpreter in a Ruby process).

This code is part of the Geddy web framework I have been building for Node.js -- so this means three different environments, and and four different JavaScript interpreters, that this code has to work within.

Multi-environment testing? Dual-sided? X-env?

I've been talking to people about ways to solve this -- in particular, Anders Conbere, who is working with similar issues at his job with Estately.

Testing multi-environment JavaScript code is an annoying problem. I looked around, I really didn't find any good solutions.

(I also didn't find anything nice and snappy to call JS code that has to run on both client and server. What's the "Ajax" of that? "Dual-sided"? "Multi-environment"? "JS-everywhere"?)

Meet Logan

I sat down to see what would be required to run the same tests in all these different environments, and it turned out to be pretty reasonable to build something that can run both brower- and server-side tests (thanks, Node.js!).

Despite having built Windmill's complex JavaScript API (or perhaps because of it), I've come around to Mikeal's way of thinking on testing: I want something minimal, without a lot of frameworky baggage -- something that just sets up some conditions and does some asserts.

What I've ended up with is Logan, a very small test runner that lets you run your tests in browsers, in Node.js, and in embedded V8 with TheRubyRacer.

The name is probably kind of obvious to anybody who's familiar with old sci-fi movies -- Logan is a runner), of course.

Logan uses a version of Node's assert module that Anders patched to remove the V8-specific code. I would love to see these changes pushed upstream to Node, so this fork doesn't have to persist (assuming there's a minimal performance impact).

Logan requires Node.js, and of course if you want to test out tests in TheRubyRacer, you need to have it installed.

Sharing code between sides

For Logan's testing purposes, "server-side" means in Node.js, which uses CommonJS modules. "Client-side" includes the browsers, and TheRubyRacer, where there is no facility for loading code modules from within the runtime. Logan uses plain requires for the server-side, and eval/script-append in the clients.

I should take a second here to call out very specifically one of the requirements is synchronous loading of dependencies. (This is why I went the route of source-code transformation.)

Once you get past some initial hackery, it's not that bad loading code in these different enviroments. (After you've done enough client-side JavaScript, I suspect your threshold for hackery gets a little higher.)

To make this stuff work, you do have to follow some specific rules in both app-code, and testing code. (The constraints for the app code are needed if you plan to run your code on both client and server at all, not specifically for running Logan tests.)

1. Use namespaces in your modules

Use a namespace object inside the module file -- you won't have module scope when you load it in the browser. Name it the same name as the variable you'll be setting it to when you load it via CommonJS require.

2. Check for the need to export

Only export your namespace in the CommonJS environment.

For example, in the module file, foo.js:

if (typeof foo != 'undefined') { foo = {}; }

foo.bar = new function () {
  this.a = 1;
  this.b = function () {}; 
}();

if (typeof module != 'undefined') { module.exports = foo.bar; }

Or, if you are exporting a constructor:

if (typeof foo != 'undefined') { foo = {}; }

foo.Baz = function () {
  this.a = 2;
  this.b = []; 
};  

if (typeof exports != 'undefined') { exports.Baz = foo.Baz; }

3. Set require result to namespace name

Use the same name as the namespace you created in the module for the variable name of the require result.

4. Create top-level namespaces non-destructively.

In your tests, if you're creating top-level namespaces to hang your required objects on, use something to create them that checks for their existence before creating.

Logan includes a utility function, logan.namespace, to help with this. In your own app's code, you'll either have to use a utility function for doing this, or simply check with a typeof check for undefined.

Here's how to load and use the previous two examples:

logan.namespace('foo');
foo.bar = require('./path/to/foo');
foo.Bar = require('./path/to/bar').Bar;

var fooTests = new function () {
  this.testFooBarAIsTruthy = function () {
    assert.ok(foo.bar.a);
  };

  this.testFooBazAIsNumber = function () {
    var barInstance = new foo.Bar();
    assert.equal(typeof barInstance.a, 'number');
  };

}();

logan.run(fooTests);

Details

The reason for these special requirements is the difference between the way that code loads on the client and the way it loads via CommonJS.

In the client environment (including TheRubyRacer), there is no module-level scope, and the mechanism for loading code doesn't return any value, so namespace creation has to happen inside the module file, and has to be careful not to rewrite anything other modules might have done.

Logan makes this work with CommonJS require syntax in your tests by doing source-code transformation -- it uses the require statements to know what code to load, and rewrites the statements so they don't stomp on the results of the previous eval operation.

If you're at all interested in the icky rewriting tricks necessary, have a look at the source for the browser runner.

The process for running tests in TheRubyRacer is pretty weird too -- Node shells out to a Ruby script that then turns right back around and embeds V8. It's a crazy world we're living in.

What's next

Logan is very new. I'm sure there are better ways to deal with the problems of X-env testing. (How about that? Yeah, "X-env"? It has the letter "X" in it like Ajax, right?) At least I hope there are better ways. I'm very interested in getting feedback on how to make improvements.

About

This is the blog for Matthew Eernisse. I currently work at Yammer as a developer, working mostly with JavaScript. All opinions expressed here are my own, not my employer's.

Related

Previous posts

All previous posts ยป

This blog is a GeddyJS application.