HowNot2 mock in Deno

A funky crocodile made out of cups and other materials. It is sitting on top of some other cartoon character, wearing a read hat
A mock “denosaur”
Photo by Naoki Suzuki on Unsplash

Mocking functions in deno is very limited, compared to NodeJS. Maybe, it’s so bad that it’s actually great. As your resident ruby-hater-in-chief, I’m here to tell you that code “magically working” comes at great cost. Today we’re going to learn about why modern javascript broke mocking, and why Deno refused to create magic to pretend otherwise.

The code for this blog can be found at https://github.com/intentionally-left-nil/deno_mocking_investigation. Feel free to clone the repository and follow along.

Do not pass go, do not magic mock an import

Here’s a toy example:

//random.ts
export const getRandomNumber = () { return Math.random() }

// hello.ts
import { getRandomNumber } from ./random.ts

export const hello = () => `Hello, ${getRandomNumber()}`

In NodeJS, you could write a unit test like this with jest:

import { hello } from './hello';
import * as randomModule from './random';

describe('hello', () => {
  it('should work with spied function', () => {
    // Spy on the original module that hello.ts imports from
    const spy = jest.spyOn(randomModule, 'getRandomNumber').mockReturnValue(0.5);
    
    const result = hello();
    
    expect(result).toBe('Hello, 0.5');
    spy.mockRestore();
  });
});

This works, well at least some of the time. The rest of the time you spend in docs and examples trying to remember the exact syntax for spyOn and how to get it to work with different imports.

More importantly, have you ever stopped to think about how and why this works in the first place? This is only really possible in CommonJS, not ES2015. In CommonJS, all of the imports would be const {getRandomNumber} = require('./random') as opposed to the import statement. This is a big deal for a few reasons. Import statements are resolved at parse time, not runtime. Then, imported references are read-only per the spec. These spec restrictions on ES2015 do enable a lot of cool features, like removing needs for bundlers (since the whole tree of files can be deduced ahead of time), tree shaking etc. However, the static nature of this completely breaks import mocking

How does this work with Jest then? Jazz hands ~~magic~~. I’m actually not entirely sure. I think that older versions of Jest required everything to be transpiled down into CommonJS, so the usual hoisting patterns would work. Newer versions of jest do something else that is still unstable after all these years.

But it works (*) so developers use it, and use it without thinking too much. Except we do think about it, every time we fight a devops/config file script or it breaks in some other way.

Deno, being typescript native (and thus ES2015-import-style) just… doesn’t pretend this works. Instead, you’ll get an MockError: cannot spy on non configurable instance method error.

Okay, but then what?

Since we don’t have magic, that means we’ll need to modify the implementation code in some way. Either we need to pass in extra objects that the tests control, or we need some global, writeable object that we can manipulate.

Direct Dependency Injection

The simplest way is direct dependency injection. Simply, this means changing the function signature of hello to be const hello = (rng) => "Hello, " + rng() Yes this is painful, but the pain also comes with visibility. It’s now easy to see what kind of side effects that a function needs. This approach will work for simple things, but definitely won’t scale if you have a lot of nested functions, or a lot of side effects to manage.

The test code becomes pretty straightforward in this example, at least. We don’t even need Deno’s mocking/stub functionality:

Deno.test("hello, mocking greeting via dependency injection", () => {
  const fakeRng = (min: number, max: number) => 42;
  assertEquals(hello("Deno", fakeRng), "Hello, Deno! 42");
})

Global This

There are a few variants on this pattern, but the basic idea is that we need a function pointer somewhere (sorry, C-brain mentality dies hard), and then we also need to change all of the code to reference the function pointer, instead of the direct call/jump itself.

function getCoolness(name: string): number {
  const rng = globalThis.getRandomNumber; // Do this instead of directly calling getRandomNumber()
  
  const scaleFactor = rng(0, name.length);
  const base = rng(0, 100);
  return base * scaleFactor;
}

There are several variants of this flavor. The one in the github example uses globalThis, but you could also put your function pointers within the file itself, like this:

export const getRandomNumber = () => Math.random();
// The ordering of definitions matters and that makes things funky too
export const impls = { getRandomNumber };
export const hello = () => "hello, " + impls.getRandomNumber() // Notice the use of impls, which is basically the same concept as globalThis

Another global… store this time

The solution to side effects is just more side effects 😀 If you start with dependency injection as a general concept (or e.g. props for React), then over time you’ll be annoyed with how much baggage (eerm “context”) you have to keep passing around. Then you’ll invent a store pattern with some kind of singleton/discovery pattern, and voila: Each function can access the store to get the global information it needs. Since the store is another name for “an object that has mutable data”, this becomes a viable mechanism for injecting mock points.

This approach still means you need to change all your source code to route through the store, in order to get the function pointer:

function getCoolness(name: string): number {
  const store = Store.getStore<{ getRandomNumber: typeof getRandomNumber }>();
  const rng = store.get().getRandomNumber;
  
  const scaleFactor = rng(0, name.length);
  const base = rng(0, 100);
  return base * scaleFactor;
}

and so conceptually this is the same idea, just a lot cleaner to implement in practice

And now for something new

Deno does have a (as far as I can tell very unique) system that allows you to effectively run a pre-processor pass to change out the values of imports. So, you can turn import foo into import bar just by passing in --import-map path-to-json where the json contains: {"foo": "bar"}

That’s pretty cool. Deno has other reasons for wanting to do this, but what it does mean is that we have a mechanism to wholesale swap out modules.

The wholesale part is the problem, though. Remember, all this happens at the static lexer part right? So now all your tests are affected by this mapping, and you have to have certain tricks to manage it.

The tooling here also is sorely lacking. It’s clear from the current usage that deno doesn’t really optimize for this use case. deno test will completely ignore the import map, and there’s no way to configure that in deno.json (unless you remind everyone to use deno task test. You can’t merge import maps and only swap out the one or two modules you want to replace. This setup also doesn’t work with monorepos, because the import-map swap is global.

Summary

In-order, you should try:

  • Direct dependency injection or other code refactoring to isolate out the side effects from your pure functions
  • Switch to some kind of store pattern when that gets too annoying
  • Make github issues against deno to make import map a more viable alternative 😀