A Primer on the Built-in Node.js Test Runner

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:

Multiple test directories at the same level as the modules they are testing

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:

A single test directory at the root of the project, mirroring the source files

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 or test.mjs or test.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, or foo_test.js.
Co-located test files for the Node.js test runner

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)
  })
})

Running tests

To run all the tests in your project, you can execute the following command:

node --test
Running the tests in the project

Alternatively, you can also specify which test files you'd like to run:

node --test lib/math.test.js

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 only a specific set of tests with the Node.js test runner

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:

Running only a specific subtest with the Node.js test runner

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.