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:
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:
Running the above program should yield the following result:
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:
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:
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:
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:
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:
This mismatch between expected and actual capacity values will result in a test failure. Running the test again will yield the following output:
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:
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:
When you run the test now, you should see the following results:
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:
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:
You will observe that the test fails:
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:
Once corrected, rerun the test to see all subtests pass successfully:
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:
The output is:
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:
Run this specific test file with:
This outputs:
Glob patterns work too (from Node.js v21)! If you want to execute tests across multiple files, try something like:
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:
This will run all the test cases in the Dummy test suite
alone:
You can also specify a regular expression pattern like this:
This runs any tests whose names contain a number resulting in:
You can also provide this flag multiple times if you wish to provide multiple patterns:
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:
You can also use the skip()
method in the same manner:
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:
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
:
At the subtest level, you can use the it.only()
method to specify what tests
to run:
Executing the tests with:
Will produce the output below:
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":
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.
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.