javascript

Exploring the Node.js Native Test Runner

Damilola Olatunji

Damilola Olatunji on

Exploring the Node.js Native Test Runner

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:

javascript
// 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:

javascript
// 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:

shell
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:

shell
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:

javascript
// 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:

  1. That the ListManager instance correctly initializes with the given capacity.
  2. 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:

  1. File names or glob patterns directly provided as an argument to the command.
  2. Files ending in .test.{js,mjs,cjs}, -test.{js,mjs,cjs}, and _test.{js,mjs,cjs} in any directory.
  3. JavaScript files starting with test- in any directory.
  4. Any .js, .cjs, or .mjs files within a test 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:

shell
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:

shell
✔ 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:

javascript
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:

shell
✖ 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:

javascript
// 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:

shell
. . . ✖ 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:

shell
▶ 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:

javascript
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:

shell
node --test

You will observe that the test fails:

shell
✔ 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:

javascript
// 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:

shell
▶ 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:

javascript
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:

shell
▶ 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:

javascript
// 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:

shell
node --test tests/main.test.js

This outputs:

shell
▶ 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:

shell
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:

shell
node --test --test-name-pattern Dummy

This will run all the test cases in the Dummy test suite alone:

shell
✔ 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:

shell
node --test --test-name-pattern '/\d+/'

This runs any tests whose names contain a number resulting in:

shell
▶ 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:

shell
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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

shell
node --test --test-only

Will produce the output below:

shell
▶ 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":

javascript
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.

shell
▶ 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.

Damilola Olatunji

Damilola Olatunji

Damilola is a freelance technical writer and software developer based in Lagos, Nigeria. He specializes in JavaScript and Node.js, and aims to deliver concise and practical articles for developers. When not writing or coding, he enjoys reading, playing games, and traveling.

All articles by Damilola Olatunji

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps