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.