A Primer on the Built-in Node.js Test Runner
Welcome! 👋 We're endpts and we make it easy for you to build and deploy Node.js apps. We take care of the infrastructure, so you can focus on building your backend. Get started for free at endpts.io.
Node.js added support for a built-in test runner which has been marked as stable as of v20.x. The test runner allows you to write tests in Node.js without having to rely on third-party libraries such as Mocha, Jest, or AVA.
The goal of this guide is to get you up and running with the Node test runner in 5 minutes, so let's get started!
Project structure
The Node.js test runner searches a few places for tests to execute. Here are some options on how you can structure your project.
Option 1: Tests in a test
directory
The first option is to have your tests live in a test
directory. One approach is to have a test
directory at the same level of the module or component you are testing. For example:

An alternative variation is to create a single test
directory at the root of your project, mirroring the structure of your source code. For example:

The Node.js test runner will execute any files under a test
directory that
have an extension ending in .js
, .mjs
, or .cjs
.
Option 2: Tests co-located with the code they are testing
The second option is to place the test files in the same directory as the code they are testing. There are a few naming conventions you can follow that will enable the Node test runner to find your tests:
- The file name is
test.js
ortest.mjs
ortest.cjs
- The file name starts with
test-
. For example:test-foo.js
. - The file name ends with
.test.js
,-test.js
, or_test.js
. For example:foo.test.js
,foo-test.js
, orfoo_test.js
.

The Node.js test runner will apply the rules above to any files with an
extension ending in .js
, .mjs
, or .cjs
.
Selecting how to structure your test directories is purely a matter of preference. I personally prefer the second option of co-locating the tests with the code they are testing.
Pick the option that works best for you and maintain consistency across your project.
Writing tests
There are 2 ways to write tests: using the describe/it syntax and the test function. Let's use the following math library as sample to add tests for in both styles:
// lib/math.js
export function sum(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new TypeError('sum() expects two numbers')
}
return a + b
}
export function square(a) {
if (typeof a !== 'number') {
throw new TypeError('square() expects a number')
}
return a ** 2
}
Using the describe/it
syntax
Using the describe/it
syntax, we can write tests for the sum
and square
functions, like so:
// lib/math.test.js
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { sum, square } from './math.js'
// the `describe` function is used to group related tests together
// referred to as a "test suite"
describe('sum', () => {
// the `it` function is used to define a single test case
// referred to as a "subtest"
it('throws a TypeError if either parameter is not a number', () => {
assert.throws(() => sum('1', 2), TypeError)
assert.throws(() => sum(1, '2'), TypeError)
})
it('returns the sum of both parameters', () => {
assert.strictEqual(sum(1, 2), 3)
})
})
describe('square', () => {
it('throws a TypeError if the parameter is not a number', () => {
assert.throws(() => square('1'), TypeError)
})
it('returns the square of the parameter', () => {
assert.strictEqual(square(2), 4)
})
})
Using the test
module
The same set of tests re-written in the test
module style would look like this:
// lib/math.test.js
import { test } from 'node:test'
import assert from 'node:assert'
import { sum, square } from './math.js'
test('sum', async (t) => {
// notice the test context parameter `t` that is passed from the parent test to
// the subtests. It is necessary to use this parameter when writing subtests.
await t.test('throws a TypeError if either parameter is not a number', () => {
assert.throws(() => sum('1', 2), TypeError)
assert.throws(() => sum(1, '2'), TypeError)
})
await t.test('returns the sum of both parameters', () => {
assert.strictEqual(sum(1, 2), 3)
})
})
test('square', async (t) => {
await t.test('throws a TypeError if the parameter is not a number', () => {
assert.throws(() => square('1'), TypeError)
})
await t.test('returns the square of the parameter', () => {
assert.strictEqual(square(2), 4)
})
})
It is important to highlight that the subtests are wrapped in an async
function and that it is necessary to await
the subtests. If you do not
await
the subtests, the parent tests will complete before the subtests are
executed and they will be cancelled and treated as failures.
Running tests
To run all the tests in your project, you can execute the following command:
node --test

Alternatively, you can also specify which test files you'd like to run:
node --test lib/math.test.js
Tip: you can also start the Node.js test runner using the --watch
flag to watch for changes to
your test files and their dependencies. When a change is detected, the test
runner will re-run the tests affected by the change automatically.
Running specific tests only: describe.only()
At times, you might want to run only a specific suite of tests. This can be done using the .only
method coupled with the --test-only
flag. For example, to run the test suite for the sum
function, we can add the .only
method to the describe
block, like so:
// lib/math.test.js
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { sum, square } from './math.js'
// add .only to execute only the sum test suite
describe.only('sum', () => {
it('throws a TypeError if either parameter is not a number', () => {
assert.throws(() => sum('1', 2), TypeError)
assert.throws(() => sum(1, '2'), TypeError)
})
it('returns the sum of both parameters', () => {
assert.strictEqual(sum(1, 2), 3)
})
})
describe('square', () => {
it('throws a TypeError if the parameter is not a number', () => {
assert.throws(() => square('1'), TypeError)
})
it('returns the square of the parameter', () => {
assert.strictEqual(square(2), 4)
})
})
Running the tests with the --test-only
flag will now only run the tests in the sum
suite:
node --test --test-only

Running specific tests only - test.only()
The same can be done using the test
module by adding the .only
method to the test
block:
// lib/math.test.js
import { test } from 'node:test'
import assert from 'node:assert'
import { sum, square } from './math.js'
// add .only to execute only the sum test suite
test.only('sum', async (t) => {
await t.test('throws a TypeError if either parameter is not a number', () => {
assert.throws(() => sum('1', 2), TypeError)
assert.throws(() => sum(1, '2'), TypeError)
})
await t.test('returns the sum of both parameters', () => {
assert.strictEqual(sum(1, 2), 3)
})
})
test('square', async (t) => {
await t.test('throws a TypeError if the parameter is not a number', () => {
assert.throws(() => square('1'), TypeError)
})
await t.test('returns the square of the parameter', () => {
assert.strictEqual(square(2), 4)
})
})
Running specific subtests only - test.only()
, t.runOnly(true)
, and { only: true }
To run a specific subtest using the test
module, you can use the { only: true }
option in the subtest and update the test context to run those subtests only using the t.runOnly(true)
method.
For example, to run the returns the sum of both parameters
subtest specifically, we can update the sum
test suite to look like this:
import { test } from 'node:test'
import assert from 'node:assert'
import { sum, square } from './math.js'
// add .only to execute only the sum test suite
test.only('sum', async (t) => {
// ensure that the test context knows to only execute subtests
// with { only: true }
t.runOnly(true)
await t.test('throws a TypeError if either parameter is not a number', () => {
assert.throws(() => sum('1', 2), TypeError)
assert.throws(() => sum(1, '2'), TypeError)
})
// set the { only: true } option on the subtest
await t.test('returns the sum of both parameters', { only: true }, () => {
assert.strictEqual(sum(1, 2), 3)
})
})
test('square', async (t) => {
await t.test('throws a TypeError if the parameter is not a number', () => {
assert.throws(() => square('1'), TypeError)
})
await t.test('returns the square of the parameter', () => {
assert.strictEqual(square(2), 4)
})
})
We can see that only our specific subtest has been run by looking at the test report:

Hooks: before
, after
, beforeEach
, afterEach
When writing tests, you might find yourself repeating the same setup and teardown code for each test.
For example, if you are testing a function that interacts with a database, you might want to connect to the database before the tests run and clean up the database after each test has run.
This can be done using the built in test-hooks provided by the test
module, like so:
import { describe, it } from 'node:test'
import assert from 'node:assert'
describe('GET /user/:id', () => {
const expectedUserId = 'usr_123'
before(async () => {
// create a test user in the database with ID `expectedUserId`
})
after(async () => {
// clean up the database to leave it in a clean state
// for the next tests
})
it('should return the correct user from the database', async () => {
const res = await fetch(`http://localhost:3000/users/${expectedUserId}`)
const user = await res.json()
// check that the user returned is indeed the one we expect
assert.strictEqual(user.id, expectedUserId)
})
it('should return a 404 if the user does not exist', async () => {
const res = await fetch(`http://localhost:3000/users/does-not-exist`)
// check that the response status code is a 404
assert.strictEqual(res.status, 404)
})
})
In the above example, we used the before
and after
hooks which run once per suite (i.e.: describe
block). We can also leverage the beforeEach
and afterEach
hooks which run once per subtest (i.e.: it
block) where it makes sense.
Deploy your Node.js app
👋 If you're new around here, welcome! We're endpts, the easiest way to build and deploy Node.js app. We take care of all the infrastructure side of things, so you can focus on building your backend. With a single git push
you get:
- 🔍 Automatic preview deployments on every push
- ⚡ Scalable, optimized serverless functions
- 🏗️ CI/CD out of the box
- 🌐 Multi-region deployments with global anycast routing
- 🔐 SSL certificates
- and much more...
You can get started for free and deploy your first API in under 2 minutes: endpts.io Dashboard.