Exploring the Node.js Native Test Runner
Damilola Olatunji
Posted on July 31, 2024
The inclusion of a stable test runner in Node.js (version 18+) has generated significant debate in the community, given the abundance of established third-party testing frameworks.
While its arrival naturally sparks comparisons to existing tools, this article won't focus on justifying its place in the ecosystem. Instead, we'll embark on a hands-on exploration of the test runner's core capabilities, from writing and executing tests to organization and customization features.
But before diving in headfirst, let's get a clear picture of what testing is all about.
What Is Testing?
Picture yourself as a meticulous cook trying out a new recipe. You wouldn't just toss everything in a pot and hope for the best, would you? Instead, you'd carefully follow instructions, tasting and adjusting along the way.
You should take a similar approach to testing your Node.js applications. You're essentially writing little checks to verify that each program component (including functions and modules) in your application works as intended.
Think of tests as automated safeguards. They run your code, feed it different inputs, and compare the results to what you expect. This way, if something breaks down the road, your trusty tests will raise a red flag.
That's testing in a nutshell.
But why was a test runner recently added to Node, anyway?
Why Was A Test Runner Added to Node.js 18+?
The reasons behind this are summarized below:
- The importance of testing software is widely acknowledged. A test runner in the Node.js standard library reinforces the idea that testing should be an integral part of the development process and not merely an afterthought.
- Many modern languages and runtimes now favor a "batteries included" approach, providing core tooling out of the box. While Node.js has traditionally maintained a minimalist philosophy, this shift addresses a growing desire for more readily available developer tools.
- The npm ecosystem, while incredibly valuable, has been a target of supply chain attacks. As test runners themselves can be intricate pieces of software, streamlining this process by offering a built-in option minimizes potential attack vectors.
Understanding these motivations is key to appreciating the value of the Node.js test runner.
Now let's start exploring the features of the Node.js test runner by writing a simple program.
Prerequisites
Ensure you're running Node.js v22 or later before proceeding, as some of the features and functionalities discussed in this context are exclusive to this version and may not be available in earlier releases.
Setting Up a Demo Program
To demonstrate how the test runner works in Node.js, we'll use a basic program that defines a ListManager
class. This class manages an array of items, supporting add, remove, and find operations, while enforcing a capacity limit.
Here's the class implementation:
// list_manager.js
class ListManager {
#maxItems;
constructor(max) {
this.#maxItems = max;
this.items = [];
}
capacity() {
return this.#maxItems - this.items.length;
}
addItem(item) {
if (this.capacity() > 0) {
this.items.push(item);
return;
}
throw new Error("Capacity has been reached");
}
removeItem(item) {
const index = this.items.indexOf(item);
if (index > -1) {
this.items.splice(index, 1);
}
}
findItem(item) {
return this.items.includes(item);
}
getAllItems() {
return this.items;
}
}
export { ListManager };
The ListManager
class uses a private #maxItems
field to manage the total capacity. It is also exported for use in other modules.
Here's a basic example of how to use it:
// main.js
import { ListManager } from "./list_manager.js";
const listManager = new ListManager(5);
// Add some items
listManager.addItem("Apple");
listManager.addItem("Banana");
listManager.addItem("Cherry");
// Check the capacity
console.log("Remaining capacity:", listManager.capacity()); // 2
// Check if a specific item exists
console.log('Is "Banana" in the list?', listManager.findItem("Banana")); // true
// Remove an item
listManager.removeItem("Banana");
// Check if the item is still there
console.log('Is "Banana" still in the list?', listManager.findItem("Banana")); // false
// List all items
console.log("Current items:", listManager.getAllItems()); // ['Apple', 'Cherry']
Running the above program should yield the following result:
Remaining capacity: 2
Is "Banana" in the list? true
Is "Banana" still in the list? false
Current items: [ 'Apple', 'Cherry' ]
In the next section, you will start writing unit tests to ensure the
ListManager
methods work as expected.
Testing Your Node.js Code
Now that you understand the program's logic, it's time to write tests to ensure its correctness. Remember that unit tests involve feeding inputs into a program and checking the resulting output or the altered state of the program.
The test module supports the automated testing of a Node.js program by providing a standardized approach to testing, and output that reports when a test has passed or failed.
For our initial test, we'll use a ListManager
instance with a maximum capacity of five, then invoke the addItem()
method to add an item to the list. The test will then verify that the capacity has reduced to four.
To start, create a test environment by setting up a directory and a test file:
mkdir tests
code tests/list_manager.test.js
Inside this test file, write your first test to check that adding an item to a ListManager
with a maximum capacity of five reduces its capacity to four:
// tests/list_manager.test.js
import { test } from "node:test";
import { ListManager } from "../list_manager.js";
import assert from "node:assert/strict";
test("test capacity after adding item", () => {
const fruits = new ListManager(5);
assert.strictEqual(fruits.capacity(), 5);
fruits.addItem("apple");
assert.strictEqual(fruits.capacity(), 4);
});
We currently have a single test that verifies that the capacity of the fruits
list is reduced from five to four when a new item is added.
The first assertion checks if the initial list capacity is correctly set to five. Afterward, the second assertion checks that the capacity of the list is correctly updated to four after adding a single item.
Overall, this test verifies two things:
- That the
ListManager
instance correctly initializes with the given capacity. - That the
capacity()
method correctly reports the updated capacity when an item is added to the list.
In the next section, you will learn how to run the test and interpret its result.
What Happens When You Run node --test
The node:test
module is accompanied by the Node.js test runner which is accessible through the node --test
command. This runner automatically locates and executes tests according to the following criteria, as detailed in the Node.js documentation:
- File names or glob patterns directly provided as an argument to the command.
- Files ending in
.test.{js,mjs,cjs}
,-test.{js,mjs,cjs}
, and_test.{js,mjs,cjs}
in any directory. - JavaScript files starting with
test-
in any directory. - Any
.js
,.cjs
, or.mjs
files within atest
directory at any level.
Each file that is discovered by the test runner is subsequently executed in a separate child process. A test is considered successful if it completes with an exit code of 0; otherwise, it fails.
Go ahead and execute the test you wrote in the previous section with:
node --test
After the tests conclude, the runner displays a summary that includes the test name, outcome, and duration. Here's what the output might look like:
✔ test capacity after adding item (1.184909ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 53.081303
This summary provides a concise overview of the test results, helping you quickly assess the functionality and reliability of your application.
Dealing with Test Failures
To see how the Node.js test runner manages failing tests, we'll intentionally introduce an error to our test. Modify the second assertion in your list_manager.test.js
to expect a capacity of 3 instead of 4:
assert.strictEqual(fruits.capacity(), 3);
This mismatch between expected and actual capacity values will result in a test failure. Running the test again will yield the following output:
✖ test capacity after adding item (1.68598ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
4 !== 3
at TestContext.<anonymous> (file:///home/ayo/dev/demo/nodejs-testing/tests/list_manager.test.js:42:10)
at Test.runInAsyncScope (node:async_hooks:206:9)
at Test.run (node:internal/test_runner/test:824:25)
at Test.processPendingSubtests (node:internal/test_runner/test:533:18)
at node:internal/test_runner/harness:247:12
at node:internal/process/task_queues:140:7
at AsyncResource.runInAsyncScope (node:async_hooks:206:9)
at AsyncResource.runMicrotask (node:internal/process/task_queues:137:8) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 4,
expected: 3,
operator: 'strictEqual'
}
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 51.559443
. . .
The AssertionError
here indicates a failed equality check, with the output detailing the actual and expected values and the specific assertion that was not met.
To resolve this and see a passing test again, undo the change to the expected value in your test file.
Organizing Test Cases with Subtests
Subtests are a feature in Node.js' testing framework that allows you to organize your tests hierarchically. This is facilitated through the TestContext
object, which can be used within the test()
function's callback to create nested subtests.
Here's how you can set it up:
// tests/list_manager.test.js
import { test } from "node:test";
import { ListManager } from "../list_manager.js";
import assert from "node:assert/strict";
test("test list capacity", async (t) => {
await t.test("capacity is initialized to 5", () => {
const fruits = new ListManager(5);
assert.strictEqual(fruits.capacity(), 5);
});
await t.test("capacity is reduced to 4", () => {
const fruits = new ListManager(5);
fruits.addItem("apple");
assert.strictEqual(fruits.capacity(), 4);
});
});
In this structure, each subtest is clearly defined with its own name, making it easier to understand what each test aims to verify.
Since the t.test()
method returns a promise, you must await
each of them so that they run to completion, one after the other. If the top-level test()
function exits prematurely, it could fail with an error, indicating that the subtest was canceled before completion:
. . .
✖ failing tests:
test at file:/home/dami/dev/demo/nodejs-testing/list_manager.test.js:12:5
✖ capacity is reduced to four (0.211737ms)
'test did not finish before its parent and was cancelled'
When you run the test now, you should see the following results:
▶ test list capacity
✔ capacity is initialized to 5 (0.10952ms)
✔ capacity is reduced to 4 (0.086634ms)
▶ test list capacity (0.575585ms)
. . .
This shows each subtest grouped under the main test name, making the output organized and easy to follow.
Let's add a new subtest that checks the behavior of the ListManager
when it is initialized without a maximum value:
test('test list capacity', async (t) => {
await t.test('capacity is initialized to 0', () => {
const empty = new ListManager();
assert.strictEqual(empty.capacity(), 0);
});
. . .
});
This test is designed to check that a list without a predefined capacity defaults to zero. Run the test to see if this is indeed the case:
node --test
You will observe that the test fails:
✔ test capacity after adding item (1.012183ms)
▶ test list capacity
✖ capacity is initialized to 0 (1.050668ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
NaN !== 0
at TestContext.<anonymous> (file:///home/ayo/dev/demo/nodejs-testing/tests/list_manager.test.js:48:12)
at Test.runInAsyncScope (node:async_hooks:206:9)
at Test.run (node:internal/test_runner/test:824:25)
at Test.start (node:internal/test_runner/test:721:17)
at TestContext.test (node:internal/test_runner/test:279:20)
at TestContext.<anonymous> (file:///home/ayo/dev/demo/nodejs-testing/tests/list_manager.test.js:46:11)
at Test.runInAsyncScope (node:async_hooks:206:9)
at Test.run (node:internal/test_runner/test:824:25)
at Test.processPendingSubtests (node:internal/test_runner/test:533:18)
at Test.postRun (node:internal/test_runner/test:923:19) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: NaN,
expected: 0,
operator: 'strictEqual'
}
✔ capacity is initialized to 5 (0.121557ms)
✔ capacity is reduced to 4 (0.120019ms)
▶ test list capacity (1.861563ms)
. . .
Instead of 0, we get NaN
, indicating that there's a bug in our code. To fix this issue, update the ListManager
constructor to default max
to 0
if no value is provided:
// list_manager.js
class ListManager {
#maxItems;
constructor(max = 0) {
this.#maxItems = max;
this.items = [];
}
. . .
}
export { ListManager };
Once corrected, rerun the test to see all subtests pass successfully:
▶ test list capacity
✔ capacity is initialized to 0 (0.366646ms)
✔ capacity is initialized to 5 (0.074781ms)
✔ capacity is reduced to 4 (0.081915ms)
▶ test list capacity (1.895922ms)
. . .
Using the Describe/It Syntax
The describe
and it
keywords are popularly used in other JavaScript testing frameworks to write and organize unit tests. This style originated in Ruby's Rspec testing library and is commonly known as spec-style testing.
The describe()
function groups related tests into a cohesive suite, labeled by a descriptive string that explains what functionality the tests collectively assess. Meanwhile, it()
acts as an alias for test()
, housing the specific test implementation.
Here's how you can reformat the previously discussed tests using the describe/it syntax:
import { describe, it } from "node:test";
import { ListManager } from "../list_manager.js";
import assert from "node:assert/strict";
describe("ListManager capacity", () => {
it("should be initialized to 0 when a maximum capacity is not provided", () => {
const empty = new ListManager();
assert.strictEqual(empty.capacity(), 0);
});
it("should have a capacity of 5", () => {
const fruits = new ListManager(5);
assert.strictEqual(fruits.capacity(), 5);
});
it("should reduce capacity from 5 to 4 when an item is added", () => {
const fruits = new ListManager(5);
fruits.addItem("apple");
assert.strictEqual(fruits.capacity(), 4);
});
});
The output is:
▶ ListManager capacity
✔ should be initialized to 0 when a maximum capacity is not provided (0.63274ms)
✔ should have a capacity of 5 (0.123713ms)
✔ should reduce capacity from 5 to 4 when an item is added (0.145275ms)
▶ ListManager capacity (1.8321ms)
. . .
Moving forward, we will continue using the describe/it syntax for its clear, structured approach to organizing tests.
Customizing Test Runs
As mentioned earlier, The node --test
command automatically finds and runs tests across various files and directories.
However, let's say you're adding new tests or modifying existing ones. You might only want to run specific tests to save time, especially in large projects with numerous tests. Here are some methods to isolate and run only the tests you need.
Running All Tests In a File
You can execute all the tests in a file by passing a file name as the last argument to the node --test
command. Since we currently have just one test file, let's create a dummy test in a new file that always passes:
// tests/main.test.js
import { describe, it } from "node:test";
import assert from "node:assert/strict";
describe("Dummy test suite", () => {
it("should always pass", () => {
assert.ok(true, "This assertion will always pass");
});
});
Run this specific test file with:
node --test tests/main.test.js
This outputs:
▶ Dummy test suite
✔ should always pass (0.342366ms)
▶ Dummy test suite (2.274729ms)
ℹ tests 1
ℹ suites 1
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 67.517265
Glob patterns work too (from Node.js v21)! If you want to execute tests across multiple files, try something like:
node --test "tests/**/*.test.js"
Wrap glob patterns in double quotes to prevent shell expansion and
ensure that the command works in different environments.
Filtering Tests by Name
The --test-name-pattern
flag allows you to run tests whose names match the provided regular expression patterns. For example, you can run the dummy test alone like this:
node --test --test-name-pattern Dummy
This will run all the test cases in the Dummy test suite
alone:
✔ tests/list_manager.test.js (44.956448ms)
▶ Dummy test suite
✔ should always pass (0.634768ms)
▶ Dummy test suite (1.480227ms)
. . .
You can also specify a regular expression pattern like this:
node --test --test-name-pattern '/\d+/'
This runs any tests whose names contain a number resulting in:
▶ ListManager capacity
✔ should be initialized to 0 when a maximum capacity is not provided (0.630625ms)
✔ should have a capacity of 5 (0.111545ms)
✔ should reduce capacity from 5 to 4 when an item is added (0.153768ms)
▶ ListManager capacity (1.911211ms)
✔ tests/main.test.js (44.292226ms)
. . .
You can also provide this flag multiple times if you wish to provide multiple patterns:
node --test --test-name-pattern '<pattern_1>' --test-name-pattern '<pattern_2>'
Skipping Tests
If you'd like to exclude certain tests from being executed, you can provide the skip
option to a single test (or an entire suite) as follows:
// skip the entire suite
describe('A test suite', { skip: true }, () => {
. . .
});
describe('A test suite', () => {
// skip a specific test
it('should pass', { skip: true }, () => {
. . .
});
});
You can also use the skip()
method in the same manner:
// skip the entire suite
describe.skip('A test suite', () => {
. . .
});
describe('A test suite', () => {
// skip a specific test
it.skip('should pass', () => {
. . .
});
});
The skip
option is more handy because it allows you to skip a set of tests depending on the Node.js environment. For example, you may want to skip some tests locally, but run them all in a CI environment:
// skip the entire suite
describe('A test suite', { skip: process.env.NODE_ENV === "development" }, () => {
. . .
})
A similar option for ultra-focused testing is the only
property, which must be combined with the --test-only
command-line flag. It skips all the top-level tests, except those marked with only
:
// run only this test suite in a CI environment
// must be combined with the `--test-only` flag
describe('A test suite', { only: process.env.NODE_ENV === "ci" }, () => {
. . .
})
At the subtest level, you can use the it.only()
method to specify what tests to run:
// tests/list_manager.test.js
import { describe, it } from "node:test";
import { ListManager } from "../list_manager.js";
import assert from "node:assert/strict";
describe("ListManager capacity", { only: true }, () => {
it("should be initialized to 0 when a maximum capacity is not provided", () => {
const empty = new ListManager();
assert.strictEqual(empty.capacity(), 0);
});
it.only("should have a capacity of 5", () => {
const fruits = new ListManager(5);
assert.strictEqual(fruits.capacity(), 5);
});
it("should reduce capacity from 5 to 4 when an item is added", () => {
const fruits = new ListManager(5);
fruits.addItem("apple");
assert.strictEqual(fruits.capacity(), 4);
});
});
Executing the tests with:
node --test --test-only
Will produce the output below:
▶ ListManager capacity
✔ should have a capacity of 5 (0.862671ms)
▶ ListManager capacity (2.041382ms)
✔ tests/main.test.js (45.39399ms)
. . .
Marking tests as "TODO"
TODO tests are a test runner feature allowing you to mark a test as incomplete or pending. This serves as a handy reminder for tests that still need to be written or fixed.
Here's how to mark a test as "todo":
import { describe, it } from "node:test";
import assert from "node:assert/strict";
describe("Feature X", () => {
// you can use the todo method like this when you're reminding yourself
// to write a test
it.todo("should handle edge case A");
// or you can provide the `todo` option when you're reminding yourself
// to fix an existing test that's currently failing
it("should log errors correctly", { todo: true }, () => {
// this will still be executed, but it won't fail the test suite
throw new Error("this does not fail the test");
});
});
Upon execution, both methods result in tests marked as "# TODO" in the output. Importantly, while a test marked with the todo option still runs, its failure won't impact the overall test suite's success.
▶ Feature X
✔ should handle edge case A (0.087529ms) # TODO
✖ should log errors correctly (0.228632ms) # TODO
Error: this does not fail the test
at TestContext.<anonymous> (file:///home/ayo/dev/demo/nodejs-testing/tests/main.test.js:12:11)
at Test.runInAsyncScope (node:async_hooks:206:9)
at Test.run (node:internal/test_runner/test:735:25)
at Suite.processPendingSubtests (node:internal/test_runner/test:449:18)
at Test.postRun (node:internal/test_runner/test:842:19)
at Test.run (node:internal/test_runner/test:777:12)
at async Suite.processPendingSubtests (node:internal/test_runner/test:449:7)
. . .
To ensure that these TODO tests aren't neglected, you can configure your CI/CD pipeline to fail builds when TODO tests exist, prompting their eventual implementation. This practice fosters a proactive approach to test completion and helps maintain a high level of test coverage.
Wrapping Up
In this article, we covered the essentials of testing with Node.js: setting up a project, writing basic tests, and executing them. These fundamentals equip you to validate that your Node.js applications perform as intended, with every new feature thoroughly tested.
However, we've only scratched the surface of the test runner's capabilities! In the next installment of this series, we'll explore advanced techniques like mocking, code coverage, hooks, and testing HTTP servers.
Thanks for reading!
P.S. If you liked this post, subscribe to our JavaScript Sorcery list for a monthly deep dive into more magical JavaScript tips and tricks.
P.P.S. If you need an APM for your Node.js app, go and check out the AppSignal APM for Node.js.
Posted on July 31, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024