Testing is a very important part of building modern applications. In the past, it was seen as an afterthought, something that was to be done after the application has been built. But in recent years, testing has received a lot more attention. While some programming languages incorporate testing tools to the core language feature, this wasn’t the case in the JavaScript/Node.js ecosystem. Tools like Mocha, Jest, and Jasmine were created to help with elaborate testing mechanisms.
A native test runner was added as an experimental feature to Node.js v18, and fully incorporated into the Node.js core in version 20. This tutorial will provide an overview of the features of the Node.js native test runner, exploring concepts like mocks, spies, and stubs. We will also compare the native test runner to other testing tools like Jest and Mocha.
Jump ahead:
The test runner is a part of the Node core. To import it, run the following command:
import { test } from "node:test"
Libraries like Jest have an assertion module built into them in order to make comparisons. Node.js has the assert
module for that:
import assert from "node:assert"
Using both test
and assert
, we can write tests as shown in the code block below:
test("some test", (t) => { assert.strictEqual(1, 1) }) test("second basic test", () => { assert.equal(2, 3) })
Other libraries provide ways to group related tests into blocks. In addition to that, the node:test
module also provides describe()
and it()
functions. The it()
function serves as a shorthand for test()
:
import { describe, it } from "node:test" describe("testing", () => { it("some test", () => { assert.strictEqual(1, 1) }) })
The new Node.js test runner also allows you to skip tests:
test.skip("Skipping tests", () => { assert.notEqual(1, 2) })
Use the node--test
command to run your test files.
Now, let’s set up our tests. First, create a directory called native-test
, and run npm init -y
to set up a new Node project. Next, create an src
directory, which will hold the code for the application and create a test
directory alongside the src
directory, which will hold the test files.
Then, update the test script in your package.json file with the following code snippet:
"scripts": { ... "test": "node --test native/tests/" },
The test script eliminates the need to run node--test--[flag] /test-folder
every single time. Instead, you can just specify the test folders and desired flags in the test script and then run npm test
afterwards. Test files should have the .test.js
or .test.ts
extension, which is the same for other Node testing frameworks.
It is also best practice to write tests for each code independently. For example, if you have a makeRequest.js
file, then it should have a corresponding test file such as makeRequest.test.js
, rather than a file with some other name. This makes it easily recognizable and helps other developers understand what is going on in the code base.
Writing and grouping tests into suites based on functionality using describe blocks is also a good practice. Clear and set mocks and other configurations in the test environment using beforeEach()
, before()
, afterEach()
, and after()
.
In addition to the basic testing functionality that the Node test runner implements, it also has the capability to be used for writing more elaborate tests. We can create spies and stubs using the mock function, and we can also collect test coverage. This section will dive deeper into some of the advanced features and functionalities of the native test runner.
The mock function from the Node test suite provides a way for us to mock and replace functions at runtime. We can use the function to create spies and stubs.
A test spy is a function that allows us to check the arguments with which it was called, the return value, the this
context, and the exceptions that are thrown, if any. The code block below is an example of how to write a test spy with the native Node test runner:
import { test, describe, it, beforeEach, mock } from "node:test" import assert from "node:assert" const sumArray = (arr) => { return arr.reduce((a, b) => a + b, 0) } describe("Spies test: it should call sumArray function with arguments", async () => { it("some test", async (t) => { const spy = mock.fn(sumArray) assert.strictEqual(spy([1, 5, 3]), 9) assert.strictEqual(spy.mock.calls.length, 1) const call = spy.mock.calls[0] assert.deepEqual(call.arguments[0], [1, 5, 3]) assert.strictEqual(call.result, 9) }) }) test.skip("Skipping tests", () => { assert.notEqual(1, 2) })
sumArray
is a function that takes an array as an argument and then returns the sum of the array. Using the mock.fn()
function, we are able to create a spy to check how many times the sumArray()
function is called. We also test the arguments passed to the function and the output of the function for correctness.
Stubs are functions that replace the behavior of other functions with predefined values. When writing tests, it is often best practice to make sure the tests do not rely on external data. We often use stubs to replace functions that make network requests. And we still use the mock()
function when writing stubs:
mock .method(objectName, methodName) .mock.mockImplementation(async () => { return {} })
Let’s see an example where we implement both spies and stubs to mock a service that fetches data from an API. Create an index.js
file with the following code as its contents. The code below implements a MakeRequest
class, which has three functions: fetchDataFromAPI()
, slugifyTitle()
, and addToDB()
:
import axios from "axios" class MakeRequest { constructor() {} static async fetchDataFromAPI(id) { const { data: todo } = await axios.get( "https://jsonplaceholder.typicode.com/todos/1" ) return todo } static slugifyTitle(todo) { const slug = `${todo.title.replace(/ /g, "-")}${todo.id}` return { ...todo, slug } } static async addToDB() { let todo = await this.fetchDataFromAPI() todo = this.slugifyTitle(todo) return todo } } export default MakeRequest
To write a test for the MakeRequest
class, create a test directory with the makeRequest.test.js
file. You have to import functions from node:test
, asserts, and also the MakeRequest
class you wish to test:
import { test, describe, it, mock, beforeEach } from "node:test" import assert from "node:assert" import MakeRequest from "../src/index.js" describe("testing", () => { beforeEach(() => mock.restoreAll()) //tests... })
The beforeEach()
function runs before each test in test suite
. In this context, we use beforeEach()
with mock.restoreAll()
to reset all mocks in the suite to prevent any conflicts:
it("fetchDataFromAPI should return a product", async (t) => { mock .method(MakeRequest, MakeRequest.fetchDataFromAPI.name) .mock.mockImplementation(async () => { return { userId: 1, id: 1, title: "delectus aut autem", completed: false, } }) const res = await MakeRequest.fetchDataFromAPI() assert.strictEqual(res.userId, 1) assert.strictEqual(res.completed, false) })
The test above mocks the fetchDataFromAPI
method from the MakeRequest
class. In order to prevent the function from making a network request as it usually would, we use the mockImplementation()
to return a predefined output, which we then test for specific values:
it("fetchDataFromAPI should return product with given ID", async (t) => { const id = 3 mock .method(MakeRequest, MakeRequest.fetchDataFromAPI.name) .mock.mockImplementation(async (id) => { return { userId: 1, id: id, title: "delectus aut autem", completed: false, } }) const res = await MakeRequest.fetchDataFromAPI(id) assert.deepEqual(res.id, id) }) it("should create a slug based on the title", async (t) => { const slugSpy = mock.method(MakeRequest, "slugifyTitle") mock .method(MakeRequest, "fetchDataFromAPI") .mock.mockImplementation(async () => { return { userId: 1, id: 1, title: "delectus aut autem", completed: false, } }) await MakeRequest.addToDB() const call = MakeRequest.slugifyTitle.mock.calls[0] assert.deepEqual(slugSpy.mock.calls.length, 1) assert.deepEqual(MakeRequest.fetchDataFromAPI.mock.callCount(), 1) assert.strictEqual(call.result.slug, `delectus-aut-autem1`) })
In the final test in this suite, using mock.method()
, we create a spy to test if the slugifyTitle()
function is called. We also test how many times the function is called and its output based on the title.
Collecting code coverage is an important part of testing as it helps to know the percentage of functions in different files that you have written tests for.
In Jest, code coverage can be collected as follows:
jest --collectCoverage [other options]
In the native test runner, you can carry out test coverage using the --experimental-test-coverage
flag. All you need to do is update the test script in your package.json file as follows:
"scripts": { ... "test": "NODE_V8_COVERAGE=native/tests/ node --test --experimental-test-coverage native/tests/ " },
Run the test command and you should see the following output:
... start of coverage report â„ą file | line % | branch % | funcs % | uncovered lines â„ą native/src/index.js | 80.77 | 100.00 | 50.00 | 7, 8, 9, 10, 11 â„ą native/tests/spies.test.js | 95.45 | 100.00 | 80.00 | 21 â„ą native/tests/stubs.test.js | 100.00 | 100.00 | 100.00 | â„ą all files | 94.64 | 100.00 | 82.35 | â„ą end of coverage report
Specifying the NODE_V8_COVERAGE=native/tests/
tells Node.js which directory to put the coverage reports. The test coverage feature is still in the experimental phase, so it lacks some features that the other established test runners possess.
Test reporters allow you to format the output of your test. The Node.js test runner supports three test reporters out of the box: tap
, spec
, and dot
. The spec
reporter is the default test reporter, so the output you see when you run your test command without specifying the --test-reporter
flag is a spec
output. You can change this by editing the test script as follows:
"scripts": { "test": "node --test --test-reporter tap native/tests/ " },
Test reporters are not a new concept in testing; testing frameworks such as Mocha and Jest also support different test reporters. However, Jest supports passing the test reporter through the command line, so you have to pass it through a jest
config file as follows:
import type {Config} from 'jest'; const config: Config = { reporters: [ 'default', ['jest-junit', {outputDirectory: 'reports', outputName: 'report.xml'}], ], }; export default config;
The list of test reporters supported by Mocha can be found in its documentation. To enable a different test reporter in Mocha, you have to run the following command:
mocha --reporter=reporterName
So far, we’ve written spies and stubs with the native Node test runner. We can do the same using other testing frameworks.
There aren’t many differences when writing mocks in Jest and the native test runner. In most cases, the Node test runner implements the same methods. Here is an example of writing spies in Jest:
import { jest, expect, describe, it } from '@jest/globals' sumArray() { //same implementation as previous } describe("...", () => { it("some test", () => { const spy = jest.fn(sumArray) spy([1, 5, 3]) expect(spy).toHaveBeenCalled() expect(spy).toHaveBeenNthCalledWith([1, 5, 3]) }) })
And here is an example of writing stubs in Jest:
import { jest, describe, it } from '@jest/globals' import MakeRequest from "../src/index.js" describe("...", () => { beforeEach(() => jest.restoreAllMocks()) it("some test", async() => { jest.spyOn(MakeRequest, "fetchDataFromAPI").mockResolvedValue(async() => { { userId: 1, id: 1, title: "delectus aut autem", completed: false, }); }) }) })
Writing mocks in Mocha is somewhat different as it makes use of Sinon, which is an external library for stubs and spies. Here is an example of writing spies in Mocha:
const expect = require("chai").expect; const sinon = require("sinon"); sumArray() { //same implementation as previous } describe("some test", () => { afterEach(() => { sinon.restore(); }); it("...", async () => { const spy = sinon.spy(sumArray) expect(next.callCount).equal(1); }); });
And here is an example of writing stubs in Mocha:
const expect = require("chai").expect; const sinon = require("sinon"); import MakeRequest from "../src/index.js" describe("some test .....", () => { afterEach(() => { sinon.restore(); }); it("...", async () => { sinon.stub(MakeRequest, "fetchDataFromAPI").returns({ userId: 1, id: 1, title: "delectus aut autem", completed: false, }); const res = await MakeRequest.addToDB(); expect(res.userId).equal(1); expect(res.completed).equal(false); }); });
Notice that the properties used for assertion in Mocha and Jest have proper interfaces, while those of the Node test runner are arrays. To access to those values, you have to use indexes.
Here is a comparison of the features provided by the native Node test runner, Jest, and Mocha:
Features | Node.js test runner | Jest | Mocha |
---|---|---|---|
Spies | Built-in | Built-in | Sinon |
Stubs | Built-in | Built-in | Sinon |
Code coverage | Experimental | Full feature | Full feature |
Assertion library | Assert | Built-in | Chai is widely used |
Fake timers | Built-in | Built-in | Sinon |
Test hooks | Present | Present | Present |
Asynchronous testing | Present | Present | Present |
Jest and Mocha have been in the ecosystem for a much longer time than the native Node test runner, and so they have large communities of developers. However, the Node test runner does offer some advantages that the other frameworks might lack.
The Node.js test runner is part of the Node core, so it makes sense that it is faster than the other options. The test runner is optimized for Node, unlike other testing frameworks that have to take browsers into consideration.
Additionally, thanks to the native test runner, developers can spend less time deliberating over what framework to write their tests in, which reduces the overhead for testing. It also eliminates the need to keep up with the latest version of a testing framework.
Finally, the native test runner implements some of the same methods as other test frameworks. For example, mock
methods such as mockImplementation()
and restoreAll()
on the native test runner and Jest are quite similar. This makes it easy for developers with prior experience in other testing frameworks to start writing tests with the native test runner.
The aim of this article was to introduce you to the Node.js native test runner and show you how to write test functions the same way you would when using other testing frameworks. You’ve learned how to write basic tests and also some of the advanced features and functionalities of the native test runner.
Hopefully, this tutorial encourages you to try out the Node test runner in your next project.
Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third-party services are successful, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens while a user interacts with your app. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.
LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.