Mocking ES6 and CommonJS modules with Babel
Jun 29, 2019 · 7-minute read
A common need when writing unit tests is to limit the scope of the code being tested and force certain code paths to be executed by mocking imported functions / classes etc. and controlling their outputs.
In an earlier post I wrote about how existing tools for mocking JavaScript modules work when using the Browserify bundler, and problems with it. In this blog post, I present the approach that we are currently using to mock modules in JavaScript code at Hypothesis.
Technology background
Hypothesis is an open-source system for annotating web pages and PDFs. It consists of several sub-projects: A browser-based annotation tool, a Chrome/Firefox browser extension and a backend server which also features an activity dashboard frontend.
The backend code is written in Python and the frontend code in JavaScript. Some of the JS code is new, and uses ES modules, other code is older and still uses CommonJS. All of our projects have solid unit test suites and tests are run in the browser using Karma + mocha. The main client application depends on some complex browser APIs (to do with selection + ranges) which have historically differed between browsers, so being able to test in an actual browser is important. At the same time, running tests under Node would likely be faster as there is less startup overhead. Therefore it is valuable to have flexibility about what environment our tests run in.
We currently use Browserify to bundle modules together for both unit tests and production builds. Browserify is simple and extremely flexible, but the consumer has to piece and configure all of the plugins needed to process source code and prepare it for production. In future it might be attractive for us to adopt a more “batteries-included” solution.
Approaches to mocking
There are several places in a typical JS project where imported functions/classes etc. can be mocked:
Module bundler plugins
Since the module bundler (Browserify in this case) is responsible for applying code transformations to modules and resolving imports to implementations, it is a natural place to insert a seam that allows the real definition of a module to be replaced in a test.
The downside of a plugin is that it is tied to the specific bundler being used (Webpack, Browserify etc.) and can’t be used if you want to just run the code under test in Node directly.
Another downside is that each plugin typically ends up parsing the same code separately, duplicating work and contributing to longer build + test times.
Directly in the code using dependency injection techniques
The most portable method of mocking is to introduce test seams or use dependency injection in the source code to support replacing imported functions or objects during a test.
The downside is that this added layer of indirection can make the overall code harder to follow, and also inhibit the ability of tools (such as intellisense, minifiers, linters etc.) to navigate and analyze the code.
Monkey-patching imports
A simple technique that is often available in dynamic languages is to import the module you want to mock as an object, and monkey-patch the exports:
import * as apiClient from "./api-client";
sinon.stub(apiClient, "callApi");
apiClient.callApi.returns(mockResponse);
// Test code that uses `callApi`
apiClient.callApi.restore();
The upside is that no special tools are required.
The downside of this approach is that, technically, in ES modules, imports are read-only bindings rather than an ordinary object with mutable properties. What this means is that this approach will not work with a “real” ES modules implementation, as opposed to transpiled code.
Source code transformations
A third option is to use an automated transformation of the code to modify it in a way that makes it possible to replace imported entities in a module being tested.
This approach is a natural fit for us because we are already applying a variety of source transforms for various purposes using Babel. Unlike a module bundler plugin, these transformations work with an already-parsed representation of the code and Babel can optimize the process of applying multiple transformations. As a result, a carefully written plugin can get a full understanding of the code with minimal impact on test execution time.
Introducing the mockable-imports Babel plugin
Having settled on a preference to use a source code transformation, I evaluated several existing Babel plugins, taking into account the following goals:
- Support both CommonJS and ES imports, so we could use it across all of our projects immediately
- Minimize the impact on test execution times (including the build/startup phase).
- Transform code in a simple and predictable way, to minimize the chances of introducing confusing bugs, interfering with other code transformations or complicating the process of debugging a failing test.
- Have a simple, minimal API, so newcomers can learn it quickly
- Catch incorrect usage early, such as attempting to mock something which a module does not actually import
Candidates looked at were babel-plugin-rewire and babel-plugin-rewire-exports. babel-plugin-rewire unfortunately generates a lot of code, which can cause problems. babel-plugin-rewire-exports is quite promising. A caveat is that because it changes exports rather than imports, it affects all consumers of that export, rather than just the specific module you are testing. Each module that is mocked also needs to be un-mocked separately after a test.
To avoid these issues, I created a new Babel plugin, babel-plugin-mockable-imports which we have been using across all of our JS projects for several months.
It works as follows:
- The plugin adds a new object,
$imports
, to each module it processes - Metadata for each imported symbol is registered with the
$imports
object. This includes the source module, original name, local name and value. The value then becomes available as a field of that object (eg.$imports.someImportedSymbol
) - Each reference to an imported symbol in the module (say,
randomDigits
) is replaced with a reference to the field on the$imports
object:$imports.randomDigits
- Tests can import the
$imports
object from the module, and temporarily replace any of the imported symbols for the duration of a test using$imports.$mock
. After the test runs, a single call to$imports.$restore()
will reset all of the references.
For example, this code:
import { randomDigits } from "./random";
export function generatePassword() {
return `not-very-good-${randomDigits()}`;
}
Is transformed into:
import { ImportMap } from "babel-plugin-mockable-imports/helpers";
import { randomDigits } from "./random";
const $imports = new ImportMap();
$imports.$register("randomDigits", "./random", "randomDigits", randomDigits);
export function generatePassword() {
return `not-very-good-${$imports.randomDigits()}`;
}
The test can then access the $imports
object and use it to temporarily replace
the imported randomDigits
function:
import { $imports, generatePassword } from "./password-gen";
describe("generatePassword", () => {
afterEach(() => {
$imports.$restore();
});
it("should return a randomly generated string", () => {
$imports.$mock({
"./random": {
randomDigits: () => "123"
}
});
assert.equal(generatePassword(), "not-very-good-123");
});
});
Aside from functions, this also works with objects or other values.
Our UI is written in React/Preact, and because components are just functions, this can be used to mock UI components as well. This can be useful in cases where we want the benefits of shallow rendering but without the limitations (inability to get a reference to DOM nodes, run effects and lifecycle hooks etc.).
Finally, because the mocking system has access to runtime metadata about imports, it can provide helpful warnings about incorrect usage such as attempting to mock a symbol which a module does not actually import, or using a mock that does not match the original symbol in some way.
Limitations
The above approach has some limitations. The main one is that it is only possible to mock imported symbols after the module has been evaluated. What this means is that you can mock references to imports inside a function, but not in statements that execute at the top-level of the module when it is imported.
It would be possible to resolve this with a more sophisticated transformation and some additional APIs, but this limitation has not proven a significant hurdle for us so far. The workaround is to move the code at the top-level of the module into an exported function which is called when the module is initialized, but can also be called directly in tests.
Conclusion
In summary, by using a Babel plugin we were able to simplify mocking imports across all of our JS code which uses a mix of ES and CommonJS imports, improve test execution times, and decouple tests from the execution environment.
If you have a project with similar needs, perhaps you might find this useful as well. See the plugin’s README for installation and usage instructions. The GitHub repository also has example projects in JavaScript and TypeScript. If you have any feedback, feel free to contact me or file an issue.