Unit testing in AsciiDoc

While finishing off this book, which I choose to write in AsciiDoc format, I really appreciate the time I took to write myself a handy little script for testing the little code examples. Because, something always gets wrong in the final steps of editing and is nice to have some automated assurance that you didn't break something stupid.

AsciiDoc or MarkDown or some other text setup is how technical books should be written. Or any books, really. Having clear plain text lets you focus on the content and not wrestle with Word formatting. And it's also easy to parse and test.

Code in AsciiDoc

The code blocks in AsciiDoc are easy to spot. They look like:

[source,js]
----------------------------------------------------------------------
var a = 0xff;
a === 255; //:: true
----------------------------------------------------------------------

Testing and linting

All you have to do in a little unit testing utility is to extract these clearly defined blocks and run them to make sure there are no silly syntax errors, creeping in in the very last edits.

But why stop with a syntax check? Why not also run jslint and also instrument and run the code and see any expected values. Asserts, in testspeak.

In my setup I did just that: lint with jshint, instrument the places where I know the expected values, lint these too (why the hell not?), run them and assert the expected values.

Output

Here's how the output of the utility looks like:

$ node scripts/test.js 
passed: 362, skipped: 43
linted: 317, nolints: 45

Since this is a book with some educational bad examples, not everything can be linted. Or run for that matter.

JSHint

I choose JSHint over JSLint as it allows more flexibility to relax the rules. Also it's a node module I can import.

var jslint = require('jshint').JSHINT;

These are my lint options:

// JSHint options need to be more relaxed because the book also
// points out bad patterns
var lintopts = {
  indent: 2,   // 2 spaces for indentation
  trailing: true, // disallow spaces at the end of a line
  white: true,
  plusplus: false, // allows ++ and --
  browser: true,   // assumes some common browser globals exist
                   // such as `document`
  node: true,  // assumes the code can run in node.js
               // and globals such as `global` are defined
  expr: true,  // ok to have expressions that seemingly do nothing
               // such as `a; // true` which the samples use to show
               // result values
  loopfunc: true, // allows definition of a function in a loop
                  // for educational purposes (in the part about closures)
  newcap: false,  // allows calling constructors (capitalized functions) 
                  // without `new`, again just for educational purposes
  proto: true,    // allows using `__proto__` which is great for understanding
                  // prototypes, although it's not supported in all browsers
};

And the lint function that lints a snippet of code:

// lint a snippet
function lint(snip) {
  // lint the snippet with all the options and have it assume
  // assert objext exists
  if (!jslint(snip, lintopts, {assert: false})) {
    log('------');
    log(snip);
    log('------');
    log(jslint.errors[0]);
    process.exit();    
  }
}

Instrumenting and executing a snippet

Execution is simple:

// run a snippet
function exec(snip) {
  // muck some stuff up and zap log()
  var mock = "function define(){}; function alert(){}; console.log = function(){};";
  try {
    eval(snip + mock);
    passed++;
  } catch (e) {
    log('------');
    log(snip);
    log('------');
    log(e.message);
    process.exit();
  }
}

The instrumentation is a little more interesting.

I often write snippets like:

// assign
var a = 1;
// test
a; // 1

Now I need a little bit of marker that will tell the instrumentation that a; is a piece of code to execute and 1 is the expected value. I came up with //::

So the above becomes:

var a = 1;
a; //:: 1

I also like to add some more explanation besides the returned value. I use ,, for that. So:

var a = 1;
a; //:: 1,, as you'd expect

Instrumented, this becomes:

var a = 1;
assert.deepEqual("a;",  1, "Error line #2");

The line is the line in the book with all code and prose, so it's easy to find.

The special markup like //:: and ,, gets stripped by another script that I run before commit.

A few other features are: support for NaN which doesn't deepEqual to anything and expecting errors with assert.throws(..)

So I can write and test code like:

sum(21, 21); //:: 42
plum(21, 21); //:: Error:: plum() is not defined

(:: that follow Error is the same as ,,)

Also:

Number("3,14"); //:: NaN

So here's the code instrumentation:

// Add asserts
function prep(l, n) {
  var parts = l.split(/;\s*\/\/::/); // "//::" separates expression to execute from its result
  var nonspace = parts[0].match(/\S/);
  var spaces = nonspace === null ? "" : Array(nonspace.index + 1).join(" ");
  parts[0] = parts[0].trim();
  if (parts[1]) {
    var r = parts[1].split(/\s*(,,|::)\s*/)[0].trim(); // the result may have ,, or ::, ignore what's on the right
                                                       // e.g. //:: true,, of course!
                                                       // e.g. //:: ReferenceError::Invalid whatever
    if (r.indexOf('Error') !== -1) {
      // expect //:: Error to throw
      return spaces + 'assert.throws(function () {' + parts[0] + '; }, ' + r + ', "error line #' + n + '");';
    }
    if (r === 'NaN') {
      // special NaN case
      return spaces + 'assert(isNaN(' + parts[0] + '), true, "error line #' + n + '");'
    }
    // usual
    return spaces + 'assert.deepEqual(' + parts[0] + ', ' + r + ', "error line #' + n + '");';
  }
  return l;
}

Main

Dependencies, locals, options and the main parser loop is how it all begins/ends:

// dependencies where I can see them
var assert = require('assert');
var fs = require('fs');
var jslint = require('jshint').JSHINT;
 
// buncha local vars
var snip, rawsnip.....;
 
// short
var log = console.log;
 
 
// JSHint options 
var lintopts = {
  indent: 2,   // 2 spaces for indentation
  // ....
};
 
// read the book one line at a time
fs.readFileSync('book.asc').toString().split('\n').forEach(function(src, num) {
 
  // src is a line in the book
  // num is the line number
 
});

There are a few additional features at snippet-level:

Ability to continue from a previous snippet using --//-- at the top of the snippet delimiter

Let's declare a variable:

[source,js]
----------------------------------------------------------------------
var a = 1;
----------------------------------------------------------------------

And then another one:

[source,js]
--------------------------------------------------------------------//--
var b = 2;
----------------------------------------------------------------------

And let's sum

[source,js]
--------------------------------------------------------------------//--
a + b; //:: 3
----------------------------------------------------------------------

Ability to skip a non-working snippet using ////

[source,js]
----------------------------------------------------------------------////--
var 1v;  // invalid
----------------------------------------------------------------------

Ability to run in non-strict mode (because strict is default) using ++

[source,js]
----------------------------------------------------------------------++--
var a = 012;
a === 10; //:: true
----------------------------------------------------------------------

nolint option

[source,js]
----------------------------------------------------------------------
/*nolint*/
assoc["one"]; //:: 1
----------------------------------------------------------------------

Cleanup before commit

Cleaning up all instrumentation markers and hints for the lint and the tests (gist):

var clean = require('fs').readFileSync('book.asc').toString().split('\n').filter(function(line) {
  if (line.indexOf('/*nolint*/') === 0 || line.indexOf('/*global') === 0) {
    return false;
  }
  return true;
})
  .join('\n')
  .replace(/--\+\+--/g, '--')
  .replace(/--\/\/--/g, '--')
  .replace(/--\/\/\/\/--/g, '--');
 
console.log(clean);

Github gist

Here's the test.js script in its entirety.

Sorry, comments disabled and hidden due to excessive spam. Working on restoring the existing comments...

Meanwhile you can find me on twitter - @stoyanstefanov