Native testing in NodeJS

Joris Verbogt
Joris Verbogt
Jul 7 2023
Posted in Engineering & Technology

No dependency on testing frameworks

Native testing in NodeJS

The NodeJS ecosystem is currently in its 20th official release cycle. Although it has matured over the years, functionality is being added and extended with every release.

On the one hand, changes to the JavaScript language itself obviously end up in NodeJS as well (for example, new Array and String methods). On the other hand, new features are being added to the NodeJS system libraries themselves (e.g., Performance Hooks)

Still an experimental feature in NodeJS 18, the built-in testing framework was declared stable and is enabled by default in the latest NodeJS 20 release.

In this blog post, we want to show you some of its functionality and how it compares to other, existing test runners already out there.

Basics

Needless to say, to run these examples, you will need to install NodeJS 20.

The module to be imported into your code is node:test (the 'node:' prefix is needed). To give some basic examples, we're going to pair it with another built-in NodeJS module node:assert.

import test from 'node:test'
import assert from 'node:assert/strict'

test('hello', () => {
  const message = 'Hello'
  assert.equal(message, 'Hello')
})

What this means is we will run a test named 'hello', which will assert equality between a string variable and a string literal. Running this test will succeed (in this case):

node --test test/sample.js

This will print something like this (in a terminal):

✔ hello (0.899084ms)
ℹ tests 1
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 66.093

If the assertion fails, let's say with a string 'Goodbye', it will inform you of the failure details:

✖ hello (1.531416ms)
  AssertionError: Expected values to be strictly equal:
  + actual - expected

  + 'Goodbye'
  - 'Hello'
      at TestContext.<anonymous> (file:///test/sample.js:6:12)
      at Test.runInAsyncScope (node:async_hooks:203:9)
      at Test.run (node:internal/test_runner/test:547:25)
      at Test.start (node:internal/test_runner/test:463:17)
      at startSubtest (node:internal/test_runner/harness:190:17) {
    generatedMessage: false,
    code: 'ERR_ASSERTION',
    actual: 'Goodbye',
    expected: 'Hello',
    operator: 'strictEqual'
  }

Which, if you are familiar with existing testing frameworks like Mocha, is pretty much what you would expect from a test runner.

The example above just runs a synchronous function, which will fail the test if it throws an exception, i.e., in this case, fails an assertion. It is also possible to run an asynchronous function:

import test from 'node:test'
import assert from 'node:assert/strict'
import { setTimeout } from 'node:timers/promises'

test('hello', async () => {
  const message = 'Hello'
  await setTimeout(1000)
  assert.equal(message, 'Hello')
})

Which will result in a successful test after 1000 ms:

✔ hello (1004.210334ms)

Let's try a more complex assertion:

test('contact', () => {
    const contact = {
      name: 'Jane Doe',
      address: { street: 'Jump Street', number: 21 }
    }
    assert.deepEqual(contact, {
      name: 'Jane Doe',
      address: { street: 'Jump Street', number: 22 }
    })
})

This time, we'll run it with an explicit report type to display nested information:

node --test-reporter tap test/sample.js

Which will result in:

TAP version 13
# Subtest: contact
not ok 1 - contact
  ---
  duration_ms: 3.344459
  failureType: 'testCodeFailure'
  error: |-
    Expected values to be strictly deep-equal:
    + actual - expected

      {
        address: {
    +     number: 21,
    -     number: 22,
          street: 'Jump Street'
        },
        name: 'Jane Doe'
      }
  code: 'ERR_ASSERTION'
  name: 'AssertionError'
  name: 'Jane Doe'
  street: 'Jump Street'
  number: 22
  name: 'Jane Doe'
  street: 'Jump Street'
  number: 21
  operator: 'deepStrictEqual'
  stack: |-
    TestContext.<anonymous> (file:///test/sample.js:6:12)
    Test.runInAsyncScope (node:async_hooks:203:9)
    Test.run (node:internal/test_runner/test:547:25)
    Test.start (node:internal/test_runner/test:463:17)
    startSubtest (node:internal/test_runner/harness:190:17)
  ...
1..1
# tests 1
# pass 0
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 9.338084

BDD Style Tests

Plain tests are nice, but not very descriptive. A lot of test writers prefer a BDD-syntax to describe and structure their tests. This is also possible with the NodeJS test runner:

import { describe, it } from 'node:test'
import assert from 'node:assert/strict'

describe('contact greeting', () => {
  it('hello', () => {
    const message = 'Hello'
    assert.equal(message, 'Hello')
  })

  it('contact', () => {
    const contact = {
      name: 'Jane Doe',
      address: {
        street: 'Jump Street', number: 21
      }
    }
    assert.deepEqual(contact, {
      name: 'Jane Doe',
      address: {
        street: 'Jump Street', number: 21
      }
    })
  })
})

which will result in:

▶ contact greeting
  ✔ hello (0.195292ms)
  ✔ contact (0.341625ms)
▶ contact greeting (1.320583ms)

Mocks

An important part of testing is the ability to mock parts of your implementation.

Let's create an example where we mock the contact name method:

import { test, mock } from 'node:test'
import assert from 'node:assert/strict'

test('return contact name', () => {
    const contact = {
        name() {
            return 'Jane Doe'
        }
    }
    // the assertion for the actual contact
    assert.equal(contact.name(), 'Jane Doe')
    // mocking the method contact.name()
    mock.method(contact, 'name', () => 'Silas Ramsbottom')
    assert.equal(contact.name(), 'Silas Ramsbottom')
    // confirm the mock method was actually called
    assert.equal(contact.name.mock.calls.length, 1)
    // restore the original contact.name() method
    contact.name.mock.restore()
    assert.equal(contact.name(), 'Jane Doe')
})

As you can see: pretty powerful features!

There's also functionality like test setup and teardown with before and after, mocking of timers, parallel execution of tests and more.

Conclusion

Although the node:test module is new and will likely see improvements, it is an option to be considered, especially for new code projects. It eliminates the need for an external testing framework and might drastically decrease the number of package dependencies.

As always, we hope you liked this article and if you have anything to add, maybe you are suited for a Developer position in Notificare. We are currently looking for a Core API Developer, check out the job description. If modern Javascript is your thing, don't hesitate to apply!

Keep up-to-date with the latest news