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!