Mocking ESM Without Loaders

nalanj

Alan Johnson

Posted on September 7, 2023

Mocking ESM Without Loaders

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]);
}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
nalanj
Alan Johnson

Posted on September 7, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related