Fork me on GitHub Expresso

Expresso

Insanely fast TDD framework for node featuring code coverage reporting.

expresso

bin/expresso

!/usr/bin/env node


 * Expresso
 * Copyright(c) TJ Holowaychuk <tj@vision-media.ca>
 * (MIT Licensed)
 

Module dependencies.

var assert = require('assert'),
    childProcess = require('child_process'),
    http = require('http'),
    path = require('path'),
    sys = require('sys'),
    cwd = process.cwd(),
    fs = require('fs'),
    defer;

Set the node env to test if it hasn't already been set

if( !process.env.NODE_ENV ) process.env.NODE_ENV = 'test';

Setup the regex which is used to match test files. Adjust it to include coffeescript files if CS is available

var file_matcher = /\.js$/;
try {
  require('coffee-script');
  file_matcher = /\.(js|coffee)$/;
} catch (e) {}

Expresso version.

var version = '0.8.1';

Failure count.

var failures = 0;

Indicates whether all test files have been loaded.

var suites = 0;
var suitesRun = 0;

Number of tests executed.

var testcount = 0;

Whitelist of tests to run.

var only = [];

Boring output.

var boring = false;

Growl notifications.

var growl = false;

Server port.

var port = 5555;

Execute serially.

var serial = false;

Default timeout.

var timeout = 2000;

Quiet output.

var quiet = false;

JSON code coverage report

var jsonCoverage = false;
var jsonFile;

Usage documentation.

var usage = ''
    + '[bold]{Usage}: expresso [options] <file ...>'
    + '\n'
    + '\n[bold]{Options}:'
    + '\n  -g, --growl          Enable growl notifications'
    + '\n  -c, --coverage       Generate and report test coverage'
    + '\n  -j, --json PATH      Used in conjunction with --coverage, ouput JSON coverage to PATH'
    + '\n  -q, --quiet          Suppress coverage report if 100%'
    + '\n  -t, --timeout MS     Timeout in milliseconds, defaults to 2000'
    + '\n  -r, --require PATH   Require the given module path'
    + '\n  -o, --only TESTS     Execute only the comma sperated TESTS (can be set several times)'
    + '\n  -I, --include PATH   Unshift the given path to require.paths'
    + '\n  -p, --port NUM       Port number for test servers, starts at 5555'
    + '\n  -s, --serial         Execute tests serially'
    + '\n  -b, --boring         Suppress ansi-escape colors'
    + '\n  -v, --version        Output version number'
    + '\n  -h, --help           Display help information'
    + '\n';

// Parse arguments

var files = [],
    args = process.argv.slice(2);

while (args.length) {
    var arg = args.shift();
    switch (arg) {
        case '-h':
        case '--help':
            print(usage + '\n');
            process.exit(1);
            break;
        case '-v':
        case '--version':
            sys.puts(version);
            process.exit(1);
            break;
        case '-i':
        case '-I':
        case '--include':
            if (arg = args.shift()) {
                require.paths.unshift(arg);
            } else {
                throw new Error('--include requires a path');
            }
            break;
        case '-o':
        case '--only':
            if (arg = args.shift()) {
                only = only.concat(arg.split(/ *, */));
            } else {
                throw new Error('--only requires comma-separated test names');
            }
            break;
        case '-p':
        case '--port':
            if (arg = args.shift()) {
                port = parseInt(arg, 10);
            } else {
                throw new Error('--port requires a number');
            }
            break;
        case '-r':
        case '--require':
            if (arg = args.shift()) {
                require(arg);
            } else {
                throw new Error('--require requires a path');
            }
            break;
        case '-t':
        case '--timeout':
          if (arg = args.shift()) {
            timeout = parseInt(arg, 10);
          } else {
            throw new Error('--timeout requires an argument');
          }
          break;
        case '-c':
        case '--cov':
        case '--coverage':
            defer = true;
            childProcess.exec('rm -fr lib-cov && node-jscoverage lib lib-cov', function(err) {
                if (err) throw err;
                require.paths.unshift('lib-cov');
                run(files);
            });
            break;
        case '-q':
        case '--quiet':
            quiet = true;
            break;
        case '-b':
        case '--boring':
            boring = true;
            break;
        case '-g':
        case '--growl':
            growl = true;
            break;
        case '-s':
        case '--serial':
            serial = true;
            break;
        case '-j':
        case '--json':
            jsonCoverage = true;
            if (arg = args.shift()) {
                jsonFile = path.normalize(arg);
            } else {
                throw new Error('--json requires file to write to');
            }
            break;
        default:
            if (file_matcher.test(arg)) {
                files.push(arg);
            }
            break;
    }
}

Colorized sys.error().

  • param: String str

function print(str) {
    sys.error(colorize(str));
}

Colorize the given string using ansi-escape sequences. Disabled when --boring is set.

  • param: String str

  • return: String

function colorize(str) {
    var colors = { bold: 1, red: 31, green: 32, yellow: 33 };
    return str.replace(/\[(\w+)\]\{([^]*?)\}/g, function(_, color, str) {
        return boring
            ? str
            : '\x1B[' + colors[color] + 'm' + str + '\x1B[0m';
    });
}

// Alias deepEqual as eql for complex equality

assert.eql = assert.deepEqual;

Assert that val is null.

  • param: Mixed val

  • param: String msg

assert.isNull = function(val, msg) {
    assert.strictEqual(null, val, msg);
};

Assert that val is not null.

  • param: Mixed val

  • param: String msg

assert.isNotNull = function(val, msg) {
    assert.notStrictEqual(null, val, msg);
};

Assert that val is undefined.

  • param: Mixed val

  • param: String msg

assert.isUndefined = function(val, msg) {
    assert.strictEqual(undefined, val, msg);
};

Assert that val is not undefined.

  • param: Mixed val

  • param: String msg

assert.isDefined = function(val, msg) {
    assert.notStrictEqual(undefined, val, msg);
};

Assert that obj is type.

  • param: Mixed obj

  • param: String type

  • api: public

assert.type = function(obj, type, msg) {
    var real = typeof obj;
    msg = msg || 'typeof ' + sys.inspect(obj) + ' is ' + real + ', expected ' + type;
    assert.ok(type === real, msg);
};

Assert that str matches regexp.

  • param: String str

  • param: RegExp regexp

  • param: String msg

assert.match = function(str, regexp, msg) {
    msg = msg || sys.inspect(str) + ' does not match ' + sys.inspect(regexp);
    assert.ok(regexp.test(str), msg);
};

Assert that val is within obj.

Examples

assert.includes('foobar', 'bar'); assert.includes(['foo', 'bar'], 'foo');

  • param: String | Array obj

  • param: Mixed val

  • param: String msg

assert.includes = function(obj, val, msg) {
    msg = msg || sys.inspect(obj) + ' does not include ' + sys.inspect(val);
    assert.ok(obj.indexOf(val) &gt;= 0, msg);
};

Assert length of val is n.

  • param: Mixed val

  • param: Number n

  • param: String msg

assert.length = function(val, n, msg) {
    msg = msg || sys.inspect(val) + ' has length of ' + val.length + ', expected ' + n;
    assert.equal(n, val.length, msg);
};

Assert response from server with the given req object and res assertions object.

  • param: Server server

  • param: Object req

  • param: Object | Function res

  • param: String msg

assert.response = function(server, req, res, msg) {
    var test = assert._test;

    // Callback as third or fourth arg
    var callback = typeof res === 'function'
        ? res
        : typeof msg === 'function'
            ? msg
            : function() {};

    // Default messate to test title
    if (typeof msg === 'function') msg = null;
    msg = msg || test.title;
    msg += '. ';

    // Add a unique token for this call to assert.response(). We'll move it to
    // succeeded/failed when done
    var token = new Error('Response not completed: ' + msg);
    test._pending.push(token);

    function check() {
        try {
            server.__port = server.address().port;
            server.__listening = true;
        } catch (err) {
            process.nextTick(check);
            return;
        }
        if (server.__deferred) {
            server.__deferred.forEach(function(fn) { fn(); });
            server.__deferred = null;
        }
    }

    // Pending responses
    server.__pending = server.__pending || 0;
    server.__pending++;

    // Check that the server is ready or defer
    if (!server.fd) {
        server.__deferred = server.__deferred || [];
        server.listen(server.__port = port++, '127.0.0.1', check);
    } else if (!server.__port) {
        server.__deferred = server.__deferred || [];
        process.nextTick(check);
    }

    // The socket was created but is not yet listening, so keep deferring
    if (!server.__listening) {
        server.__deferred.push(issue);
        return;
    } else {
        issue();
    }

    function issue() {
        // Issue request
        var timer,
            method = req.method || 'GET',
            status = res.status || res.statusCode,
            data = req.data || req.body,
            requestTimeout = req.timeout || 0,
            encoding = req.encoding || 'utf8';

        var request = http.request({
            host: '127.0.0.1',
            port: server.__port,
            path: req.url,
            method: method,
            headers: req.headers
        });

        var check = function() {
            if (--server.__pending === 0) {
                server.close();
                server.__listening = false;
            }
        };

        // Timeout
        if (requestTimeout) {
            timer = setTimeout(function() {
                check();
                delete req.timeout;
                test.failure(new Error(msg + 'Request timed out after ' + requestTimeout + 'ms.'));
            }, requestTimeout);
        }

        if (data) request.write(data);

        request.on('response', function(response) {
            response.body = '';
            response.setEncoding(encoding);
            response.on('data', function(chunk) { response.body += chunk; });
            response.on('end', function() {
                if (timer) clearTimeout(timer);
                try {
                    // Assert response body
                    if (res.body !== undefined) {
                        var eql = res.body instanceof RegExp
                          ? res.body.test(response.body)
                          : res.body === response.body;
                        assert.ok(
                            eql,
                            msg + 'Invalid response body.\n'
                                + '    Expected: ' + sys.inspect(res.body) + '\n'
                                + '    Got: ' + sys.inspect(response.body)
                        );
                    }

                    // Assert response status
                    if (typeof status === 'number') {
                        assert.equal(
                            response.statusCode,
                            status,
                            msg + colorize('Invalid response status code.\n'
                                + '    Expected: [green]{' + status + '}\n'
                                + '    Got: [red]{' + response.statusCode + '}')
                        );
                    }

                    // Assert response headers
                    if (res.headers) {
                        var keys = Object.keys(res.headers);
                        for (var i = 0, len = keys.length; i &lt; len; ++i) {
                            var name = keys[i],
                                actual = response.headers[name.toLowerCase()],
                                expected = res.headers[name],
                                eql = expected instanceof RegExp
                                  ? expected.test(actual)
                                  : expected == actual;
                            assert.ok(
                                eql,
                                msg + colorize('Invalid response header [bold]{' + name + '}.\n'
                                    + '    Expected: [green]{' + expected + '}\n'
                                    + '    Got: [red]{' + actual + '}')
                            );
                        }
                    }

                    // Callback
                    callback(response);

                    // Add this to the succeeded bin.
                    test.success(msg);
                } catch (err) {
                    test.failure(err);
                    test.callback();
                } finally {
                    // Remove our token.
                    var idx = test._pending.indexOf(token);
                    if (idx &gt;= 0) {
                        test._pending.splice(idx, 1);
                    } else {
                        // Someone else took our token. This is an error.
                        test.failure(new Error('Request succeeded, but token vanished: ' + msg));
                    }

                    // Potentially shut down the server.
                    check();
                }
            });
        });

        request.end();
      }
};

Pad the given string to the maximum width provided.

  • param: String str

  • param: Number width

  • return: String

function lpad(str, width) {
    str = String(str);
    var n = width - str.length;
    if (n &lt; 1) return str;
    while (n--) str = ' ' + str;
    return str;
}

Pad the given string to the maximum width provided.

  • param: String str

  • param: Number width

  • return: String

function rpad(str, width) {
    str = String(str);
    var n = width - str.length;
    if (n &lt; 1) return str;
    while (n--) str = str + ' ';
    return str;
}

Report test coverage in tabular format

  • param: Object cov

function reportCoverageTable(cov) {
    // Stats
    print('\n   [bold]{Test Coverage}\n');
    var sep = '   +------------------------------------------+----------+------+------+--------+',
        lastSep = '                                              +----------+------+------+--------+';
    sys.puts(sep);
    sys.puts('   | filename                                 | coverage | LOC  | SLOC | missed |');
    sys.puts(sep);
    for (var name in cov) {
        var file = cov[name];
        if (Array.isArray(file)) {
            sys.print('   | ' + rpad(name, 40));
            sys.print(' | ' + lpad(file.coverage.toFixed(2), 8));
            sys.print(' | ' + lpad(file.LOC, 4));
            sys.print(' | ' + lpad(file.SLOC, 4));
            sys.print(' | ' + lpad(file.totalMisses, 6));
            sys.print(' |\n');
        }
    }
    sys.puts(sep);
    sys.print('     ' + rpad('', 40));
    sys.print(' | ' + lpad(cov.coverage.toFixed(2), 8));
    sys.print(' | ' + lpad(cov.LOC, 4));
    sys.print(' | ' + lpad(cov.SLOC, 4));
    sys.print(' | ' + lpad(cov.totalMisses, 6));
    sys.print(' |\n');
    sys.puts(lastSep);
    // Source
    for (var name in cov) {
        if (name.match(file_matcher)) {
            var file = cov[name];
            if ((file.coverage &lt; 100) || !quiet) {
               print('\n   [bold]{' + name + '}:');
               print(file.source);
               sys.print('\n');
            }
        }
    }
}

Report test coverage in raw json format

  • param: Object cov

function reportCoverageJson(cov) {
    var report = {
        &quot;coverage&quot; : cov.coverage.toFixed(2),
        &quot;LOC&quot; : cov.LOC,
        &quot;SLOC&quot; : cov.SLOC,
        &quot;totalMisses&quot; : cov.totalMisses,
        &quot;files&quot; : {}
    };

    for (var name in cov) {
        var file = cov[name];
        if (Array.isArray(file)) {
            report.files[name] = {
                &quot;coverage&quot; : file.coverage.toFixed(2),
                &quot;LOC&quot; : file.LOC,
                &quot;SLOC&quot; : file.SLOC,
                &quot;totalMisses&quot; : file.totalMisses
            };
        }
    }

    fs.writeFileSync(jsonFile, JSON.stringify(report), &quot;utf8&quot;);
}

Populate code coverage data.

  • param: Object cov

function populateCoverage(cov) {
    cov.LOC =
    cov.SLOC =
    cov.totalFiles =
    cov.totalHits =
    cov.totalMisses =
    cov.coverage = 0;
    for (var name in cov) {
        var file = cov[name];
        if (Array.isArray(file)) {
            // Stats
            ++cov.totalFiles;
            cov.totalHits += file.totalHits = coverage(file, true);
            cov.totalMisses += file.totalMisses = coverage(file, false);
            file.totalLines = file.totalHits + file.totalMisses;
            cov.SLOC += file.SLOC = file.totalLines;
            if (!file.source) file.source = [];
            cov.LOC += file.LOC = file.source.length;
            file.coverage = (file.totalHits / file.totalLines) * 100;
            // Source
            var width = file.source.length.toString().length;
            file.source = file.source.map(function(line, i) {
                ++i;
                var hits = file[i] === 0 ? 0 : (file[i] || ' ');
                if (!boring) {
                    if (hits === 0) {
                        hits = '\x1b[31m' + hits + '\x1b[0m';
                        line = '\x1b[41m' + line + '\x1b[0m';
                    } else {
                        hits = '\x1b[32m' + hits + '\x1b[0m';
                    }
                }
                return '\n     ' + lpad(i, width) + ' | ' + hits + ' | ' + line;
            }).join('');
        }
    }
    cov.coverage = (cov.totalHits / cov.SLOC) * 100;
}

Total coverage for the given file data.

  • param: Array data

  • return: Type

function coverage(data, val) {
    var n = 0;
    for (var i = 0, len = data.length; i &lt; len; ++i) {
        if (data[i] !== undefined &amp;&amp; data[i] == val) ++n;
    }
    return n;
}

Test if all files have 100% coverage

  • param: Object cov

  • return: Boolean

function hasFullCoverage(cov) {
  for (var name in cov) {
    var file = cov[name];
    if (file instanceof Array) {
      if (file.coverage !== 100) {
          return false;
      }
    }
  }
  return true;
}

Run the given test files, or try test/*.

  • param: Array files

function run(files) {
    cursor(false);
    if (!files.length) {
        try {
            files = fs.readdirSync('test').map(function(file) {
                return 'test/' + file;
            }).filter(function(file) {
                return !(/(^\.)|(\/\.)/.test(file));
            });
        } catch (err) {
            print('\n  failed to load tests in [bold]{./test}\n');
            ++failures;
            process.exit(1);
        }
    }
    runFiles(files);
}

Show the cursor when show is true, otherwise hide it.

  • param: Boolean show

function cursor(show) {
    if (boring) return;
    if (show) {
        sys.print('\x1b[?25h');
    } else {
        sys.print('\x1b[?25l');
    }
}

Run the given test files.

  • param: Array files

function runFiles(files) {
    files = files.filter(function(file) {
        return file.match(file_matcher);
    });
    suites = files.length;

    if (serial) {
        (function next() {
            if (files.length) {
                runFile(files.shift(), next);
            }
        })();
    } else {
        files.forEach(runFile);
    }
}

Run tests for the given file, callback fn() when finished.

  • param: String file

  • param: Function fn

function runFile(file, fn) {
    var title = path.basename(file),
        file = path.join(cwd, file),
        mod = require(file.replace(file_matcher,''));
    (function check() {
       var len = Object.keys(mod).length;
       if (len) {
           runSuite(title, mod, fn);
           suitesRun++;
       } else {
           setTimeout(check, 20);
       }
    })();
}

Run the given tests, callback fn() when finished.

  • param: String title

  • param: Object tests

  • param: Function fn

var dots = 0;
function runSuite(title, tests, callback) {
    // Keys
    var keys = only.length
        ? only.slice(0)
        : Object.keys(tests);

    // Setup
    var setup = tests.setup || function(fn, assert) { fn(); };
    var teardown = tests.teardown || function(fn, assert) { fn(); };

    process.setMaxListeners(10 + process.listeners('beforeExit').length  + keys.length);

    // Iterate tests
    (function next() {
        if (keys.length) {
            var key,
                fn = tests[key = keys.shift()];
            // Non-tests
            if (key === 'setup' || key === 'teardown') return next();

            // Run test
            if (fn) {
                var test = new Test({
                    fn: fn,
                    suite: title,
                    title: key,
                    setup: setup,
                    teardown: teardown
                })
                test.run(next);
            } else {
                // @TODO: Add warning message that there's no test.
                next();
            }
        } else if (serial) {
            callback();
        }
    })();
}

require('util').inherits(Test, require('events').EventEmitter);
function Test(options) {
    for (var key in options) {
        this[key] = options[key];
    }
    this._succeeded = [];
    this._failed = [];
    this._pending = [];
    this._beforeExit = [];
    this.assert = { __proto__: assert, _test: this };

    var test = this;
    process.on('beforeExit', function() {
        try {
            test.emit('exit');
        } catch (err) {
            test.failure(err);
        }
        test.report();
    });
}

Test.prototype.success = function(err) {
    this._succeeded.push(err);
};

Test.prototype.failure = function(err) {
    this._failed.push(err);
    this.error(err);
};

Test.prototype.report = function() {
    for (var i = 0; i &lt; this._pending.length; i++) {
        this.error(this._pending[i]);
    }
};

Test.prototype.run = function(callback) {
    try {
        ++testcount;
        assert._test = this;

        if (serial) {
            this.runSerial(callback);
        } else {
            // @TODO: find a way to run setup/tearDown.
            this.runParallel();
            callback();
        }
    } catch (err) {
        this.failure(err);
        this.report();
    }
};

Test.prototype.runSerial = function(callback) {
    var test = this;
    sys.print('.');
    if (++dots % 25 === 0) sys.print('\n');
    test.setup(function() {
        if (test.fn.length &lt; 1) {
            test.fn();
            test.teardown(callback);
        } else {
            var id = setTimeout(function() {
                throw new Error(&quot;'" + key + "' timed out&quot;);
            }, timeout);
            test.callback = function() {
                clearTimeout(id);
                test.teardown(callback);
                process.nextTick(function() {
                    test.report();
                });
            };
            test.fn(test.callback);
        }
    });
};

Test.prototype.runParallel = function() {
    var test = this;
    test.fn(function(fn) {
        test.on('exit', function() {
            fn(test.assert);
        });
    }, test.assert);
};

Report err for the given test and suite.

  • param: String suite

  • param: String test

  • param: Error err

Test.prototype.error = function(err) {
    if (!err._reported) {
        ++failures;
        var name = err.name,
            stack = err.stack ? err.stack.replace(err.name, '') : '',
            label = this.title === 'uncaught'
                ? this.title
                : this.suite + ' ' + this.title;
        print('\n   [bold]{' + label + '}: [red]{' + name + '}' + stack + '\n');
        err._reported = true;
    }
}

Report exceptions.

function report() {
    cursor(true);
    process.emit('beforeExit');
    if (suitesRun &lt; suites) {
        print('\n   [bold]{Failure}: [red]{Only ' + suitesRun + ' of ' + suites + ' suites have been started}\n\n');
    }
    else if (failures) {
        print('\n   [bold]{Failures}: [red]{' + failures + '}\n\n');
        notify('Failures: ' + failures);
    } else {
        if (serial) print('');
        print('\n   [green]{100%} ' + testcount + ' tests\n');
        notify('100% ok');
    }
    if (typeof _$jscoverage === 'object') {
        populateCoverage(_$jscoverage);
        if (!hasFullCoverage(_$jscoverage) || !quiet) {
            (jsonCoverage ? reportCoverageJson(_$jscoverage) : reportCoverageTable(_$jscoverage));
        }
    }
}

Growl notify the given msg.

  • param: String msg

function notify(msg) {
    if (growl) {
        childProcess.exec('growlnotify -name Expresso -m "' + msg + '"');
    }
}

// Report uncaught exceptions
var unknownTest = new Test({
    suite: 'uncaught',
    test: 'uncaught'
});

process.on('uncaughtException', function(err) {
    unknownTest.error(err);
});

// Show cursor

['INT', 'TERM', 'QUIT'].forEach(function(sig) {
    process.on('SIG' + sig, function() {
        cursor(true);
        process.exit(1);
    });
});

// Report test coverage when available
// and emit "beforeExit" event to perform
// final assertions

var orig = process.emit;
process.emit = function(event) {
    if (event === 'exit') {
        report();
        process.reallyExit(failures);
    }
    orig.apply(this, arguments);
};

// Run test files

if (!defer) run(files);