Created
October 28, 2015 11:00
-
-
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`
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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