Hiro Protagonist

Hiro is a small yet powerful unit testing framework for JavaScript. It runs each test suite in a separate iframe sandbox, preventing global state leaks and conflicts.

Example: Hiro testing itself (run it):

Screenshot of Hiro in action

Usage

Hiro's usage pattern resembles patterns used by similar frameworks in other languages such as Python (PyUnit) and Java (JUnit). You create test suites, load fixtures, write test cases, etc. Let's start with the test suites.

Test suites, in Hiro, are created using the hiro.module method. The method accepts two parameters: name of the suite and its implementation in form of a JavaScript object.

hiro.module('MySuite', {
  setUp: function () { /* ... */ },
  testFeatureA: function () { /* ... */ },
  testFeatureB: function () { /* ... */ }
});

The example above should be pretty self-explanatory. We created a simple test suite that defines one special method, setUp, and two test cases: testFeatureA and testFeatureB. Hiro supports three types of properties for its test suites: control properties, test cases and everything else. Control properties are hooks and helpers that you can use to configure the suite's behavior. Test cases are, well, test cases—the basic building blocks of unit testing. All other properties and methods are ignored by Hiro and can be used as user-defined helper functions.

Let's go over all control properties that are available today:

setUp This is a hook method which Hiro will automatically call when we run the suite.
waitFor This is a hook method which Hiro will use to check if the suite is ready to be executed. If this method is defined, the suite will be paused and Hiro will start calling waitFor continuously until it returns a truthy value (or until the timeout treshold is met). As soon as this happens, Hiro will start executing test cases.
onTest This is a hook method which Hiro will automatically call before each test in a suite. The this object inside of the method exposes one special property called args. You can assign an array to this property and this array will later be passed into each test as a list of formal parameters.
hiro.module('MySuite', {
  onTest: function () {
    // Pass sandboxed version of jQuery into each test
    this.args = [ this.window.jQuery ];
  },

  testExtend: function (jQuery) {
    // ...
  }
});
mixin This is a special property that can be used to tell Hiro to mixin other suites into the current one. It accepts an array of strings, each string representing a name of a suite to mixin from.
hiro.module('MyMixinSuite', {
  mixin: [ 'MyParentSuite', 'AnotherParentSuite' ]
});
When creating a suite instance, Hiro will copy all properties and methods from MyParentSuite into the current one, then it will copy everything from AnotherParentSuite, and then it will overwrite them with those defined in the current suite (if any).
test* All methods with names starting with test will be recognized as test cases. Test cases are single scenarios that must be set up and checked for correctness.

After we created our suite, we need to load a fixture and make sure that our environment is initialized. For the former task we will use an instance method called loadFixture that is accessible from inside of our setUp implementation.

hiro.module('MySuite', function () {
  setUp: function () {
    this.loadFixture('mylib');
  },

  // ...
});

That's it! To hold on our suite execution until our environment is initialized we will use the method waitFor.

hiro.module('MySuite', {
  waitFor: function () {
    // this.window here refers to the sandboxed environment
    return this.window.MyLib && this.window.myLib.isReady;
  },

  // ...
});

Now let's walk through the process of creating a fixture. A fixture in Hiro is simply an HTML document that is injected into a newly created iframe. In order to define a fixture, we will need to write the document's code into a textarea HTML element with class attribute set to “fixture” and data-name attribute set to whatever name we picked for that fixture (for our examples above, the name will be “mylib”).

All fixtures must be defined in the same file as Hiro itself.

<textarea class="fixture" data-name="mylib">
  <html>
    <head>
      <script src="mylib.js">
    </head>

    <body>
      <!-- ... -->
    </body>
  </html>
</textarea>

All the things we described so far are used to prepare the environment. As we mentioned above, the basic building blocks of unit testings are test cases. Each test case in Hiro, as well as the special hook method onTest, has access to the sandboxed environment created for its suite. References to that environment—in form of window and document objects—are available from within test cases as instance properties (i.e. properties of this).

hiro.module('MySuite', {
  // ...

  testSomeMethod: function () {
    // Here, `window` refers to the global environment
    // (where Hiro is loaded) and `this.window` refers
    // to the sandboxed environment with your fixture
    // code in it. Same with `this.document`.

    window.hiro == null;       // false
    window.MyLib == null;      // true

    this.window.hiro == null;  // true
    this.window.MyLib == null; // false
  },

  // ...
});

In addittion to aforementioned window and document properties each instance of a test case contains some other useful methods.

expect

this.expect(num);

Specifies how many assertions are expected to run within a test.
pause

this.pause();

Pauses the current test. This method is usually used before an asynchronous function call. It also must be followed by a call to the resume() method later in the code or the test will fail with a timeout error.
resume

this.resume();

Resumes the current test.
assertTrue

this.assertTrue(value);

A boolean assertion, checks that value is truthy.
assertFalse

this.assertFalse(value);

A boolean assertion, checks that value is falsy.
assertEqual

this.assertEqual(actual, expected);

A comparison assertion, checks that both values are equal (===).
assertException

this.assertException(fn, [exc]);

Assertion to test if fn throws an exception. Accepts an optional second parameter, an exception object, to test for a specific exception.
assertNoException

this.assertNoException(fn);

Assertion to test if fn executes without raising any exceptions.

After we created a test suite, loaded our fixture and wrote a couple of tests, we might want to actually run them. For that, we can use the method hiro.run. It accepts two optional arguments: a suite name and a test name. Without any arguments, Hiro will run all suites and tests. With one argument (a suite name), it will run all tests within that suite. And with both arguments provided, it will run just a specific test.

There is also another method, hiro.autorun, which reads the query string and decides what to run based on its value.

hiro.html               -> run all suites and tests
hiro.html?MySuite       -> run all tests from MySuite
hiro.html?MySuite.testA -> run only testA from MySuite

And that's how you use Hiro. If you are still confused, read through the next section where we will go through a real world example of using Hiro. In fact, that use case was the reason why I wrote Hiro in the first place.

Another great thing about Hiro is that its presentation code is completely separated from the main library. When running tests Hiro fires events and any code can listen to them and report the results in any way they want. We will have documentation for the events soon but, for now, please refer to the web.js file in our repository. This file and test.html define the out-of-the-box presentation of Hiro (the one you see in the screenshot above).

Example

In this section, we will walk through the process of testing a JSON library. The library in question is the one we use at Disqus. All code samples were taken from the actual Disqus repository and adapted for the purpose of this text. Let's start by creating our tests endpoint.

Tests endpoint is an HTML file that loads Hiro and unit tests, and contains all the fixtures our tests need. Its layout is rather simple.

<!DOCTYPE html>

<html lang="en">
  <head>
    <title>DISQUS JavaScript Tests</title>

    <!-- Hiro files -->
    <link rel="stylesheet" href="css/tests/hiro.css">
    <script src="js/tests/ender.js"></script>
    <script src="js/tests/hiro.js"></script>
    <script src="js/tests/web.js"></script>
  </head>

  <body onload="hiro.autorun();">
    <h1>DISQUS JavaScript Tests</h1>

    <!-- Hiro container (web.js needs this) -->
    <div id="web">
    </div>

    <div id="footer">
      (music, movies, microcode)
    </div>

    <!-- Our fixture -->
    <textarea class="fixture" data-name="json">
      <html>
        <head>
          <script>
            var isReady = false;

            // A bunch of disqus configuration variables that were
            // removed for the sake of simplicity.

            function disqus_config() {
              DISQUS.once('thread.onReady', function () {
                // This function will be called when Disqus is fully loaded
                isReady = true;
              });
            }
          </script>
        </head>

        <body>
          <div id="disqus_thread"></div>

          <script>
            // Normal Disqus snippet here
            (function (doc) {
              var dsq = doc.createElement('script');
              dsq.type = 'text/javascript';
              dsq.async = true;
              dsq.src = 'http://test.disqus/embed.js';

              (document.getElementsByTagName('head')[0] ||
               document.getElementsByTagName('body')[0]).appendChild(dsq);
            }(document));
          </script>
        </body>
      </html>
    </textarea>

    <!-- Our unit tests -->
    <script src="js/tests/unit/json.js"></script>
  </body>
</html>

After we finished our endpoint, we can create a test suite and start writing test cases. Each test case will get a reference to the sandboxed DISQUS object as its function argument. We could reference that object with this.window.DISQUS in each test case but the shorter version is better.

/*jshint undef: true */
/*global hiro: false */

hiro.module('GenericJSONTests', {
  setUp: function () {
    // Loading the fixture we defined in the previous snippet
    this.loadFixture('json');
  },

  waitFor: function () {
    // Wait until Disqus is ready before running this suite
    return this.window.isReady;
  },

  onTest: function () {
    // Each test should get a reference to the DISQUS object as an argument
    this.args = [ this.window.DISQUS ];
  },

  testParse: function (DISQUS) {
    var valid   = '{ "name": "Anton", "company": "Disqus" }';
    var invalid = '{ "name": , "company" "Disqus" }';
    var obj     = DISQUS.json.parse(valid);

    this.expect(3);
    this.assertEqual(obj.name, "Anton");
    this.assertEqual(obj.company, "Disqus");

    this.assertException(function () {
      DISQUS.json.parse(invalid);
    });
  },

  testStringify: function (DISQUS) {
    var obj  = { name: "Anton", company: "Disqus" };
    var arr  = [ "Hello", "World", true, 2 ];
    var json = DISQUS.json.stringify(obj);

    this.expect(2);
    this.assertEqual(json, '{"name":"Anton","company":"Disqus"}');
    json = DISQUS.json.stringify(arr);
    this.assertEqual(json, '["Hello","World",true,2]');
  }
});

Our test cases check that DISQUS.json correctly parses JSON and stringifies JavaScript objects. They also check that, in case of invalid JSON, our library throws an exception.

Now, a few months ago we hit a bug where our library was failing on sites with Prototype.js v1.5. After some debugging we realized that older versions of that library implement the Object.toJSON method in a way that breaks our JSON library. We fixed that bug but in order to make sure that it won't be re-introduced in future, we need to write a regression test.

We start by creating a new fixture that loads Disqus alongside with Prototype.js v1.5.

<!-- Another fixture, with Prototype -->
<textarea class="fixture" data-name="json-prototype-15">
  <html>
    <head>
      <script src='js/tests/externals/prototype15.js'></script>
      <script>
        var isReady = false;

        // A bunch of disqus configuration variables here,
        // They are irrelevant for our example.

        function disqus_config() {
          DISQUS.once('thread.onReady', function () {
            // This function will be called when Disqus is fully loaded
            isReady = true;
          });
        }
      </script>
    </head>

    <body>
      <div id="disqus_thread"></div>

      <script>
        // Normal Disqus snippet here
        (function (doc) {
          var dsq = doc.createElement('script');
          dsq.type = 'text/javascript';
          dsq.async = true;
          dsq.src = 'http://test.disqus/embed.js';

          (document.getElementsByTagName('head')[0] ||
           document.getElementsByTagName('body')[0]).appendChild(dsq);
        }(document));
      </script>
    </body>
  </html>
</textarea>

After that, we want to re-run our GenericJSONTests suite but with this new fixture. We do that by creating a new test suite and “mixing” the original suite into it. The only method we will overwrite is setUp since we want to load our new fixture with Prototype.js in it.

hiro.module('Prototype15JSONTests', {
  mixin: ['GenericJSONTests'],

  setUp: function () {
      this.loadFixture('json-prototype-15');
  }
});

And that's it! Hiro will now load the new fixture and run all tests from GenericJSONTests again but in the new environment.

I hope you now understand the idea behind Hiro and how it can be used in your own projects. We use it for our JavaScript tests at Disqus and I would love to hear from you if you decided to use it for some of your projects. Feel free to ping me on Twitter and tell what you like or hate about Hiro.

Code

Source code is available on GitHub: antonkovalyov/hiro. Feel free to fork it, submit pull requests, report bugs, etc.

The library itself doesn't have any dependencies. It was designed to work (without modifications) only in the browser environment. The presentation part uses a custom DOM library built with Ender.

Name

Hiro Protagonist is the main character from Neal Stephenson's cult cyberpunk novel “Snow Crash”.