Using Node.js's test runner

Node.js has a flexible and robust built-in test runner. This guide will show you how to set up and use it.

example/
  ├ …
  ├ src/
    ├ app/…
    └ sw/…
  └ test/
    ├ globals/
      ├ …
      ├ IndexedDb.js
      └ ServiceWorkerGlobalScope.js
    ├ setup.mjs
    ├ setup.units.mjs
    └ setup.ui.mjs

Note: globs require node v21+, and the globs must themselves be wrapped in quotes (without, you'll get different behaviour than expected, wherein it may first appear to be working but isn't).

There are some things you always want, so put them in a base setup file like the following. This file will get imported by other, more bespoke setups.

General setup

import { function register<Data = any>(specifier: string | URL, parentURL?: string | URL, options?: Module.RegisterOptions<Data>): void (+1 overload)register } from 'node:module';

register<any>(specifier: string | URL, parentURL?: string | URL, options?: Module.RegisterOptions<any> | undefined): void (+1 overload)register('some-typescript-loader');
// TypeScript is supported hereafter
// BUT other test/setup.*.mjs files still must be plain JavaScript!

Then for each setup, create a dedicated setup file (ensuring the base setup.mjs file is imported within each). There are a number of reasons to isolate the setups, but the most obvious reason is YAGNI + performance: much of what you may be setting up are environment-specific mocks/stubs, which can be quite expensive and will slow down test runs. You want to avoid those costs (literal money you pay to CI, time waiting for tests to finish, etc) when you don't need them.

Each example below was taken from real-world projects; they may not be appropriate/applicable to yours, but each demonstrate general concepts that are broadly applicable.

Dynamically generating test cases

Some times, you may want to dynamically generate test-cases. For instance, you want to test the same thing across a bunch of files. This is possible, albeit slightly arcane. You must use test (you cannot use describe) + testContext.test:

Simple example

import 
const assert: Omit<typeof assert, "equal" | "notEqual" | "deepEqual" | "notDeepEqual" | "ok" | "strictEqual" | "deepStrictEqual" | "ifError" | "strict" | "AssertionError"> & {
    ...;
}
assert
from 'node:assert/strict';
import { function test(name?: string, fn?: test.TestFn): Promise<void> (+3 overloads)test } from 'node:test'; import { import detectOsInUserAgentdetectOsInUserAgent } from ''; const
const userAgents: {
    ua: string;
    os: string;
}[]
userAgents
= [
{ ua: stringua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.3', os: stringos: 'WIN', }, // … ]; function test(name?: string, options?: test.TestOptions, fn?: test.TestFn): Promise<void> (+3 overloads)test('Detect OS via user-agent', { test.TestOptions.concurrency?: number | boolean | undefinedconcurrency: true }, t: test.TestContextt => { for (const { const os: stringos, const ua: stringua } of
const userAgents: {
    ua: string;
    os: string;
}[]
userAgents
) {
t: test.TestContextt.test.TestContext.test: (name?: string, fn?: test.TestFn) => Promise<void> (+3 overloads)test(const ua: stringua, () =>
const assert: Omit<typeof assert, "equal" | "notEqual" | "deepEqual" | "notDeepEqual" | "ok" | "strictEqual" | "deepStrictEqual" | "ifError" | "strict" | "AssertionError"> & {
    ...;
}
assert
.equal: <string>(actual: unknown, expected: string, message?: string | Error) => asserts actual is stringequal(import detectOsInUserAgentdetectOsInUserAgent(const ua: stringua), const os: stringos));
} });

Advanced example

import 
const assert: Omit<typeof assert, "equal" | "notEqual" | "deepEqual" | "notDeepEqual" | "ok" | "strictEqual" | "deepStrictEqual" | "ifError" | "strict" | "AssertionError"> & {
    ...;
}
assert
from 'node:assert/strict';
import { function test(name?: string, fn?: test.TestFn): Promise<void> (+3 overloads)test } from 'node:test'; import { import getWorkspacePJSONsgetWorkspacePJSONs } from './getWorkspacePJSONs.mjs'; const const requiredKeywords: string[]requiredKeywords = ['node.js', 'sliced bread']; function test(name?: string, options?: test.TestOptions, fn?: test.TestFn): Promise<void> (+3 overloads)test('Check package.jsons', { test.TestOptions.concurrency?: number | boolean | undefinedconcurrency: true }, async t: test.TestContextt => { const const pjsons: anypjsons = await import getWorkspacePJSONsgetWorkspacePJSONs(); for (const const pjson: anypjson of const pjsons: anypjsons) { // ⚠️ `t.test`, NOT `test` t: test.TestContextt.test.TestContext.test: (name?: string, fn?: test.TestFn) => Promise<void> (+3 overloads)test(`Ensure fields are properly set: ${const pjson: anypjson.name}`, () => {
const assert: Omit<typeof assert, "equal" | "notEqual" | "deepEqual" | "notDeepEqual" | "ok" | "strictEqual" | "deepStrictEqual" | "ifError" | "strict" | "AssertionError"> & {
    ...;
}
assert
.partialDeepStrictEqual: (actual: unknown, expected: unknown, message?: string | Error) => voidpartialDeepStrictEqual(const pjson: anypjson.keywords, const requiredKeywords: string[]requiredKeywords);
}); } });

Note: Prior to version 23.8.0, the setup is quite different because testContext.test was not automatically awaited.

ServiceWorker tests

ServiceWorkerGlobalScope contains very specific APIs that don't exist in other environments, and some of its APIs are seemingly similar to others (ex fetch) but have augmented behaviour. You do not want these to spill into unrelated tests.

import { function beforeEach(fn?: test.HookFn, options?: test.HookOptions): voidbeforeEach } from 'node:test';

import { import ServiceWorkerGlobalScopeServiceWorkerGlobalScope } from './globals/ServiceWorkerGlobalScope.js';

import './setup.mjs'; // 💡

function beforeEach(fn?: test.HookFn, options?: test.HookOptions): voidbeforeEach(function globalSWBeforeEach(): voidglobalSWBeforeEach);
function function globalSWBeforeEach(): voidglobalSWBeforeEach() {
  module globalThisglobalThis.var self: Window & typeof globalThisself = new import ServiceWorkerGlobalScopeServiceWorkerGlobalScope();
}
import 
const assert: Omit<typeof assert, "equal" | "notEqual" | "deepEqual" | "notDeepEqual" | "ok" | "strictEqual" | "deepStrictEqual" | "ifError" | "strict" | "AssertionError"> & {
    ...;
}
assert
from 'node:assert/strict';
import { function describe(name?: string, options?: it.TestOptions, fn?: it.SuiteFn): Promise<void> (+3 overloads)describe, const mock: it.MockTrackermock, function it(name?: string, fn?: it.TestFn): Promise<void> (+3 overloads)it } from 'node:test'; import { import onActivateonActivate } from './onActivate.js'; function describe(name?: string, fn?: it.SuiteFn): Promise<void> (+3 overloads)describe('ServiceWorker::onActivate()', () => { const const globalSelf: Window & typeof globalThisglobalSelf = module globalThisglobalThis.var self: Window & typeof globalThisself; const const claim: it.Mock<() => Promise<void>>claim = const mock: it.MockTrackermock.test.MockTracker.fn<() => Promise<void>>(original?: (() => Promise<void>) | undefined, options?: it.MockFunctionOptions): it.Mock<() => Promise<void>> (+1 overload)fn(async function function (local function) mock__claim(): Promise<void>mock__claim() {}); const const matchAll: it.Mock<() => Promise<void>>matchAll = const mock: it.MockTrackermock.test.MockTracker.fn<() => Promise<void>>(original?: (() => Promise<void>) | undefined, options?: it.MockFunctionOptions): it.Mock<() => Promise<void>> (+1 overload)fn(async function function (local function) mock__matchAll(): Promise<void>mock__matchAll() {}); class class ActivateEventActivateEvent extends
var Event: {
    new (type: string, eventInitDict?: EventInit): Event;
    prototype: Event;
    readonly NONE: 0;
    readonly CAPTURING_PHASE: 1;
    readonly AT_TARGET: 2;
    readonly BUBBLING_PHASE: 3;
}
Event
{
constructor(...args: any[]args) { super('activate', ...args: any[]args); } } before(() => { module globalThisglobalThis.var self: Window & typeof globalThisself = {
clients: {
    claim: it.Mock<() => Promise<void>>;
    matchAll: it.Mock<() => Promise<void>>;
}
clients
: { claim: it.Mock<() => Promise<void>>claim, matchAll: it.Mock<() => Promise<void>>matchAll },
}; }); after(() => { var global: typeof globalThisglobal.var self: Window & typeof globalThisself = const globalSelf: Window & typeof globalThisglobalSelf; }); function it(name?: string, fn?: it.TestFn): Promise<void> (+3 overloads)it('should claim all clients', async () => { await import onActivateonActivate(new constructor ActivateEvent(...args: any[]): ActivateEventActivateEvent());
const assert: Omit<typeof assert, "equal" | "notEqual" | "deepEqual" | "notDeepEqual" | "ok" | "strictEqual" | "deepStrictEqual" | "ifError" | "strict" | "AssertionError"> & {
    ...;
}
assert
.equal: <1>(actual: unknown, expected: 1, message?: string | Error) => asserts actual is 1equal(const claim: it.Mock<() => Promise<void>>claim.mock: it.MockFunctionContext<() => Promise<void>>mock.test.MockFunctionContext<() => Promise<void>>.callCount(): numbercallCount(), 1);
const assert: Omit<typeof assert, "equal" | "notEqual" | "deepEqual" | "notDeepEqual" | "ok" | "strictEqual" | "deepStrictEqual" | "ifError" | "strict" | "AssertionError"> & {
    ...;
}
assert
.equal: <1>(actual: unknown, expected: 1, message?: string | Error) => asserts actual is 1equal(const matchAll: it.Mock<() => Promise<void>>matchAll.mock: it.MockFunctionContext<() => Promise<void>>mock.test.MockFunctionContext<() => Promise<void>>.callCount(): numbercallCount(), 1);
}); });

Snapshot tests

These were popularised by Jest; now, many libraries implement such functionality, including Node.js as of v22.3.0. There are several use-cases such as verifying component rendering output and Infrastructure as Code config. The concept is the same regardless of use-case.

There is no specific configuration required except enabling the feature via --experimental-test-snapshots. But to demonstrate the optional configuration, you would probably add something like the following to one of your existing test config files.

By default, node generates a filename that is incompatible with syntax highlighting detection: .js.snapshot. The generated file is actually a CJS file, so a more appropriate file name would end with .snapshot.cjs (or more succinctly .snap.cjs as below); this will also handle better in ESM projects.

import { function (method) basename(path: string, suffix?: string): stringbasename, function (method) dirname(path: string): stringdirname, function (method) extname(path: string): stringextname, function (method) join(...paths: string[]): stringjoin } from 'node:path';
import { snapshot } from 'node:test';

snapshot.function test.snapshot.setResolveSnapshotPath(fn: (path: string | undefined) => string): voidsetResolveSnapshotPath(function generateSnapshotPath(testFilePath: string): stringgenerateSnapshotPath);
/**
 * @param {string} testFilePath '/tmp/foo.test.js'
 * @returns {string} '/tmp/foo.test.snap.cjs'
 */
function function generateSnapshotPath(testFilePath: string): stringgenerateSnapshotPath(testFilePath: stringtestFilePath) {
  const const ext: stringext = function extname(path: string): stringextname(testFilePath: stringtestFilePath);
  const const filename: stringfilename = function basename(path: string, suffix?: string): stringbasename(testFilePath: stringtestFilePath, const ext: stringext);
  const const base: stringbase = function dirname(path: string): stringdirname(testFilePath: stringtestFilePath);

  return function join(...paths: string[]): stringjoin(const base: stringbase, `${const filename: stringfilename}.snap.cjs`);
}

The example below demonstrates snapshot testing with testing library for UI components; note the two different ways of accessing assert.snapshot):

import { function describe(name?: string, options?: it.TestOptions, fn?: it.SuiteFn): Promise<void> (+3 overloads)describe, function it(name?: string, fn?: it.TestFn): Promise<void> (+3 overloads)it } from 'node:test';

import { import prettyDOMprettyDOM } from '@testing-library/dom';
import { import renderrender } from '@testing-library/react'; // Any framework (ex svelte)

import { import SomeComponentSomeComponent } from './SomeComponent.jsx';

function describe(name?: string, fn?: it.SuiteFn): Promise<void> (+3 overloads)describe('<SomeComponent>', () => {
  // For people preferring "fat-arrow" syntax, the following is probably better for consistency
  function it(name?: string, fn?: it.TestFn): Promise<void> (+3 overloads)it('should render defaults when no props are provided', t: it.TestContextt => {
    const const component: anycomponent = import renderrender(<import SomeComponentSomeComponent />).container.firstChild;

    t: it.TestContextt.test.TestContext.assert: it.TestContextAssertassert.test.TestContextAssert.snapshot(value: any, options?: it.AssertSnapshotOptions): voidsnapshot(import prettyDOMprettyDOM(const component: anycomponent));
  });

  function it(name?: string, fn?: it.TestFn): Promise<void> (+3 overloads)it('should consume `foo` when provided', function () {
    const const component: anycomponent = import renderrender(<import SomeComponentSomeComponent foo: stringfoo="bar" />).container.firstChild;

    this.assert.snapshot(import prettyDOMprettyDOM(const component: anycomponent));
    // `this` works only when `function` is used (not "fat arrow").
  });
});

⚠️ assert.snapshot comes from the test's context (t or this), not node:assert. This is necessary because the test context has access to scope that is impossible for node:assert (you would have to manually provide it every time assert.snapshot is used, like snapshot(this, value), which would be rather tedious).

Unit tests

Unit tests are the simplest tests and generally require relatively nothing special. The vast majority of your tests will likely be unit tests, so it is important to keep this setup minimal because a small decrease to setup performance will magnify and cascade.

import { function register<Data = any>(specifier: string | URL, parentURL?: string | URL, options?: Module.RegisterOptions<Data>): void (+1 overload)register } from 'node:module';

import './setup.mjs'; // 💡

register<any>(specifier: string | URL, parentURL?: string | URL, options?: Module.RegisterOptions<any> | undefined): void (+1 overload)register('some-plaintext-loader');
// plain-text files like graphql can now be imported:
// import GET_ME from 'get-me.gql'; GET_ME = '
import 
const assert: Omit<typeof assert, "equal" | "notEqual" | "deepEqual" | "notDeepEqual" | "ok" | "strictEqual" | "deepStrictEqual" | "ifError" | "strict" | "AssertionError"> & {
    ...;
}
assert
from 'node:assert/strict';
import { function describe(name?: string, options?: it.TestOptions, fn?: it.SuiteFn): Promise<void> (+3 overloads)describe, function it(name?: string, fn?: it.TestFn): Promise<void> (+3 overloads)it } from 'node:test'; import { import CatCat } from './Cat.js'; import { import FishFish } from './Fish.js'; import { import PlasticPlastic } from './Plastic.js'; function describe(name?: string, fn?: it.SuiteFn): Promise<void> (+3 overloads)describe('Cat', () => { function it(name?: string, fn?: it.TestFn): Promise<void> (+3 overloads)it('should eat fish', () => { const const cat: anycat = new import CatCat(); const const fish: anyfish = new import FishFish();
const assert: Omit<typeof assert, "equal" | "notEqual" | "deepEqual" | "notDeepEqual" | "ok" | "strictEqual" | "deepStrictEqual" | "ifError" | "strict" | "AssertionError"> & {
    ...;
}
assert
.doesNotThrow: (block: () => unknown, message?: string | Error) => void (+1 overload)doesNotThrow(() => const cat: anycat.eat(const fish: anyfish));
}); function it(name?: string, fn?: it.TestFn): Promise<void> (+3 overloads)it('should NOT eat plastic', () => { const const cat: anycat = new import CatCat(); const const plastic: anyplastic = new import PlasticPlastic();
const assert: Omit<typeof assert, "equal" | "notEqual" | "deepEqual" | "notDeepEqual" | "ok" | "strictEqual" | "deepStrictEqual" | "ifError" | "strict" | "AssertionError"> & {
    ...;
}
assert
.throws: (block: () => unknown, message?: string | Error) => void (+1 overload)throws(() => const cat: anycat.eat(const plastic: anyplastic));
}); });

User Interface tests

UI tests generally require a DOM, and possibly other browser-specific APIs (such as IndexedDb used below). These tend to be very complicated and expensive to setup.

If you use an API like IndexedDb but it's very isolated, a global mock like below is perhaps not the way to go. Instead, perhaps move this beforeEach into the specific test where IndexedDb will be accessed. Note that if the module accessing IndexedDb (or whatever) is itself widely accessed, either mock that module (probably the better option), or do keep this here.

import { function register<Data = any>(specifier: string | URL, parentURL?: string | URL, options?: Module.RegisterOptions<Data>): void (+1 overload)register } from 'node:module';

// ⚠️ Ensure only 1 instance of JSDom is instantiated; multiples will lead to many 🤬
import import jsdomjsdom from 'global-jsdom';

import './setup.units.mjs'; // 💡

import { import IndexedDbIndexedDb } from './globals/IndexedDb.js';

register<any>(specifier: string | URL, parentURL?: string | URL, options?: Module.RegisterOptions<any> | undefined): void (+1 overload)register('some-css-modules-loader');

import jsdomjsdom(var undefinedundefined, {
  url: stringurl: 'https://test.example.com', // ⚠️ Failing to specify this will likely lead to many 🤬
});

// Example of how to decorate a global.
// JSDOM's `history` does not handle navigation; the following handles most cases.
const const pushState: (data: any, unused: string, url?: string | URL | null) => voidpushState = module globalThisglobalThis.
module history
var history: History
history
.History.pushState(data: any, unused: string, url?: string | URL | null): voidpushState.CallableFunction.bind<(data: any, unused: string, url?: string | URL | null) => void>(this: (data: any, unused: string, url?: string | URL | null) => void, thisArg: unknown): (data: any, unused: string, url?: string | URL | null) => void (+1 overload)bind(module globalThisglobalThis.
module history
var history: History
history
);
module globalThisglobalThis.
module history
var history: History
history
.History.pushState(data: any, unused: string, url?: string | URL | null): voidpushState = function function (local function) mock_pushState(data: any, unused: any, url: any): voidmock_pushState(data: anydata, unused: anyunused, url: anyurl) {
const pushState: (data: any, unused: string, url?: string | URL | null) => voidpushState(data: anydata, unused: anyunused, url: anyurl); module globalThisglobalThis.var location: Locationlocation.Location.assign(url: string | URL): voidassign(url: anyurl); }; beforeEach(function globalUIBeforeEach(): voidglobalUIBeforeEach); function function globalUIBeforeEach(): voidglobalUIBeforeEach() { module globalThisglobalThis.indexedDb = new import IndexedDbIndexedDb(); }

You can have 2 different levels of UI tests: a unit-like (wherein externals & dependencies are mocked) and a more end-to-end (where only externals like IndexedDb are mocked but the rest of the chain is real). The former is generally the purer option, and the latter is generally deferred to a fully end-to-end automated usability test via something like Playwright or Puppeteer. Below is an example of the former.

import { function before(fn?: it.HookFn, options?: it.HookOptions): voidbefore, function describe(name?: string, options?: it.TestOptions, fn?: it.SuiteFn): Promise<void> (+3 overloads)describe, const mock: it.MockTrackermock, function it(name?: string, fn?: it.TestFn): Promise<void> (+3 overloads)it } from 'node:test';

import { import screenscreen } from '@testing-library/dom';
import { import renderrender } from '@testing-library/react'; // Any framework (ex svelte)

// ⚠️ Note that SomeOtherComponent is NOT a static import;
// this is necessary in order to facilitate mocking its own imports.

function describe(name?: string, fn?: it.SuiteFn): Promise<void> (+3 overloads)describe('<SomeOtherComponent>', () => {
  let let SomeOtherComponent: anySomeOtherComponent;
  let let calcSomeValue: anycalcSomeValue;

  function before(fn?: it.HookFn, options?: it.HookOptions): voidbefore(async () => {
    // ⚠️ Sequence matters: the mock must be set up BEFORE its consumer is imported.

    // Requires the `--experimental-test-module-mocks` be set.
    let calcSomeValue: anycalcSomeValue = const mock: it.MockTrackermock.test.MockTracker.module(specifier: string, options?: it.MockModuleOptions): it.MockModuleContextmodule('./calcSomeValue.js', {
      calcSomeValue: it.Mock<(...args: any[]) => undefined>calcSomeValue: const mock: it.MockTrackermock.test.MockTracker.fn<(...args: any[]) => undefined>(original?: ((...args: any[]) => undefined) | undefined, options?: it.MockFunctionOptions): it.Mock<(...args: any[]) => undefined> (+1 overload)fn(),
    });

    ({ type SomeOtherComponent: anySomeOtherComponent } = await import('./SomeOtherComponent.jsx'));
  });

  function describe(name?: string, fn?: it.SuiteFn): Promise<void> (+3 overloads)describe('when calcSomeValue fails', () => {
    // This you would not want to handle with a snapshot because that would be brittle:
    // When inconsequential updates are made to the error message,
    // the snapshot test would erroneously fail
    // (and the snapshot would need to be updated for no real value).

    function it(name?: string, fn?: it.TestFn): Promise<void> (+3 overloads)it('should fail gracefully by displaying a pretty error', () => {
      let calcSomeValue: anycalcSomeValue.mockImplementation(function function (local function) mock__calcSomeValue(): nullmock__calcSomeValue() {
        return null;
      });

      import renderrender(<let SomeOtherComponent: anySomeOtherComponent />);

      const const errorMessage: anyerrorMessage = import screenscreen.queryByText('unable');

      assert.ok(const errorMessage: anyerrorMessage);
    });
  });
});