Many Node.js projects install a large testing stack before writing their first assertion. That can be worthwhile when a team needs a mature plugin ecosystem, but modern Node.js also includes a capable test runner in core. The node:test module supports suites, hooks, asynchronous tests, mocks, filtering, reporters, process isolation, and built-in coverage collection.

This tutorial builds a small test setup with no third-party test framework. You will test pure functions and HTTP behavior, mock a dependency, add coverage in CI, and troubleshoot the failures that commonly appear when a suite grows. The examples use ECMAScript modules and work best on an actively supported Node.js release.

Start with a minimal project

Create a project and declare ESM so imports behave consistently:

mkdir node-test-runner-demo
cd node-test-runner-demo
npm init -y
npm pkg set type=module
npm pkg set scripts.test="node --test"
npm pkg set scripts.test:watch="node --test --watch"

By default, node --test discovers common test filename patterns, including files ending in .test.js and files inside a test directory. A script keeps local and CI commands identical.

Write a focused unit test

Start with a function that has no network, clock, or database dependency:

// src/shipping.js
export function shippingCost(subtotal, expedited = false) {
  if (!Number.isFinite(subtotal) || subtotal < 0) {
    throw new TypeError('subtotal must be a non-negative number')
  }

if (subtotal >= 75) return expedited ? 12 : 0 return expedited ? 18 : 7 }

Use the strict assertion API from core and group related behavior with describe and it:

// test/shipping.test.js
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { shippingCost } from '../src/shipping.js'

describe('shippingCost', () => { it('gives standard shipping to qualifying orders', () => { assert.equal(shippingCost(75), 0) })

it('charges for expedited delivery', () => { assert.equal(shippingCost(80, true), 12) })

it('rejects invalid subtotals', () => { assert.throws(() => shippingCost(-1), { name: 'TypeError', message: 'subtotal must be a non-negative number' }) }) })

Run npm test. Keep each test centered on one observable behavior. An assertion against a public return value is usually more durable than checking private implementation details.

Test asynchronous failures correctly

Return or await every promise. Otherwise the test may finish before the rejection occurs:

import test from 'node:test'
import assert from 'node:assert/strict'
import { loadUser } from '../src/users.js'

test('loadUser rejects when the record is missing', async () => { await assert.rejects( () => loadUser('missing-id'), { name: 'NotFoundError' } ) })

The runner reports asynchronous activity that outlives a completed test, but the clean solution is explicit ownership: await work, close resources, and never leave a rejected promise floating.

Mock a dependency without replacing the whole module

Dependency injection keeps mocks small. Instead of letting a service import a global email client, accept the function it needs:

// src/invitations.js
export function createInvitationService({ sendEmail }) {
  return async function invite(email) {
    const invitation = { email, status: 'pending' }
    await sendEmail({ to: email, template: 'invitation' })
    return invitation
  }
}

The test context provides a mock tracker and records calls:

import test from 'node:test'
import assert from 'node:assert/strict'
import { createInvitationService } from '../src/invitations.js'

test('invite sends one invitation email', async (t) => { const sendEmail = t.mock.fn(async () => ({ accepted: true })) const invite = createInvitationService({ sendEmail })

const result = await invite('dev@example.com')

assert.equal(result.status, 'pending') assert.equal(sendEmail.mock.callCount(), 1) assert.deepEqual(sendEmail.mock.calls[0].arguments[0], { to: 'dev@example.com', template: 'invitation' }) })

Prefer asserting an important call and the resulting behavior. Tests that reproduce every internal call become expensive to refactor.

Test an HTTP endpoint using an ephemeral port

Integration tests should not assume port 3000 is available. Ask the operating system for a free port by listening on port zero:

// src/server.js
import { createServer } from 'node:http'

export function buildServer() { return createServer((request, response) => { if (request.url === '/health') { response.writeHead(200, { 'content-type': 'application/json' }) response.end(JSON.stringify({ status: 'ok' })) return } response.writeHead(404).end() }) }

// test/server.test.js
import test from 'node:test'
import assert from 'node:assert/strict'
import { once } from 'node:events'
import { buildServer } from '../src/server.js'

test('GET /health reports readiness', async (t) => {
  const server = buildServer().listen(0, '127.0.0.1')
  await once(server, 'listening')
  t.after(() => new Promise((resolve, reject) =>
    server.close((error) => error ? reject(error) : resolve())
  ))

  const { port } = server.address()
  const response = await fetch(`http://127.0.0.1:${port}/health`)

  assert.equal(response.status, 200)
  assert.deepEqual(await response.json(), { status: 'ok' })
})

t.after() registers cleanup even when an assertion fails. Use the same pattern for temporary directories, database pools, and test servers.

Filter tests while debugging

Run a subset by name without editing source:

node --test --test-name-pattern="health|shipping"
node --test test/server.test.js

Watch mode is convenient locally, but the current Node.js documentation still labels it experimental. CI should use a normal one-shot run. Process isolation is the default, so test files normally execute in separate child processes; do not rely on globals shared between files.

Collect coverage and enforce it deliberately

Node can collect V8 coverage through its test runner:

node --test --experimental-test-coverage

The coverage and threshold flags remain marked experimental in current documentation, so verify them against the Node version pinned by your project. A practical package script can start by reporting coverage, then add thresholds after the team has excluded generated code and agreed on meaningful targets:

{
  "scripts": {
    "test": "node --test",
    "test:coverage": "node --test --experimental-test-coverage"
  }
}

Coverage is a signal, not a quality score. A test can execute every line and assert nothing useful. Review important branches, error handling, permissions, and data boundaries.

Add the suite to GitHub Actions

name: node-tests
on:
  pull_request:
  push:
    branches: [main]

jobs: test: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' cache: npm - run: npm ci - run: npm test - run: npm run test:coverage

Pin an active Node release in .nvmrc and commit the lockfile. If coverage flags differ across supported Node majors, run coverage only on the primary version while the ordinary suite covers the compatibility matrix.

Troubleshooting common failures

No tests were discovered

Check the filename pattern and working directory. Pass the file explicitly to separate discovery problems from syntax or import problems.

The process never exits

An open server, timer, socket, or database pool is keeping the event loop alive. Register cleanup with t.after(). Avoid --test-force-exit as a first fix because it can hide leaked resources.

A test passes alone but fails in the suite

Look for shared files, environment variables, ports, database rows, or mocks. Give every test unique data and clean up what it creates. Use deterministic inputs instead of assuming execution order.

ESM imports fail in CI

Confirm type in package.json, include file extensions for local ESM imports, and use the same Node major locally and in CI.

Production checklist

  • Pin an actively supported Node.js version and commit the lockfile.
  • Keep unit tests isolated from networks, clocks, and databases where possible.
  • Await asynchronous assertions and close every resource.
  • Use ephemeral ports and unique test data for integration tests.
  • Mock narrow dependency boundaries instead of internal implementation.
  • Run the same npm test command locally and in CI.
  • Treat experimental CLI flags as version-specific.
  • Review coverage gaps instead of chasing a number alone.

Official sources