(Updated: )
/ #node:test #node #testing 

Node.js Native Test Runner

If anyone missed it, Node.js 18 includes a test runner/test definition module (node --test and node:test respectively).

node:test exports a test function and you can run the Node CLI with a --test flag which does some basic search/matching for test files.

Full Documentation: nodejs.org/api/test.html

Table of Contents

Example Node.js Tests with Assertions

The recommended way to do assertions is to use the existing assert module.

Combination of node:test + node:assert/strict are good for testing pure code (ie. check output is as expected).

For module interception or advanced stubbing, you’ll have to look elsewhere, although I did find CallTracker the other day, which isn’t as complete as jest.fn() or sinon stubs but you can create a stub that reports on how many times it’s been called, see assert.CallTracker docs.

In short: you can write and run tests in Node.js without any npm packages.

A simple example, in a ping.mjs file.

import test from 'node:test';
import assert from 'node:assert/strict';
const ping = () => 'pong';

test('ping', () => {
  assert.equal(ping(), 'pong');
});

We can run the above using node --test ping.mjs or node ping.mjs which gives the following output:

node ping.mjs
(node:2191) ExperimentalWarning: The test runner is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
TAP version 13
ok 1 - ping
  ---
  duration_ms: 0.001783726
  ...
1..1
# tests 1
# pass 1
# fail 0
# skipped 0
# todo 0
# duration_ms 0.91425132

Given a failure by changing assert.equal(ping(), 'pong'); to assert.equal(ping(), 'ping');, we would get the following output:

node ping.mjs
(node:2321) ExperimentalWarning: The test runner is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
TAP version 13
not ok 1 - ping
  ---
  duration_ms: 0.00915903
  failureType: 'testCodeFailure'
  error: |-
    Expected values to be strictly equal:

    'pong' !== 'ping'

  code: 'ERR_ASSERTION'
  stack: |-
    TestContext.<anonymous> (ping.mjs:5:10)
    Test.runInAsyncScope (node:async_hooks:202:9)
    Test.run (node:internal/test_runner/test:340:20)
    Test.start (node:internal/test_runner/test:292:17)
    Test.test (node:internal/test_runner/harness:126:18)
    file://ping.mjs:5:1
    ModuleJob.run (node:internal/modules/esm/module_job:198:25)
    async Promise.all (index 0)
    async ESMLoader.import (node:internal/modules/esm/loader:409:24)
    async loadESM (node:internal/process/esm_loader:85:5)
  ...
1..1
# tests 1
# pass 0
# fail 1
# skipped 0
# todo 0
# duration_ms 0.684553003

See more examples at github.com/HugoDF/microbundle-ts-pkg/tree/master/test

Mocks, stubs and spies in node:test

The example for this section is available at node-test-runner-examples/src/02.01-simple-mock-assertions.test.js.

Spying and stubbing can be done natively with node:test’s “mocking” functionality.

We can create a mock using mock.fn and assert on its calls using mockFn.mock.callCount() and mockFn.mock.calls[].

import { test } from 'node:test';
import assert from 'node:assert/strict';

test('Simple mock assertions', (t) => {
  const mockFn = t.mock.fn();

  mockFn('call-arg-1', 'call-arg-2');
  assert.deepEqual(mockFn.mock.calls[0].arguments, [
    'call-arg-1',
    'call-arg-2',
  ]);
  assert.equal(mockFn.mock.callCount(), 1);

  mockFn('call-arg-3', 'call-arg-4');
  assert.equal(mockFn.mock.callCount(), 2);
});

Running the above test file at the command line yields the following output:

node src/02.01-simple-mock-assertions.test.js
✔ Simple mock assertions (1.289042ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 5.653416

A failure of callCount() looks as follows:

node src/02.01-simple-mock-assertions.test.js
✖ Simple mock assertions (2.051875ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:

  1 !== 2

      at TestContext.<anonymous> (file:///Users/hugo/Documents/projects/node-test-runner-examples/src/02.01-simple-mock-assertions.test.js:15:9)
      at Test.runInAsyncScope (node:async_hooks:206:9)
      at Test.run (node:internal/test_runner/test:631:25)
      at Test.start (node:internal/test_runner/test:542:17)
      at startSubtest (node:internal/test_runner/harness:216:17) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: 1,
    expected: 2,
    operator: 'strictEqual'
  }

ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 7.69825

And a failure of deepEqual(...arguments, [...]) looks as follows

node src/02.01-simple-mock-assertions.test.js
✖ Simple mock assertions (2.699792ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
  + actual - expected

    [
      'call-arg-1',
  +   'call-arg-2'
  -   'call-arg-1'
    ]
      at TestContext.<anonymous> (file:///Users/hugo/Documents/projects/node-test-runner-examples/src/02.01-simple-mock-assertions.test.js:8:9)
      at Test.runInAsyncScope (node:async_hooks:206:9)
      at Test.run (node:internal/test_runner/test:631:25)
      at Test.start (node:internal/test_runner/test:542:17)
      at startSubtest (node:internal/test_runner/harness:216:17) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: [Array],
    expected: [Array],
    operator: 'deepStrictEqual'
  }

ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 7.854208

More mocking examples are available at github.com/HugoDF/node-test-runner-examples.

For more advanced functionality to augment your tests:

Notes on node:test TAP output

Back to the Node.js test runner, it outputs in TAP, which is the “test anything protocol”, here’s the specification: testanything.org/tap-specification.html.

That means you can take your output and pipe it into existing formatters and there was already node-tap as a userland runner implementation.

Closing remarks

Final couple of things, which are a given for most modern Node.js test runners:

  • it supports all the usual test function formats ie. Promise rejection, error throwing, done callback not invoked are all reported as failures
  • there’s “skip/only” and “sub-tests/test suite” concepts

Node.js “test” files that use node:test can be run directly using node file.js for example. node --test <more-options> can be used for running multiple files at once, see “test runner execution model”.

For a curated set of examples with node:test, see github.com/HugoDF/node-test-runner-examples.

Find more node:test posts on Code with Hugo by going to the node:test tag page.

Photo by DiEGO MüLLER on Unsplash

Author

Hugo Di Francesco

Co-author of "Professional JavaScript", "Front-End Development Projects with Vue.js" with Packt, "The Jest Handbook" (self-published). Hugo runs the Code with Hugo website helping over 100,000 developers every month and holds an MEng in Mathematical Computation from University College London (UCL). He has used JavaScript extensively to create scalable and performant platforms at companies such as Canon, Elsevier and (currently) Eurostar.

Get The Jest Handbook (100 pages)

Take your JavaScript testing to the next level by learning the ins and outs of Jest, the top JavaScript testing library.