In my last post I talked about ES module incompatibility with jest
, let's dive
deeper into the land of modules!
First some basic terminology: At the risk of oversimplification, a module is a JavaScript file, with a way to encapsulate code and expose it to other files. A major benefit is scoping, to avoid the need for global variables that can lead to confusing and buggy code.
For years there have been two syntaxes for writing JavaScript modules. Node has
always used modules CommonJS modules, which use require()
and
module.exports
to refer to other modules. ES module syntax uses the
import
and export
keywords to share code with other modules. It was widely
adopted thanks to tools like Babel, which transforms ES module syntax into more
commonly understood JS (both in the browser and Node).
If syntax were the only difference, the two might have lived a long and happy life together but, well... it's complicated because ES modules operate fundamentally differently than CommonJS. Let me tell you about it.
The first big difference: ES modules are loaded in an asynchronous three-step process: they're parsed, instantiated, and executed separately. A static dependency tree/graph is built before executing a single line of code.This way it can know how the code is structured and how to allocate memory.
CommonJS is evaluated and executed synchronously all in one go. It doesn't have a way to know the dependency graph before it starts running code, it evaluates and executes code as it's building the graph.
This has a few implications, which leads us to...
When something is exported from a CommonJS module, the import and export are copies of each other, stored in different places in memory. In ES modules the import and export share the same memory location.
Among other things, this means each system handles cyclic dependencies differently - in short: ES modules can handle them, CommonJS can't.
Picture this:
In ES modules when A
imports second
from B
, if B
also imports first
from A
it will allocate a location in memory for first
, and when parsing
reaches the first
export in A
it will put it in that memory location.
In CommonJS, as execution makes its way from A
to B
, since the code hasn't
reached first
when it's importing, the import will be undefined
. Then when
the code reaches the export (if it doesn't error first), the export goes into a
different place in memory and doesn't update the undefined
import. Womp womp.
In CommonJS you can call require()
wherever you want. It's just a function:
This will not fly in ES modules, import
is a keyword and must be at the top
level of the module.
Actually that's not exactly true anymore. Dynamic imports enable this, but they're asynchronous so they introduce promises into the flow:
Not the end of the world, but it can mean a lot of refactoring if you didn't write the code with promises in mind.
Back to the task at hand. This all came up because I decided to enable ES
modules in Node. There has been a big push lately to make npm
packages (most
of which were built in CommonJS) compatible with ES modules. Maintainers are
making their modules compatibile, and skypack is at
the forefront of tools designed to ease compatibility woes.
One tool that relies directly on the CommonJS structure is jest
, one of the
most widely used JS testing frameworks. During this side project, I discovered
that jest
's mocking capability isn't yet working with ES modules, and it will
likely never work in its current form since it relies on overriding CommonJS's
require()
with a version that replaces exports with mocks. If you've used
jest.mock
with import/export
before, you've
likely had to include babel-jest
,
which transforms the code behind the scenes.
jest
(very funny)"Mock" is a loaded term, but in jest
a mock can act as either:
spy
, which attaches to a function and allows you to inspect it after the
code under test has run, doing something like
expect(function).toBeCalledWith('foo')
.stub
, which also attaches to a function and allows for inspection, but
also overrides its behavior so you can, for example, return a specific
value: mockFn.mockReturnValue('foo')
Module mocking is when you add mocks to every export in a module:
jest.mock('./happy')
. This is handy when you don't want to inject
dependencies, or when you want to mock something nested deeply where you would
need to inject through multiple layers.
I was writing this article I realized I don't know exactly how jest
module
mocking works - I figured it was somehow hooking into the require()
cache.
After some debugging and
reading,
I discovered that's basically what's going on. It's overriding the cache with
its own module registry, but also hooking into the global require()
function
itself and overriding it with
requireModuleOrMock
.
If there's a jest.mock
in the file, it's going to replace all the module's
exports with mocked functions.
Alas, jest
can't hook into import
in the same way, and so here we are
injecting all our dependencies instead of mocking.
jest
has this GitHub issue
where you can track progress on ES module support, and
this PR for tracking jest.mock
progress. I love this about open source, you can see exactly how far along
something is, and if you're able, help move it along yourself!
In short, it looks like something like jest.mockImport
will be added to the
API, and may require (heh) dynamic import syntax.
Learnings:
- CommonJS and ES modules work very differently, more than just syntax
jest
will probably add to the API rather than make the currentjest.mock
work for both CommonJS and ES modules
For more in-depth discussion of ES modules, Lin Clark has a great cartoon deep dive on how they work, and here's a closer look at the incompatibility between ES modules and CommonJS.
I hope you enjoyed this detour. I found the differences between CommonJS and ES modules confusing for a long time, so I hope this explanation has been helpful, or at least not made things more confusing. Next time we'll get back to work on the release toolkit, see you then!