Skip to content

Instantly share code, notes, and snippets.

@tomazzaman
Created October 28, 2015 11:00
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save tomazzaman/34aa5ab95501030e46f2 to your computer and use it in GitHub Desktop.
Save tomazzaman/34aa5ab95501030e46f2 to your computer and use it in GitHub Desktop.
Fast mocha runner with instrumentation support. Run it with `babel-node runner.js`
import fs from 'fs';
import path, { resolve } from 'path';
import assert from 'assert';
import Module from 'module';
import jsdom from 'jsdom';
import Mocha from 'mocha';
import chokidar from 'chokidar';
import { isMatch } from 'micromatch';
import log from 'fancy-log';
import async from 'async';
import colors from 'colors';
import { Instrumenter } from 'isparta';
import { hook, Collector, Report } from 'istanbul';
import utils from 'istanbul/lib/object-utils.js';
const instrumenter = new Instrumenter({coverageVariable: '__coverage__'});
let singleRun = false;
process.argv.forEach(val => {
if (val === '--single-run') singleRun = true;
});
// Let's import and globalize testing tools so
// there's no need to require them in each test
import sinon from 'sinon';
import chai, { expect } from 'chai';
import chaiAsPromised from 'chai-as-promised';
chai.use(chaiAsPromised);
// Environment setup (used by Babel as well, see .babelrc)
process.env.NODE_ENV = 'test';
/**
* Monkey-patching the Function prototype so we can have require.ensure working.
* Easier achieved than hacking the Module for targeting the "require" specifically.
*/
Function.prototype.ensure = (arr, func) => func(); //eslint-disable-line
/*eslint-disable */
/**
* Monkey-patching native require, because Webpack supports requiring files, other
* than JavaScript. But Node doesn't recognize them, so they should be ignored.
* IMPORTANT: don't use arrow functions because they change the scope of 'this'!
*/
Module.prototype.require = function require(path) {
const types = /(s?css|sass|less|svg|html|png|jpe?g|gif)$/;
if (path.search(types) !== -1) return;
assert(typeof path === 'string', 'path must be a string');
assert(path, 'missing path');
// Mimics Webpack's "alias" feature
if (path === 'config') {
path = resolve('./src/js/secrets/test.js');
}
return Module._load(path, this);
};
/*eslint-enable */
// localStorage poyfill
const localStorage = {
_data: {},
setItem: function setItem(id, val) { this._data[id] = String(val); },
getItem: function getItem(id) {
return this._data.hasOwnProperty(id) ? this._data[id] : undefined;
},
removeItem: function removeItem(id) { delete this._data[id]; },
clear: function clear() { this._data = {}; },
};
// setup the simplest document possible
const doc = jsdom.jsdom('<!doctype html><html><body></body></html>');
const win = doc.defaultView;
// set globals for mocha that make access to document and window feel
// natural in the test environment
global.document = doc;
global.window = win;
global.window.localStorage = localStorage;
global.self = global;
global.chai = chai;
global.expect = expect;
global.sinon = sinon;
/**
* Take all the properties of the window object and attach them to the mocha
* global object. This is to prevent 'undefined' errors which sometime occur.
* Gotten from: http://jaketrent.com/post/testing-react-with-jsdom/
* @param {object} window: The fake window, build by jsdom
*/
((window) => {
for (const key in window) {
if (! window.hasOwnProperty(key)) continue;
if (key in global) continue;
global[ key ] = window[ key ];
}
})(win);
/**
* hook require patches the original require so that our instrumented source
* files are required instead of the original ones. Otherwise coverage won't work.
* http://gotwarlost.github.io/istanbul/public/apidocs/classes/Hook.html
*/
const fileMap = {};
hook.hookRequire(
filepath => !!fileMap[filepath],
(code, filepath) => fileMap[filepath]
);
/**
* When running CI tests, we need to check the average branch coverage -
* the most important test metric we use. Exit with 1 if below 85%.
*/
function checkBranchCoverage(collector) {
if (!singleRun) return;
const summaries = [];
let finalSummary;
collector.files().map(file => {
summaries.push(utils.summarizeFileCoverage(collector.fileCoverageFor(file)));
});
finalSummary = utils.mergeSummaryObjects.apply(null, summaries);
if (parseInt(finalSummary.branches.pct, 10) < 85) {
log.error('Warning! Test coverage below 85%');
process.exit(1);
} else {
process.exit(0);
}
}
/**
* Generate a report once the tests are done running. We can have as many
* reporters as needed.
*/
function generateReport(errors) {
if (errors) {
if (singleRun) process.exit(1);
return;
}
const collector = new Collector();
collector.add(global.__coverage__);
['text', 'html'].map(type => {
const reporter = Report.create(type, {dir: 'coverage'});
reporter.writeReport(collector, true);
});
checkBranchCoverage(collector);
}
/**
* Mocha side of things. Before we can show a report we need to run all our
* test, so that the instrumented code gets executed. We traverse through all the
* source files and only add them to Mocha if they are in the test folder.
* It's also important to remove the require cache for the file so that the updated
* suite will run on each save.
*/
function runSuite(files) {
Object.keys( require.cache ).forEach( key => delete require.cache[ key ] );
global.__coverage__ = {};
const mocha = new Mocha({ reporter: 'dot' });
files.forEach(filepath => mocha.addFile(path.resolve(filepath)));
try {
log('Running suite...');
mocha.run(generateReport);
} catch (error) {
log.error(error);
}
}
/**
* Instumenter is responsible to create a special version of the source and
* replace the originaly required with this version.
* @param {string} filepath Full path to the file
*/
function instrumentFile(filepath, done) {
log('Instrumenting file:', filepath.bold.green);
async.waterfall([
fs.readFile.bind(fs, filepath, 'utf8'),
(source, callback) => instrumenter.instrument(source, filepath, callback),
(code, callback) => {
fileMap[filepath] = code;
callback();
},
], done);
}
/**
* Watch files for change and run the whole suite *every time*. This is needed
* because we need to rebuild all the sources to generate accurate reports.
* We're abusing chokidar a bit, since it has no internal tracking of files,
* which is why we create two instance properties, sourceFiles and testFiles.
*/
const sourcesGlob = 'src/**/*.js';
const testsGlob = 'test/**/*.spec.js';
const watcher = chokidar.watch([sourcesGlob, testsGlob]);
watcher.on('add', function add(filepath) {
this.sourceFiles = this.sourceFiles || [];
this.testFiles = this.testFiles || [];
if (isMatch(filepath, sourcesGlob)) {
this.sourceFiles.push(filepath);
} else if (isMatch(filepath, testsGlob)) {
this.testFiles.push(filepath);
}
});
watcher.on('ready', function ready() {
async.each(this.sourceFiles, (item, callback) => {
instrumentFile(path.resolve(item), callback);
}, (error) => {
if (error) throw new Error(error);
log('Instrumentation complete.');
runSuite(this.testFiles);
});
});
watcher.on('change', function change(filepath) {
if (isMatch(filepath, sourcesGlob)) {
instrumentFile(path.resolve(filepath), () => {
runSuite(this.testFiles);
});
}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment