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 detectOsInUserAgent
detectOsInUserAgent } from '…';
const const userAgents: {
ua: string;
os: string;
}[]
userAgents = [
{
ua: string
ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.3',
os: string
os: '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 | undefined
concurrency: true }, t: test.TestContext
t => {
for (const { const os: string
os, const ua: string
ua } of const userAgents: {
ua: string;
os: string;
}[]
userAgents) {
t: test.TestContext
t.test.TestContext.test: (name?: string, fn?: test.TestFn) => Promise<void> (+3 overloads)
test(const ua: string
ua, () => 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 string
equal(import detectOsInUserAgent
detectOsInUserAgent(const ua: string
ua), const os: string
os));
}
});
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 getWorkspacePJSONs
getWorkspacePJSONs } 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 | undefined
concurrency: true }, async t: test.TestContext
t => {
const const pjsons: any
pjsons = await import getWorkspacePJSONs
getWorkspacePJSONs();
for (const const pjson: any
pjson of const pjsons: any
pjsons) {
// ⚠️ `t.test`, NOT `test`
t: test.TestContext
t.test.TestContext.test: (name?: string, fn?: test.TestFn) => Promise<void> (+3 overloads)
test(`Ensure fields are properly set: ${const pjson: any
pjson.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) => void
partialDeepStrictEqual(const pjson: any
pjson.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): void
beforeEach } from 'node:test';
import { import ServiceWorkerGlobalScope
ServiceWorkerGlobalScope } from './globals/ServiceWorkerGlobalScope.js';
import './setup.mjs'; // 💡
function beforeEach(fn?: test.HookFn, options?: test.HookOptions): void
beforeEach(function globalSWBeforeEach(): void
globalSWBeforeEach);
function function globalSWBeforeEach(): void
globalSWBeforeEach() {
module globalThis
globalThis.var self: Window & typeof globalThis
self = new import ServiceWorkerGlobalScope
ServiceWorkerGlobalScope();
}
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.MockTracker
mock, function it(name?: string, fn?: it.TestFn): Promise<void> (+3 overloads)
it } from 'node:test';
import { import onActivate
onActivate } from './onActivate.js';
function describe(name?: string, fn?: it.SuiteFn): Promise<void> (+3 overloads)
describe('ServiceWorker::onActivate()', () => {
const const globalSelf: Window & typeof globalThis
globalSelf = module globalThis
globalThis.var self: Window & typeof globalThis
self;
const const claim: it.Mock<() => Promise<void>>
claim = const mock: it.MockTracker
mock.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.MockTracker
mock.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 ActivateEvent
ActivateEvent 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 globalThis
globalThis.var self: Window & typeof globalThis
self = {
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 globalThis
global.var self: Window & typeof globalThis
self = const globalSelf: Window & typeof globalThis
globalSelf;
});
function it(name?: string, fn?: it.TestFn): Promise<void> (+3 overloads)
it('should claim all clients', async () => {
await import onActivate
onActivate(new constructor ActivateEvent(...args: any[]): ActivateEvent
ActivateEvent());
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 1
equal(const claim: it.Mock<() => Promise<void>>
claim.mock: it.MockFunctionContext<() => Promise<void>>
mock.test.MockFunctionContext<() => Promise<void>>.callCount(): number
callCount(), 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 1
equal(const matchAll: it.Mock<() => Promise<void>>
matchAll.mock: it.MockFunctionContext<() => Promise<void>>
mock.test.MockFunctionContext<() => Promise<void>>.callCount(): number
callCount(), 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): string
basename, function (method) dirname(path: string): string
dirname, function (method) extname(path: string): string
extname, function (method) join(...paths: string[]): string
join } from 'node:path';
import { snapshot } from 'node:test';
snapshot.function test.snapshot.setResolveSnapshotPath(fn: (path: string | undefined) => string): void
setResolveSnapshotPath(function generateSnapshotPath(testFilePath: string): string
generateSnapshotPath);
/**
* @param {string} testFilePath '/tmp/foo.test.js'
* @returns {string} '/tmp/foo.test.snap.cjs'
*/
function function generateSnapshotPath(testFilePath: string): string
generateSnapshotPath(testFilePath: string
testFilePath) {
const const ext: string
ext = function extname(path: string): string
extname(testFilePath: string
testFilePath);
const const filename: string
filename = function basename(path: string, suffix?: string): string
basename(testFilePath: string
testFilePath, const ext: string
ext);
const const base: string
base = function dirname(path: string): string
dirname(testFilePath: string
testFilePath);
return function join(...paths: string[]): string
join(const base: string
base, `${const filename: string
filename}.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 prettyDOM
prettyDOM } from '@testing-library/dom';
import { import render
render } from '@testing-library/react'; // Any framework (ex svelte)
import { import SomeComponent
SomeComponent } 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.TestContext
t => {
const const component: any
component = import render
render(<import SomeComponent
SomeComponent />).container.firstChild;
t: it.TestContext
t.test.TestContext.assert: it.TestContextAssert
assert.test.TestContextAssert.snapshot(value: any, options?: it.AssertSnapshotOptions): void
snapshot(import prettyDOM
prettyDOM(const component: any
component));
});
function it(name?: string, fn?: it.TestFn): Promise<void> (+3 overloads)
it('should consume `foo` when provided', function () {
const const component: any
component = import render
render(<import SomeComponent
SomeComponent foo: string
foo="bar" />).container.firstChild;
this.assert.snapshot(import prettyDOM
prettyDOM(const component: any
component));
// `this` works only when `function` is used (not "fat arrow").
});
});
⚠️
assert.snapshot
comes from the test's context (t
orthis
), notnode:assert
. This is necessary because the test context has access to scope that is impossible fornode:assert
(you would have to manually provide it every timeassert.snapshot
is used, likesnapshot(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 Cat
Cat } from './Cat.js';
import { import Fish
Fish } from './Fish.js';
import { import Plastic
Plastic } 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: any
cat = new import Cat
Cat();
const const fish: any
fish = new import Fish
Fish();
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: any
cat.eat(const fish: any
fish));
});
function it(name?: string, fn?: it.TestFn): Promise<void> (+3 overloads)
it('should NOT eat plastic', () => {
const const cat: any
cat = new import Cat
Cat();
const const plastic: any
plastic = new import Plastic
Plastic();
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: any
cat.eat(const plastic: any
plastic));
});
});
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 jsdom
jsdom from 'global-jsdom';
import './setup.units.mjs'; // 💡
import { import IndexedDb
IndexedDb } 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 jsdom
jsdom(var undefined
undefined, {
url: string
url: '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) => void
pushState = module globalThis
globalThis.module history
var history: History
history.History.pushState(data: any, unused: string, url?: string | URL | null): void
pushState.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 globalThis
globalThis.module history
var history: History
history);
module globalThis
globalThis.module history
var history: History
history.History.pushState(data: any, unused: string, url?: string | URL | null): void
pushState = function function (local function) mock_pushState(data: any, unused: any, url: any): void
mock_pushState(data: any
data, unused: any
unused, url: any
url) {
const pushState: (data: any, unused: string, url?: string | URL | null) => void
pushState(data: any
data, unused: any
unused, url: any
url);
module globalThis
globalThis.var location: Location
location.Location.assign(url: string | URL): void
assign(url: any
url);
};
beforeEach(function globalUIBeforeEach(): void
globalUIBeforeEach);
function function globalUIBeforeEach(): void
globalUIBeforeEach() {
module globalThis
globalThis.indexedDb = new import IndexedDb
IndexedDb();
}
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): void
before, function describe(name?: string, options?: it.TestOptions, fn?: it.SuiteFn): Promise<void> (+3 overloads)
describe, const mock: it.MockTracker
mock, function it(name?: string, fn?: it.TestFn): Promise<void> (+3 overloads)
it } from 'node:test';
import { import screen
screen } from '@testing-library/dom';
import { import render
render } 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: any
SomeOtherComponent;
let let calcSomeValue: any
calcSomeValue;
function before(fn?: it.HookFn, options?: it.HookOptions): void
before(async () => {
// ⚠️ Sequence matters: the mock must be set up BEFORE its consumer is imported.
// Requires the `--experimental-test-module-mocks` be set.
let calcSomeValue: any
calcSomeValue = const mock: it.MockTracker
mock.test.MockTracker.module(specifier: string, options?: it.MockModuleOptions): it.MockModuleContext
module('./calcSomeValue.js', {
calcSomeValue: it.Mock<(...args: any[]) => undefined>
calcSomeValue: const mock: it.MockTracker
mock.test.MockTracker.fn<(...args: any[]) => undefined>(original?: ((...args: any[]) => undefined) | undefined, options?: it.MockFunctionOptions): it.Mock<(...args: any[]) => undefined> (+1 overload)
fn(),
});
({ type SomeOtherComponent: any
SomeOtherComponent } = 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: any
calcSomeValue.mockImplementation(function function (local function) mock__calcSomeValue(): null
mock__calcSomeValue() {
return null;
});
import render
render(<let SomeOtherComponent: any
SomeOtherComponent />);
const const errorMessage: any
errorMessage = import screen
screen.queryByText('unable');
assert.ok(const errorMessage: any
errorMessage);
});
});
});