Dependency Injection in Javascript and Testing

The conventional way of writing code in Javascript and many other languages that offer easy library patching/mocking (like Python) is to just import the module, and then invoke directly from all functions. Let's see it in practice with a trivial example of writing a file with NodeJS:

import fs from "fs";

export const write = (content) => {
  fs.writeFileSync("test.txt", content);
};


// usage
// -----
import { write } from "somewhere.mjs";
write("something something");

This is easy, quickly to code and conveniently ready to export.

If we want to test the behaviour with Jest, a simple jest.mock("fs"); sets everything up.


Now let's do the same with the simplest form of dependency injection:

export const write = (ioModule, content) => {
  ioModule.writeFileSync("test.txt", content);
};


// usage
// -----
import fs from "fs";
import { write } from "somewhere.mjs";

write(fs, "something something");

With this implementation we need to be quite explicit about the module we're using for I/O, which makes testing trivial and you no longer need Jest's mocking capabilities. But it is true that we need an extra import potentially at many places. While there are more techniques, let's refactor the code to provide an exported function injecting fs, and applying the testables named export pattern provide a way to test everything:

import fs from "fs";

let ioModule = fs;

const writeDI = (ioModule, content) => {
  ioModule.writeFileSync("test.txt", content);
};

const setIOModule = (newIOModule) => {
  ioModule = newIOModule;
};

export const write = (content) => {
  return writeDI(ioModule, content);
};

export const testables = {
  writeDI,
  setIOModule,
};


// usage (normal code)
// -------------------
import fs from "fs";
import { write } from "somewhere.mjs";

write("something else");


// usage (tests)
// -------------------
import fs from "fs";
import { testables, write } from "somewhere.mjs";

const ioModuleMock = {
  writeFileSync: (filename, content) => {
    console.log("ioModuleMock.writeFileSync():", filename, content);
  },
};

// to test the `writeDI` method:
testables.writeDI(ioModuleMock, "mocked something");

// to test the `write` method, and for tests where we don't want real I/O:
testables.setIOModule(ioModuleMock);
// until changed again, everything will use the mock from now on
write("mocked something else");

Cool, so there we have something as usable as the classic implementation, while being able to manually mock it without frameworks. And if we want semi-complex scenarios, we can plug some in-memory implementation like memfs still without having to patch modules.


At this point, you might be wondering why all these changes when Jest does the same with a single line. The answer is speed.

Nothing is free. Jest is quite complete, covers many complex scenarios and is highly configurable. But as all supercharged frameworks it is opinionated, and the complexity needs to be "paid off" somewhere, so you get all this nice features at the expense of using Jest the way Jest wants to be used. Meaning:

  • Module mocking features needs some bootstrapping
  • Jest tries to be smart regarding test discovery

Previous points are compensated by letting Jest be runner/handler for all your tests, because it both has a cache to do test avoidance, and you pay the mock bootstrapping cost just once.

But what happens if you want to have isolated and hermetic tests, with potentially individual, per test runs? What if you want to use an external system to decide which tests to run, instead of Jest deciding for you? Then, the framework gets in the way, because all the extra features become a burden, and you either can't drop them or even after dropping many still weights a lot.

For normal projects a 1 or 2 seconds bootstrap might not sound too much, but at scale and with a big project, you simply can't have thousands of test files each requiring 2s to boot up (plus whatever they take to run).

So reading and exploring ways and alternatives, one question I wanted to reflect upon is: "can it be done without frameworks?".

After all, building your classes and modules with dependency injection in mind, and using existing NodeJS asserts or the new v18.13.0 native mocks, you can go a long way in many cases. And then, for those complex tests where the framework is a clear advantage (or directly a requirement), then do use Jest and similar solutions. It's simply about not making the framework the baseline.

A potential issue you might get into is building your own tiny testing framework with assert helpers and whatnot. I've seen it happen with Python and unittest, instead of using Pytest. I don't think this is bad if you keep it small and simple. And I bet your it will still be faster compared with a big, opinionated framework.

Sidenote: I've focused mostly on Javascript here, but in the past I saw the same scenario with Ruby on Rails: rspec tests using Rails for a non-trivial project would take a whooping amount of ~30 seconds of initial bootstrapping even to run a trivial unit test (RoR preloads all objects, at least did back around 2015). So an engineer thought about using minitest and migrate all tests, because it was orders of magnitude faster. It didn't worked because of other unrelated reasons, but the intention was good.

Tags: Development Javascript Patterns & Practices Testing

Dependency Injection in Javascript and Testing article, written by Kartones. Published on