I build APIs, scripts and other node flavored tooling using my favorite transpiler, swc. Ever since the esm kerfuffle of July 2022, I have been waiting on jest’s implementation of viable esm testing support. Testing is awfully hard without mocks. Who knew.
Anyway, on a cold Friday afternoon, I tried converting a small project (around 20 files or so) from a CommonJS target compilation to an ESM target compilation. The code was already in TypeScript format. Internally it used import
and export
, but it desugared that into the CommonJS require
syntax instead. Outside of that, ESM compliance was just extension tweaks and ensuring the compilation output was matching.
Getting the codebase over to ESM wasn’t trivial, but it wasn’t too bad either. Here are steps I used:
- Using a search
import\s+(.*)"(\..*)";
and replaceimport $1 "$2.js";
regex; I renamed most imports from their extensionless variant to theiresm
variant ending in.js
(usually this step seems the most unbearable, especially for projects with many more files) - I added
"type": "module",
to thepackage.json
to enable esm modules at the node level - I updated the
.swcrc
file’smodule.type
property fromCommonJS
toes6
(this nomenclature makes a lot… wait, no sense) - I updated the
tsconfig.json
, specifically themodule
property fromCommonJS
toNodeNext
(what’s consistency, again?)
I had to make various edits to imports that my global search and replace didn’t find (I did not account for multiline imports, ooops). If there was a working codemod for this purpose, I’d use it.
Jest
Next up, testing.
Jest requires additional steps for ESM compatible testing, like adding NODE_OPTIONS=--experimental-vm-module
to your environment, or at least prefixing your test commands with that. Experimental VM support is available in Node 16 at least, but there’s probably better support on more recent versions too.
Even with the flag, I received an error:
ReferenceError: module is not defined in ES module scope This file is being treated as an ES module because it has a ‘.js’ file extension and ‘/Users/ryan/Code/tooling/package.json’ contains “type”: “module”. To treat it as a CommonJS script, rename it to use the ‘.cjs’ file extension.
Since this package runs in ESM mode now, I have to rename the jest.config.js
to jest.config.cjs
for CommonJS
.
After that, more errors:
Cannot find module ‘../system/system.js’ from ‘src/system/system.ts’
This happened in every test file that was importing a unit.
swc
compiles your TypeScript code into JavaScript ESM flavored code. I thought it would do this on the fly in memory, without writing out to the ./dist
folder like it does during a normal build, just for this special purpose of testing, and then pass that in memory representation right over to jest. I guess that doesn’t quite work out either.
I read about the extensionsToTreatAsEsm
in the jest docs, but that wasn’t enough. Luckily I found this thread for @swc/jest
“ES import of typescript with .js extension fails”. I found this by reading through the repositories issue list. I never saw this come in a search result, unfortunately.
module.exports = {
transform: {
"^.+\\.(t|j)sx?$": "@swc/jest",
},
moduleNameMapper: { // <--- this one
"^(\\.{1,2}/.*)\\.js$": "$1",
},
extensionsToTreatAsEsm: [".ts", ".tsx"], // <--- this one
collectCoverageFrom: ["./src/**"],
coveragePathIgnorePatterns: [".*__snapshots__/.*"],
testPathIgnorePatterns: ["node_modules", "dist"],
};
Pure unit tests, as they worked before, will continue working from this point on.
Pure tests are wonderful, but they can only get you so far. Sometimes complexity is layered, and unless you decide to inject everything, you’ll want to mock the internals of a unit under test so that there aren’t unintended side effects.
This is where this swc
and jest
testing story gets scary.
Here’s an example:
export function writeFile(content: string) {
fs.writeFileSync("./data.json", content, {encoding: "utf8"});
}
export function saveData(a: string, b: number) {
const data = {name: a, age: b};
writeFile(JSON.stringify(data));
}
Some basic shim for writing to the filesystem, and some business logic taking input, formatting it, and saving it using the shim.
With swc
and the esm configuration, you’ll end up generating something like this:
export function writeFile(content) {
fs.writeFileSync("./data.json", content, {
encoding: "utf8"
});
}
export function saveData(a, b) {
const data = {
name: a,
age: b
};
writeFile(JSON.stringify(data));
}
This is great, except, with ESM code, you can’t rely on jest’s historical mocking abilities anymore. Eventually loaders might make this work out better.
So you’re wondering now, what’s the point of all this? ESM is cool but presents such a hurdle to testing that you might be better off without it. Still, you say, as I said, that you really want to use swc
for its quick TypeScript conversion capabilities.
What if you could stick to the modern ESM code, but have it compile down to CommonJS syntax? Here’s what you get, from that same example above:
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
function _export(target, all) {
for(var name in all)Object.defineProperty(target, name, {
enumerable: true,
get: all[name]
});
}
_export(exports, {
writeFile: ()=>writeFile,
saveData: ()=>saveData
});
function writeFile(content) {
fs.writeFileSync("./data.json", content, {
encoding: "utf8"
});
}
function saveData(a, b) {
const data = {
name: a,
age: b
};
writeFile(JSON.stringify(data));
}
This looks fine at first glance, but the secret is in that _export
function that swc
adds as a helper. In particular, it prevents the function from being patched externally, or in other words, from being mocked. swc
emulates the frozen nature of ESM code, in CommonJS. It’s an accomplishment, for sure, but it’s rough because it means you might be stuck with code that’s using swc
and stuck without a way for useful tests.
I have no idea what to think about the ESM ecosystem and no idea what to think about swc
. Although, since the esm kerfuffle as previousy mentioned, there has been a wonky userland fix for this rather than an integrated option. I don’t see enough complaints, which could mean either nobody tests anything, nobody uses this swc
or I am the odd one out.
Follow me on Mastodon @ryanmr@mastodon.cloud.
Follow me on Twitter @ryanmr.