Tuesday, September 18, 2018

3 Ways to Use ES6 Modules with Mocha

The problem: Mocha was written long before es6 modules became common within browsers. It's a classic node program that internally uses require() fn to load each test file. Therefore, running mocha under --experimental-modules node flag does no good, for in the es6 modules mode there is no require() fn.

Say we have 2 modules:

$ cat foo.mjs
export default function() { return 'foo' }

$ cat bar.mjs
export default function() { return 'bar' }

&

$ cat package.json
{
"devDependencies": {
"mocha": "5.2.0"
}
}

How can we test them using the celebrated Mocha that doesn't know anything about es6 modules?

1. Babel 7

Cons
Slow
Depends on Babel with a 3rd party plugin
Cumbersome to setup

This is the most famous method & the most sluggish one. It works like this: Mocha instructs Babel to convert import statements into require() calls, bind Babel to those calls through a special hook, then compiles the rest of mandatory files on the fly. Though it caches the compilation results (see node_modules/.cache dir), it's nevertheless noticeably slower than running the equivalent tests written in commonjs style.

It you like your node_modules directory fat, this option is for you!

We write tests for each module in a separate file. For foo module we use the usual static imports:

$ cat foo_test.mjs
import assert from 'assert'
import foo from './foo.mjs'

suite('Foo', function() {
test('smoke', function() {
assert.equal(foo(), 'foo')
})
})

but for bar module we employ a dynamic import to make the test less ordinary:

$ cat bar_test.mjs
import assert from 'assert'

suite('Bar', function() {
test('smoke', function() {
return import('./bar.mjs').then( module => {
let bar = module.default
assert.equal(bar(), 'bar')
})
})
})

Now we need to specify a proper list of dependencies, then write a configuration for Babel. For simplicity's sake, we confine everything to package.json:

{
  "devDependencies": {
    "@babel/core": "7.1.0",
    "@babel/preset-env": "7.1.0",
    "@babel/register": "7.0.0",
    "babel-plugin-dynamic-import-node": "2.1.0",
    "mocha": "5.2.0"
  },
  "babel": {
    "plugins": [
      [
        "dynamic-import-node"
      ]
    ],
    "presets": [
      [
        "@babel/preset-env",
        {
          "targets": {
            "node": "current"
          }
        }
      ]
    ]
  }
}

This is an absolute minimum (which is a sad state of affairs). You may omit babel-plugin-dynamic-import-node if you don't use dynamic imports (but we do here). Btw, don't be tempted to use the official @babel/plugin-syntax-dynamic-import plugin–it doesn't do any transformations at all, for it's written in the main for tools like Webpack that can handle the conversions in place of Babel.

Run the tests:

$ npm i
...
$ node_modules/.bin/mocha -R list --require @babel/register -u tdd *test.mjs

✓ Bar smoke: 4ms
✓ Foo smoke: 0ms

2 passing (21ms)

The size of the dependency tree is of course depressing:

$ rm -rf node_modules/.cache
$ du -h --max-depth=0 node_modules
16M node_modules
$ find node_modules -type f | wc -l
4870
$ find node_modules -name package.json | wc -l
161

2. In the browser

Pros Cons
No compilation step Chai dependency
Every test file must be listed explicitly

The version of Mocha for the browser obviously doesn't contain any require() fn calls & doesn't know about any module systems.

Our package.json looks much simpler:

{
"devDependencies": {
"mocha": "5.2.0",
"chai": "4.1.2"
}
}

Unfortunately we need to edit *_test.mjs files, for neither the browser doesn't have a built-in assert module, nor there is a way to inline the wrapper around Chai for mocking a global es6 module. Thus, foo_test.js becomes:

import foo from './foo.mjs'

suite('Foo', function() {
test('smoke', function() {
// assert is global & comes from Chai
assert.equal(foo(), 'foo')
})
})

(The same goes for bar_test.mjs.)

The test runner is an html page:

<!doctype html>
<link rel="stylesheet" href="node_modules/mocha/mocha.css">

<script src="node_modules/chai/chai.js"></script>
<script src="node_modules/mocha/mocha.js"></script>

<div id="mocha"></div>

<script type="module">
window.assert = chai.assert
mocha.setup('tdd')
</script>

<script type="module" src="foo_test.mjs"></script>
<script type="module" src="bar_test.mjs"></script>

<script type="module">
mocha.run()
</script>

Nothing should be surprising here, except that the "setup" parts must be marked as "modules" too.

3. Monkey Patching

Pros Cons
No compilation step A custom wrapper instead of the mocha executable
0 dependencies

Mocha has an API. It expects a commonjs usage, but we can always overwrite 2 methods in Mocha class: loadFiles() & run():

$ cat mocha.mjs
import path from 'path'
import fs from 'fs'
import Mocha from './node_modules/mocha/index.js'

Mocha.prototype.loadFiles = async function(fn) {
var self = this;
var suite = this.suite;
for await (let file of this.files) {
file = path.resolve(file);
suite.emit('pre-require', global, file, self);
suite.emit('require', await import(file), file, self);
suite.emit('post-require', global, file, self);
}
fn && fn();
}

Mocha.prototype.run = async function(fn) {
if (this.files.length) await this.loadFiles();

var suite = this.suite;
var options = this.options;
options.files = this.files;
var runner = new Mocha.Runner(suite, options.delay);
var reporter = new this._reporter(runner, options);

function done(failures) {
if (reporter.done) {
reporter.done(failures, fn);
} else {
fn && fn(failures);
}
}

runner.run(done);
};

let mocha = new Mocha({ui: 'tdd', reporter: 'list'})
process.argv.slice(2).forEach(mocha.addFile.bind(mocha))
mocha.run( failures => { process.exitCode = failures ? -1 : 0 })

Using node v10.10.0:

$ node --experimental-modules mocha.mjs *test.mjs
(node:100618) ExperimentalWarning: The ESM module loader is experimental.

✓ Bar smoke: 2ms
✓ Foo smoke: 0ms

2 passing (16ms)

The wrapper is intentionally bare bone. loadFiles() fn here is very similar to its original version, only it became async, for the es6 dynamic import statement returns a promise. run() fn, on the other hand, is significantly shorter then the original, for we don't support any CLOs & assume any CL argument to be a file name.

You can add at least -g option to the wrapper as a homework.