I've been using the new node:test
based testing framework more and more, and recently hit a tricky issue. I often mock dependencies for a file to keep the test focused on the unit I'm testing and not the entire app. But every bit of research I did about doing that with node:test
implied that it's not possible to mock ESM imports right now, and wouldn't be in the foreseeable future without messing with loaders.
Issues and discussions pointed at using tools like testdouble.js and esmock. I wanted to see if I could avoid them before adding a new layer of things I needed to configure.
I explored a few dependency injection options that wouldn't require a loader, and most of those libraries also kind of rubbed me the wrong way. The tended to include a lot of boilerplate code and require specific types of expression, like service classes. Again, I wanted to see if I could keep things simpler.
After a little exploring in RunJS I came up with a pattern that I was happy with.
First, an Example
Here's a quick example of what I'm doing.
users.js
:
export async function find(id) {
return db.query("SELECT * FROM users WHERE id = $1", [id]);
}
get-user.js
:
import { find } from "./users.js";
export async function handleRequest(request, response) {
const user = await find(request.params.id);
response.status(200).send({ user });
}
get-user.test.js
:
import { test } from "node:test";
import { assert } from "node:assert/strict";
import { handleRequest } from "./get-user.js";
test("success", async () => {
const resp = await handleRequest(fakeRequest("/users/12"));
assert.equal(resp.statusCode, 200);
});
If I were to run the test above, a query would hit the database when the find
function is called. I don't want that to happen because it requires us to set the database up correctly, and in a large test suite that can take a ton of time.
As an aside, my general rule of thumb is that I test the database when I'm testing my data layer, but once I move out of my data layer I tend to mock the database rather than shoving all sorts of data into the database. That tends to be easier and keeps my tests running faster.
Tag Mockable Dependencies
First off, lets update the unit we're testing to tag what data we want to mock. We don't know how that will eventually work, but we can imagine a higher-order function that handles our ability to mock:
get-user.js
:
import { find } from "./users.js";
import { mockable } from "./mockable.js";
export const findUser = mockable(find);
export async function handleRequest(request, response) {
const user = await findUser(request.params.id);
response.status(200).send({ user });
}
All we changed here is added an import of a non-existent function called mockable
that we wrapped our find
function in. We exported it because we know we'll need to access it in our test.
No Production Overrides, Ever
The code above would fail since there's no mockable.js
yet, so let's write it up. First off, we know in production we want it to return the original function. Piece of cake!
mockable.js
:
export function mockable(fn) {
if (process.env.NODE_ENV === "production") {
return fn;
}
}
This won't work anywhere but production, yet, but we know in production it adds limited overhead because really it just returns the original function.
Allow Override
Now, let's build out the override case for mockable. We want mockable to return a callable function, but that function should be able to be overriden. Here's how that looks:
mockable.js
:
export function mockable(fn) {
if (process.env.NODE_ENV === "production") {
return fn;
}
// impl holds the overridden implementation of the function, if it is
// overridden
const impl = undefined;
// call impl or the original function, based on if impl is set
const wrap = function (...args) {
if (impl) {
return impl(...args);
} else {
return fn(...args);
}
};
// attach an override function to wrap
wrap.override = function (fn) {
impl = fn;
return fn;
};
// clear the override
wrap.clear = function () {
impl = undefined;
};
return wrap;
}
In the code above we're leaning on closures and the idea that JavaScript functions are objects, so you can set a function as a property of a function! It is very weird but also very useful here.
So our mockable
function always returns a function. In production it only returns the original function. Not in production it returns a function that also includes override
and clear
properties that are callable.
Using Mockable in a Test
Now it's time to update the test to lean on the new mockable
implementation:
get-user.test.js
:
import { test } from "node:test";
import { assert } from "node:assert/strict";
import { handleRequest, findUser } from "./get-user.js";
test("success", async (t) => {
// override findUser for this test
findUser.override((id) => {
return {
id,
name: "Test Tester",
email: "test@test.com",
createdAt: new Date(),
updatedAt: new Date(),
};
});
// clear the override after the test runs
t.after(() => findUser.clear());
const resp = await handleRequest(fakeRequest("/users/12"));
assert.equal(resp.statusCode, 200);
});
Done!
That's it! We have a fairly compact way to override functions in a test using higher-order functions, and not requiring a loader of any sort.
This doesn't cover things like overriding a method on a class, but that's mostly a similar idea with a slightly different implementation.
Top comments (0)