# The Node.js built-in test runner

> Use Node.js built-in test runner with node:test and node:assert. Write tests, run with node --test, watch mode, mocking, and coverage without Jest.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-29 | Updated: 2026-08-03 | Topics: [Node.js](https://flaviocopes.com/tags/node/) | Canonical: https://flaviocopes.com/node-test-runner/

Node ships a test runner in `node:test` and assertions in `node:assert`. For many Node projects, that is a complete test stack with no test dependency or configuration file.

Let's build a small module and test it from the command line.

## Write the first test

Create `price.js`:

```js
export function totalPrice(price, quantity) {
  if (quantity < 1) {
    throw new RangeError('quantity must be positive')
  }

  return price * quantity
}
```

Now create `price.test.js`:

```js
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { totalPrice } from './price.js'

test('calculates the total price', () => {
  assert.equal(totalPrice(12, 3), 36)
})
```

These examples use ES modules. Add `"type": "module"` to `package.json`, or use the `.mjs` extension.

Run the test file:

```bash
node --test price.test.js
```

The process exits with code `0` when every test passes. It exits with a non-zero code when a test fails, which is exactly what CI needs.

Run all discovered test files with:

```bash
node --test
```

Node recognizes common names including files ending in `.test.js`, `.spec.js`, `_test.js`, and `-test.js`. I prefer `name.test.js` because the relationship with `name.js` is clear.

Add a script so nobody has to remember the command:

```json
{
  "scripts": {
    "test": "node --test"
  }
}
```

Now `npm test` runs the suite.

## Use strict assertions

The `node:assert/strict` entry point uses strict equality by default.

Check primitive values with `equal()`:

```js
assert.equal(totalPrice(12, 3), 36)
```

Check arrays and objects with `deepEqual()`:

```js
assert.deepEqual(
  { title: 'Buy coffee', done: false },
  { title: 'Buy coffee', done: false }
)
```

Check synchronous errors with `throws()`:

```js
test('rejects a zero quantity', () => {
  assert.throws(
    () => totalPrice(12, 0),
    { name: 'RangeError', message: 'quantity must be positive' }
  )
})
```

The test describes observable behavior. It does not know how `totalPrice()` performs the calculation.

## Test asynchronous code

An async test returns a promise. The runner waits for it:

```js
import assert from 'node:assert/strict'
import { test } from 'node:test'

test('loads a note', async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/todos/1')
  const note = await response.json()

  assert.equal(note.id, 1)
})
```

If the promise rejects, the test fails.

Use `assert.rejects()` when rejection is the expected result:

```js
test('rejects a missing note', async () => {
  await assert.rejects(
    () => loadNote(999),
    /note not found/
  )
})
```

Do not mix a callback and a returned promise in the same test. Pick one completion mechanism so the runner knows exactly when the test ends.

## Group related tests

`describe()` creates a suite. `it()` is another name for a test:

```js
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { totalPrice } from './price.js'

describe('totalPrice', () => {
  it('multiplies price and quantity', () => {
    assert.equal(totalPrice(12, 3), 36)
  })

  it('rejects invalid quantities', () => {
    assert.throws(() => totalPrice(12, 0), RangeError)
  })
})
```

Grouping helps when several tests share one public API. Do not build deeply nested suites. Flat test names are easier to scan when CI fails.

## Set up and clean up fixtures

Use hooks when tests need a shared setup pattern:

```js
import assert from 'node:assert/strict'
import { afterEach, beforeEach, test } from 'node:test'

let notes

beforeEach(() => {
  notes = []
})

afterEach(() => {
  notes = undefined
})

test('adds a note', () => {
  notes.push('Buy coffee')
  assert.deepEqual(notes, ['Buy coffee'])
})
```

Available hooks include `before`, `after`, `beforeEach`, and `afterEach`.

Prefer fresh state for each test. Shared mutable fixtures make tests depend on execution order.

The test context also provides `t.after()` for cleanup tied to one test:

```js
test('writes a temporary file', async (t) => {
  const directory = await createTemporaryDirectory()

  t.after(async () => {
    await removeTemporaryDirectory(directory)
  })

  await writeReport(directory)
  assert.equal(await reportExists(directory), true)
})
```

The cleanup runs even when the assertion fails.

## Test only one part of the suite

Filter tests by name while debugging:

```bash
node --test --test-name-pattern='rejects invalid'
```

You can also mark a test as skipped:

```js
test.skip('connects to the payment sandbox', async () => {
  // test body
})
```

Or run only one test temporarily:

```js
test.only('calculates the total price', () => {
  assert.equal(totalPrice(12, 3), 36)
})
```

Start Node with `--test-only` when using `only`:

```bash
node --test --test-only
```

Be careful not to commit an accidental focused test. CI may then run less than the full suite.

## Watch mode

Re-run affected tests when files change:

```bash
node --test --watch
```

The runner watches test files and their dependencies. Save `price.js`, and its tests run again.

Watch mode is still marked experimental in current Node 24 documentation. It is useful for local development, but I keep the normal `node --test` command in CI.

## Mock functions

The test context has a mock tracker. It automatically restores tracked mocks when the test ends.

Create a small spy:

```js
import assert from 'node:assert/strict'
import { test } from 'node:test'

test('sends one notification', (t) => {
  const send = t.mock.fn(() => 'sent')

  const result = send('Build finished')

  assert.equal(result, 'sent')
  assert.equal(send.mock.callCount(), 1)
  assert.deepEqual(send.mock.calls[0].arguments, ['Build finished'])
})
```

Mock a method on an object:

```js
test('logs the saved note', (t) => {
  const logger = {
    info(message) {
      console.log(message)
    }
  }

  const info = t.mock.method(logger, 'info')

  logger.info('note saved')

  assert.equal(info.mock.callCount(), 1)
})
```

Mocks are useful at a real boundary: email, time, payments, or an HTTP client. Mocking every internal function makes tests follow the implementation instead of the behavior.

Node also supports module mocking, but current Node 24 releases require an experimental flag for it. I would not redesign a test suite around that API until the project accepts that stability level.

## Code coverage

Collect coverage with:

```bash
node --test --experimental-test-coverage
```

Node reports line, branch, and function coverage. The feature remains experimental in Node 24.

Coverage answers one question: which code ran during the tests? It does not prove that the assertions are useful. A test can execute every line and still miss the important behavior.

Use coverage to find suspicious gaps, not as a substitute for thinking about cases.

## Choose a reporter

Node selects a terminal-friendly reporter by default. Choose one explicitly with `--test-reporter`:

```bash
node --test --test-reporter=spec
```

TAP output works well with tools that understand the protocol:

```bash
node --test --test-reporter=tap
```

For machine-readable output, use `junit` or another supported reporter from the current Node release. Reporter availability can change, so check the Node documentation before wiring a CI parser around one.

## Understand isolation and concurrency

By default, Node runs discovered test files in separate child processes. A crash or changed global in one file does not directly mutate another test file's process.

Tests inside one file are not automatically parallel. You can opt into concurrency, but start sequentially unless runtime is a measured problem.

Parallel tests must not share the same file, port, database row, or environment variable. Give each test its own resource or add explicit coordination.

## When the built-in runner is enough

The built-in runner works well for:

- Node scripts and command-line tools
- backend services and libraries
- projects that want very few development dependencies
- tests built around standard assertions and small mocks

Reach for [Vitest](https://flaviocopes.com/vitest/) when a frontend toolchain, richer snapshots, or its plugin ecosystem brings real value. Use [Playwright](https://flaviocopes.com/playwright-e2e-testing/) for complete browser flows.

Those tools solve different layers. A Node unit test should not launch a browser, and an end-to-end test should not replace focused business-logic tests.

## How I use the Node test runner

I start new Node utilities with `node:test` and `node:assert/strict`. The first test command is one line, so testing begins before configuration becomes a project of its own.

I keep tests close to public behavior. I use in-memory state or temporary directories, clean up through the test context, and mock only external boundaries.

I would switch tools when a project genuinely needs another ecosystem. I would not install a larger runner only because its syntax is familiar. The built-in runner now covers the normal path very well.
