I’m writing the blog I wish I’d seen about a month ago. Getting to the point of having unit tests with mocking and watching to work with ESM (ECMAScript Modules) was much harder than I at first envisioned.
This is a story of failure. There were times when I thought there would be no happy ending. So, for past me (and possibly future you), I’m going to explain what didn’t work and what eventually did.
Background
First I’m going to talk a little bit about what I was trying to test. The repo I was looking at is a node app with two parts: an Azure function written in CommonJS and an API written in ESM (complete with .mjs extensions). The CommonJS modules also used some of the ESM scripts through dynamic imports. This helped with reusability, but did mean you had to hunt a bit to know everything any given file was using.
I should note that a lot of my pain was caused by trying to use Jest. Why did I do this to myself, you ask?
- Jest’s watch mode is great, and they have it working very nicely with ESM.
- I’m a bit lazy, and Jest comes with tons of features, like matchers and coverage, that you have to add and configure separately in Mocha.
- The other projects for this group are React with CommonJS. Jest is a natural fit for those types of projects. I was hoping to save the other devs some grief by giving them a consistent test framework.
- I can be a bit too stubborn for my own good. Once I started down the Jest path I had to convince myself it was, in fact, not going to work.
For those who would like some more information on the differences between ESM and CommonJS, this blog from Redfin gives a really great overview. I found the article about halfway through my desperate search for answers. For the official documentation on the differences, you can find the Node documentation here.
The Jest path
“This blog is about Mocha, why are you going on about Jest?” you may be asking. Well, a couple of reasons. First, it’s important to talk about the times we failed, miserably, after many attempts. While it’s definitely fun to pretend I’m an all-knowing deity who never makes mistakes, that’s not the reality. I personally think part of the reason so many people get imposter syndrome is because there’s a cloud of secrecy around the things that don’t work. I want to help dispel that cloud.
The second reason is that I did a lot, and I mean a lot, of Googling around “how to test ESM with Jest” and found very little information about it. There was the occasional “just use x or y instead” post, but not a lot of detail on why. As mentioned previously, this blog is for past me more than anyone else.
Thirdly, maybe writing what went wrong will reveal to me or someone else what I missed. I think I went through all the permutations, but maybe I missed something. It’s very possible!
So here are some of the things I tried, in no particular order (because I have forgotten the order I did them in):
- Tried mocking with Jest mocks, even though they 100% say in their documentation that mocking is not available yet for ESM.
- Attempted to use Babel to transpile everything and then use Jest. This mostly didn’t work because of all the dynamic imports and the use of the .mjs extension. There was also pushback on testing transpiled code when we don’t use it in production.
- Tried to use testdouble.js for mocking. I’ll go into how ESM mocking with testdouble.js works later when I talk about Mocha, but for Jest it didn’t work due to a conflict with how Jest handles loaders. My sadness did inspire a PR to prevent a problem I found attempting to use experimental loaders, so that’s good!
I definitely tried a bunch of other things I’ve since forgotten. I read many issues, blogs and StackOverflow questions about Jest, ESM, CommonJS, and mocking. I broke my PR and reverted again and again. Eventually, I gave up on my original plan.
The Mocha path
Once the Jest team confirmed I couldn’t do what I wanted, I scrapped most of the branch I had been working on. I kept the test cases I had created. But all the package changes, configuration files, and any documentation went straight into the trash. It was garbage and I had failed.
Now, in all my hunting I had seen a few references to using testdouble.js with Mocha, including at one point having this mocha section linked when I was looking up things about mocking ESM scripts.
It was actually embarrassing how easy the setup was once I had all the Mocha/Chai/testdouble packages installed. Here’s the package.json scripts section:
"scripts": {
"test": "mocha --loader=testdouble",
"test:watch": "mocha --loader=testdouble test/unit --parallel --watch",
"test:cov": "c8 mocha --loader=testdouble"
}
If you’re familiar with common Mocha setups, you might notice a couple of oddities. Despite what the Mocha docs say, watch mode can work if you run in parallel. However, I wouldn’t recommend this with large test suites as it will run all the tests on every save. This is why we limited the watch mode to just unit tests. You might also note I’m using c8 for coverage instead of nyc (the coverage package formally known as Istanbul). This is actually recommended by the nyc team, although they have an experimental loader for anyone who wants to try that out instead.
The settings for mocha only required a little bit of customization, mostly because of the mix of CommonJS and ESM. The main addition that mattered for the .mocharc.json was the extension setting:
{
"extension": ["js", "cjs", "mjs"],
"ui": "bdd",
"require": "mocha.setup.mjs"
}
Finally, we added a setup file to give us some globals (because I hate adding Mocha and Chai to every test file) and reset any doubles we add:
import chai from 'chai';
import chaiAsPromised from 'chai-as-promised';
chai.use(chaiAsPromised);
global.expect = chai.expect;
import * as td from 'testdouble';
global.td = td;
export const mochaHooks = {
afterEach() {
td.reset();
}
};
Once that was all done, writing the tests was a cinch!
describe('moduleToTest', () => {
let moduleToMock;
let moduleToTest;
beforeEach(async () => {
moduleToMock = await td.replaceEsm('./fake/moduleToMock.mjs');
moduleToTest = await import('./fake/moduleToTest.mjs');
});
it('works and calls the mock', async () => {
td.when(moduleToMock.prototype.doAThing(td.matchers.anything())).thenResolve();
await expect(moduleToTest.realFunction()).to.eventually.be.fulfilled;
});
});
The road goes ever ever on
Software development to me always feels like a long, occasionally arduous, journey. It often feels as though there’s an infinite amount of learning to do. The problems of today will not be the problems of tomorrow. We’ll never have all the solutions, and sometimes finding alternatives is frustrating.
All we can do is forge ahead, and leave a trail.