Testing a CLI utility using Nodejs is extremely easy, even without using any 3rd party tool. Here is how I unit test a cli utility using a homebrew 10 liner.
The CLI test subject
A tiny command line script that outputs back passed command line argument, twice.
// index.js
if (!process.argv[2]) { process.exit(1); }
console.log('' + process.argv[2] + process.argv[2]);
// example
$ node index.js hello!
hello!hello!
Testing goals
The test cases need to confirm correct STDOUT output and exit codes.
node index.js //=> no output, exit code 1
node index.js foo // exit code 0, outputs foofoo
The testing engine should keep track of the number of running tests (because everything is being tested asynchronously) and exit with the number of failed assertions. If everything passes, the exit code should be 0.
Homebrew testing framework
While there are fine testing frameworks for Node, like mocha and my own gt, for this exercise I rolled a homebrew framework. Its biggest advantages are simplicity and easy adjustment to any specific project's needs. I start with writing a tiny utility function that would fail an assertion based on condition.
// exec-test.js
var failed = 0;
function check(name, condition, actual, expected, message) {
if (!condition) {
console.error('test "' + name + '" failed,', message);
console.error(' Expected "' + expected + '", got "' + actual + '"');
failed += 1;
}
}
This is common pattern, for example
QUnit.push is used the same
way: our other assertions can report failures via single check
call. We can even expose
this function to the user code via assert
object to allow creating custom assertions.
The main function exposed to the user is called execTest
in the same file.
Here is the book keeping part
// exec-test.js after check function
var running = 0;
function execTest(cmd, expectedExitCode, expr) {
running += 1;
// exec CLI command
// when finishes decrement running counter
if (!running) { process.exit(failed); } // all done
}
module.exports = execTest;
Here is the inside of the execTest
function, I am using Node's child process
to actually execute the shell command, in this case without any options.
function execTest(cmd, expectedExitCode, expr) {
var exec = require('child_process').exec;
running += 1;
exec(cmd, function (err, stdout, stderr) {
running -= 1;
if (err) {
check(cmd, err.code === expectedExitCode,
err.code, expectedExitCode,
'exit code\n---\n' + stderr + '---'); // 1
} else {
if (expr) {
stdout = stdout.trim();
check(cmd, expr.test(stdout),
stdout, expr.toString(), 'output'); // 2
}
}
if (!running) { process.exit(failed); }
});
}
Notice how exit code comparison uses check
function in // 1
.
The success condition is simple strict equality err.code === expectedExitCode
.
The order of actual vs expected arguments is not important, and is up to you
in this case. I always forget which one is first.
Comparing program's output using regular expression in // 2
is more robust than string
comparison. It only needs to happen if the exit code is unexpected. In this particular
case, if the exit code is unexpected, we print the stderr
output surrounding
it by delimiters. This will come handly when we combine multiple tests into a hierarchy.
User tests
Lets write a few tests
// test.js
var execTest = require('./exec-test');
execTest('node index.js foo', 0, /^foofoo$/);
execTest('node index.js 2', 0, /^22$/);
execTest('node index.js', 1);
If all tests pass, there is no output. Boring, but useful. Lets change the expected outputs for two tests on purpose
// test.js with failing tests
var execTest = require('./exec-test');
execTest('node index.js foo', 0, /^foofoo$/);
execTest('node index.js 2', 0, /^55$/);
execTest('node index.js', 2);
// run
$ node test.js
test "node index.js" failed, exit code
---
---
Expected "2", got "1"
test "node index.js 2" failed, output
Expected "/^55$/", got "22"
Because these unit tests are so simple, we can easily place this command into
package.json for both npm test
and pre-commit.
"scripts": {
"test": "node test.js"
},
"pre-commit": "node test.js"
Tests of tests
Notice that node test.js
is a shell command.
Thus we can test it using execTest
itself! This might come handy when
combining lots of unit tests into a hierarchy without using modules or suites of tests
(because our tiny testing engine does not support them).
Lets write a top level module that runs other exec tests, with a single assertion for now
// all-tests.js
var execTest = require('./exec-test');
execTest('node test.js', 0);
This test does not check the output (because it is successful, there should not be any). It only checks that command exited cleanly. Since we left test.js with failing unit tests, here is what the top level test reports
$ node all-tests.js
test "node test.js" failed, exit code
---
test "node index.js" failed, exit code
---
---
Expected "2", got "1"
test "node index.js 2" failed, output
Expected "/^55$/", got "22"
---
Expected "0", got "2"
The output might be a little more clear, but overall a powerful tool from just a few lines of JavaScript.
Missing
It would be easy to extend this engine with more assertions, but in my opinion
the most important missing feature is the sequential test execution. Currently,
due to Node's asynchronous nature, if you write 3 execTest
commands,
all 3 will run in parallel, and the order is not guaranteed. This might be fine
in this case, but might break for larger programs. Luckily this feature would
be easy to implement via a single 3rd party module, like
async or cadence.