Apr 16, 2024

Replacing Jest with Node test runner

#programming #javascript #nodejs #jest

I tried out the new built-in Node test runner recently and ran into a few hurdles worth documenting. The existing tests were written in Jest. In one package Jest tests that took ~2-3 seconds to run now run in ~500ms.

Typescript

Through some Jest config, we were able to write source and test code in TypeScript and Jest could directly execute the tests and import the Typescript source files. I believe this is because Jest uses Babel under the hood to compile to JS before running. This is slow, but also obfuscates what's actually going on1. Converting to the Node test runner did not work out of box and it took me a while to figure out how to make this happen:

pnpm add -D tsx
node --loader tsx --test tests/*.test.ts

This works with Node v20.11.1 and tsx@4.7.2. Versions are more important in nascent technology so I document all the patch version. The --loader CLI flag does not work in Node 18, for example, but --import does.

I will say that this feels SO MUCH BETTER than Jest's solution of compiling to JS first. I just don't trust that compilation and also don't trust that the tests will catch actual user issues with that setup.

Mocking ES Modules

This is potentially a show stopper for people, but I took the tradeoff. An ES modules exports are frozen, so you can't mock them. E.g.:

// hello.mjs
export function hello() {}
// main.mjs
import { hello } from "./hello.mjs";
hello();
// main.test.mjs
import { test, mock } from "node:test";
import * as helloModule from "./hello";

// THIS DOES NOT WORK
mock.method(helloModule, "hello");

You cannot mock the hello function from the hello.mjs module. The only workaround is to export an object instead and mock that.

// hello.mjs
- export function hello() {}
+ function hello() {}
+ export default {
+    hello,
+ }

--import flag

The --import tsx flag doesn't work in Node 18. I think it works as --loader tsx though. I just bumped to Node 20.

mock.timer for Dates on Node 18

After updating to Node 20, I tried to see if I could get Node 18 working also, but one of our tests was mocking the Date object with a special API that doesn't exist in Node 18.

import { mock } from "node:test";
mock.timers.enable({
  apis: ["Date"],
  now: new Date("2021-01-01T00:00:00.000Z"),
});

I did not see any obvious way to polyfill this, but there might be one.

test.each

Jest has an API for running multiple variations of a test like this:

const tests = [
  { input: "", output: "", whatever: "" },
  { input: "", output: "", whatever: "" },
  { input: "", output: "", whatever: "" },
];

test.each(tests)("$input: $whatever", (testCase) => {
  expect(input).toBe(testCase.output);
});

This always felt like a weird API since JS can do forEach and for loops, but I imagine it existed for backward compatibility reasons. In any case, converting this to for...of loops was easy.

Floating promises issue

I had to disable a lint rules about floating promises because of what seems like a bug in Node test runner.

{
  files: ["src/**/*.test.ts"],
  rules: {
    // https://github.com/nodejs/node/issues/51292
    "@typescript-eslint/no-floating-promises": "off"
  },
},

** glob doesn't work in Node 20

This finds 0 tests and passes invisibly:

node --import tsx --test tests/**/*.test.ts

Removing the double star works, but I'm not sure if it would find test files in subdirs.

- node --import tsx --test tests/**/*.test.ts
+ node --import tsx --test tests/*.test.ts

** should work in Node 21 though.

As a side note, I appreciate Jest's feature of exiting with non-zero exit code when no tests are found. --passWithNoTests is a nice signal that you should double-check whether any tests are even running.

Missing matchers

I like the limitation of assert.equal, et al. At the end of the day, matchers are just fancy wrappers around truthy and falsey conditions, so I like that node:assert isn't trying to implement all the matchers. But it was a bit laborious to update the expect matchers that we had from Jest.


Footnotes

  1. The entire JS toolchain obfuscates how imports work though, so this is not really a Jest-specific problem.

If you like this post, please share it on Twitter and/or subscribe to my RSS feed. Or don't, that's also ok.