The Node.js built-in test runner
By Flavio Copes
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.
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:
export function totalPrice(price, quantity) {
if (quantity < 1) {
throw new RangeError('quantity must be positive')
}
return price * quantity
}
Now create price.test.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:
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:
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:
{
"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():
assert.equal(totalPrice(12, 3), 36)
Check arrays and objects with deepEqual():
assert.deepEqual(
{ title: 'Buy coffee', done: false },
{ title: 'Buy coffee', done: false }
)
Check synchronous errors with throws():
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:
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:
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:
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:
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:
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:
node --test --test-name-pattern='rejects invalid'
You can also mark a test as skipped:
test.skip('connects to the payment sandbox', async () => {
// test body
})
Or run only one test temporarily:
test.only('calculates the total price', () => {
assert.equal(totalPrice(12, 3), 36)
})
Start Node with --test-only when using only:
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:
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:
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:
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:
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:
node --test --test-reporter=spec
TAP output works well with tools that understand the protocol:
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 when a frontend toolchain, richer snapshots, or its plugin ecosystem brings real value. Use Playwright 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.
Want me to talk about your product? You can sponsor this site.
Related posts about node: