Sam Thorogood

Builtin Node.js Testing

As of Node.js 18, which became stable in April 2022, you can test your backend code without installing any dependencies. This works because of the new node:test package, along with the existing node:assert package—which is from the very first releases of Node.

Here's the tl;dr. First, write a script like this, naming it e.g., "test.js":

import test from 'node:test';
import assert from 'node:assert';

test('check something' () => {
  // check that 1+1 equals 2
  assert.equal(1 + 1, 2);
});

And then run "node test.js". It'll spit out some information on your tests: hooray! 🎊

If your tests have failed, Node will let you know in the output—and the status code will be non-zero, which'll count as a failure for other tools like automated test runners.

The Test Flag

While it's cute to run a single file with Node and have it spit out its results, most of the time, you'll want to use the "node --test" flag. When used without any further arguments, this will automatically find JS files inside folders named "test", or files named "test-foo.js" and "foo-test.js", and run them. For many projects, this will be enough and just work. 👍

However, you can also tell Node just to look in certain folders or run files explicitly as tests:

Some caveats:

Automation

You've found a way to run your tests, but it could be easier. So add this to your "package.json" file:

{
  "name": "your-package-name",
  "scripts": {
    "test": "node --test src/test"
  },
  "license": "Apache-2.0",
  "type": "module"
}

Now you can just run npm test or yarn test. GitHub even has a great article on how to use this command to add a CI action to your project!

More notes on writing tests

You now know enough to be dangerous (well, safe, because you're writing tests. Tests are cool. Be cool! 😎) Read on for some more thoughts.

Asynchronous and grouped tests

You can make any test async, and Node will happily wait for its result. For example:

test('check async code', async () => {
  const result = await someLongRunningTask();
  assert.strictDeepEqual(result, { status: 'ok' }, 'Status is OK');
});

You can also group tests by calling calling t.test within another test. The t here is the context object passed to a test, which you might not otherwise need, so be sure to include it in this case:

test('groups other tests', (t) => {
  t.test('subtest #1', () => {
    assert.equal(100, 25 + 75);
  });
  t.test('subtest #2', () => {
    assert.equal(4, 2 + 2);
  });
});

(This is different from my preferred test runner Ava, where all the assert methods are on the context.)

Note that if your grouped test is async, then the parent test should await its result—you may need to make it async all the way down:

test('groups other tests', async (t) => {
  await t.test('subtest #1', () => {
    const r = await longTask();
    assert.equal(r, 'longtask is long');
  });
});

Assertions

In tests, you can literally just throw to cause a failure. You don't strictly need the assert library.

However, Node.js has a great built-in assertion library, which I've been using above. It typically works by you passing the actual and then expected values to compare for equality. (I remember the order of these as A comes before E in the alphabet.)

So I've used assert.equal and friends in the above examples, but there's actually a few more helpers I'd like to call out:

// throws is for for non-async methods
test('something', () => {
  assert.throws(() => {
    JSON.parse("this is not JSON and should fail");
  });
});

// rejects is for async methods
test('something', async () => {
  assert.rejects(async () => {
    JSON.parse("this is not JSON and should fail");
  });
});
test('something', () => {
  const result = await something();
  assert.deepStrictEqual(result, {
    value: {
      x: 1,
      y: 'foo',
    },
  });
});

This needs a bit of explanation. When writing a test, you might want to make sure that a particular callback is invoked at all, or a number of times. You can wrap a callback with CallTracker.calls, providing a number of times it should be called—it must be greater than zero. You later then call CallTracker.verify, which fails if the condition was not met.

Here's the example:

test('calls', (t) => {
  const actualCallback = () => {
    // could do something
  };

  const tr = new assert.CallTracker();
  const trackedCallback = tr.calls(actualCallback, 1);  // should be called once

  doOperationWithCallback(trackedCallback);

  tr.verify();
});

This doesn't support 0 times. If you want that, just write a callback that itself throws an Error.

An aside on CommonJS

If you're not using type: "module", then you'll have to require() the relevant modules instead:

const test = require('node:test');
const assert = require('node:test');

test('check something' () => {
  // check that 1+1 equals 2
  assert.equal(1 + 1, 2);
});

Nothing else needs to change. But it's 2022. Have you considered swapping to ESM?

Build Systems

Does your code need to be built before test? No worries, just add a build step before the test runner:

$ esbuild --bundle --format=esm src/test/*.js --outdir test-dist/
$ node --test test-dist/*.js

Or in your "package.json" file:

{
  "scripts": {
    "test": "esbuild --bundle --format=esm src/test/*.js --outdir test-dist/ && node --test test-dist/*.js"
  }
}

We're using the --test flag because Node by default cannot run multiple files, so even though you know they'll all be matched by the "test-dist/*.js" glob, Node without that flag will just run the 1st file. So be sure to remember it.

Done

That's all! I hope you find this guide handy. 👋