Node.js 70 🟢 Native Test Runner (node:test) and Assertion Library (node:assert)
The Node.js test runner is not a third-party package. It ships with the runtime, stabilized in Node.js 20, and requires no npm install for the core functionality . You write a test file, run node --test, and the runner discovers the file, executes it in a separate child process, and reports the result. The assertion library is node:assert, also built in, which provides the comparison functions that determine whether a test passes or fails. Together they form a testing stack with zero dependencies, which is the entire point: for a Node-only library or CLI tool, pulling in Jest’s fifty-plus transitive packages just to run unit tests is no longer the default assumption .
The runner’s design reflects the runtime’s constraints. Each test file runs in its own child process, isolating global state and preventing a failing test in one file from corrupting another. The execution model supports synchronous functions, promise-returning functions, and callback-style functions, so tests can be written in whichever style the codebase already uses . The assertion library covers equality, deep equality, error throwing, pattern matching, and rejection checking. It is not as expressive as Chai or Jest’s expect, but it is sufficient for the majority of Node-only test suites .
Key point: The Node.js test runner is built into the runtime and invoked with node --test, which discovers files matching *.test.js, *-test.js, *_test.js, and test-*.js patterns and runs each in a separate child process; node:assert provides the assertion functions, with assert.strictEqual and assert.deepStrictEqual as the primary equality checks.
Why the built-in runner exists
The dependency problem. Before Node.js 18, every project needed a test framework. Mocha, Jest, and AVA each brought their own dependency trees, configuration files, and plugin ecosystems. A small library with a handful of tests could end up with more test dependencies than production dependencies. The built-in runner eliminates that entirely. There is nothing to install, nothing to configure, and nothing to keep updated .
The startup problem. Third-party runners add startup overhead. Jest’s module transform and sandboxing are the most expensive: benchmarks on a 500-test project show Jest taking 4.2 seconds to cold start, while the Node runner takes 1.0 second . For a developer running tests frequently during development, that difference compounds across a day. The Node runner starts faster because there is no framework to load—just the runtime that is already running.
The isolation problem. Each test file runs in a separate child process. This means global state, module caches, and environment variables are isolated between files. A test that mutates globalThis in one file cannot affect a test in another file. This is the same isolation model that Mocha and Jest provide, but it is built into the runner’s execution model rather than being a configuration option .
The standards problem. The runner outputs TAP (Test Anything Protocol) by default when stdout is not a TTY, and a human-readable spec format when it is . TAP is a standard format that any TAP consumer can parse, which means the output integrates with CI systems without custom configuration. The --test-reporter flag allows switching to junit for JUnit XML or lcov for coverage output .
a. Writing tests and running them
A test file imports test from node:test and assert from node:assert. The test function receives a context object (t) that provides subtests, skipping, and other utilities .
import { test } from 'node:test';
import assert from 'node:assert';
test('synchronous passing test', (t) => {
assert.strictEqual(1, 1);
});
test('asynchronous passing test', async (t) => {
const result = await Promise.resolve(42);
assert.strictEqual(result, 42);
});
test('callback passing test', (t, done) => {
setImmediate(done);
});
The three styles map to how the test function behaves. A synchronous function passes if it does not throw. A promise-returning function passes if the promise fulfills and fails if it rejects. A callback-style function passes if done() is called with no argument or a falsy first argument . The runner detects the style by inspecting the function’s signature and return value.
Subtests create a hierarchical structure. The parent test’s context object has a test method that registers a subtest. The parent must await the subtest; otherwise, the subtest is cancelled when the parent finishes and treated as a failure .
test('top level test', async (t) => {
await t.test('subtest 1', (t) => {
assert.strictEqual(1, 1);
});
await t.test('subtest 2', (t) => {
assert.strictEqual(2, 2);
});
});
The describe() and it() aliases provide the BDD-style syntax that developers coming from Mocha expect. describe is an alias for suite, and it is an alias for test. They are imported from the same node:test module .
Running the tests is a single command:
node --test
The runner discovers files matching the default patterns: **/*.test.{js,cjs,mjs}, **/*-test.{js,cjs,mjs}, **/*_test.{js,cjs,mjs}, **/test-*.{js,cjs,mjs}, and **/test/**/*.{js,cjs,mjs} . Specific files or glob patterns can be passed as arguments:
node --test test/unit/*.test.js
node --test "**/*.test.js" "**/*.spec.js"
b. The assertion library
The node:assert module provides the comparison functions. The two most important are strictEqual and deepStrictEqual. strictEqual uses Object.is semantics and is the recommended replacement for the legacy assert.equal, which uses loose equality (==) and is deprecated for most purposes .
import assert from 'node:assert';
// Strict equality (recommended)
assert.strictEqual(1 + 1, 2);
// Deep equality for objects and arrays
assert.deepStrictEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } });
// Boolean assertion
assert.ok(value); // passes if value is truthy
The error assertions are the other essential category. assert.throws verifies that a function throws, and assert.rejects verifies that an async function or promise rejects. Both accept an optional error matcher—a class, a regular expression, or a validation function—that checks the type or message of the thrown error .
assert.throws(
() => { throw new Error('expected error'); },
/expected error/
);
await assert.rejects(
async () => { throw new Error('async error'); },
{ message: 'async error' }
);
The pattern-matching assertions, assert.match and assert.doesNotMatch, verify that a string matches or does not match a regular expression. They are useful for testing error messages and formatted output .
The assert.fail function throws an AssertionError unconditionally. It is used in tests where reaching a certain code path should never happen, such as the default case in a switch statement that is supposed to be exhaustive.
c. Skipping, TODO, and focusing tests
The test options object controls the test’s behavior. The skip option skips the test entirely. The todo option marks the test as pending—it runs, but a failure does not fail the suite. The only option focuses the test when the --test-only flag is passed .
test('skipped test', { skip: true }, (t) => {
// never runs
});
test('todo test', { todo: 'not implemented yet' }, (t) => {
throw new Error('this does not fail the suite');
});
test('focused test', { only: true }, (t) => {
assert.strictEqual(1, 1);
});
The skip and todo options can also be called at runtime through the context object. t.skip() skips the test at the point of the call. t.todo() marks it as pending. This is useful when the skip condition depends on something computed during the test .
The --test-name-pattern flag filters tests by name using a regular expression. This is the Node runner’s equivalent of Mocha’s grep:
node --test --test-name-pattern="database"
Only tests whose names match the pattern are executed. The --test-skip-pattern flag does the inverse, skipping tests that match .
Complete Example Session
The following session builds a small module with a test suite covering synchronous tests, async tests, error assertions, subtests, and a skipped test.
// ============================================
// PART 1: THE MODULE UNDER TEST
// ============================================
// math.js
export function add(a, b) {
return a + b;
}
export function divide(a, b) {
if (b === 0) {
throw new Error('Division by zero');
}
return a / b;
}
export async function fetchValue() {
return 42;
}
// ============================================
// PART 2: THE TEST FILE
// ============================================
// math.test.js
import { test, describe, it } from 'node:test';
import assert from 'node:assert';
import { add, divide, fetchValue } from './math.js';
// ============================================
// PART 3: SYNCHRONOUS TESTS
// ============================================
test('add returns the sum', (t) => {
assert.strictEqual(add(2, 3), 5);
assert.strictEqual(add(-1, 1), 0);
});
// ============================================
// PART 4: ASYNC TESTS
// ============================================
test('fetchValue resolves to 42', async (t) => {
const value = await fetchValue();
assert.strictEqual(value, 42);
});
// ============================================
// PART 5: ERROR ASSERTIONS
// ============================================
test('divide throws on zero', (t) => {
assert.throws(
() => divide(1, 0),
/Division by zero/
);
});
// ============================================
// PART 6: SUBTESTS
// ============================================
test('math operations', async (t) => {
await t.test('add works', (t) => {
assert.strictEqual(add(1, 1), 2);
});
await t.test('divide works', (t) => {
assert.strictEqual(divide(10, 2), 5);
});
});
// ============================================
// PART 7: DESCRIBE/IT SYNTAX
// ============================================
describe('Array operations', () => {
it('should add elements', () => {
const arr = [];
arr.push(1);
assert.strictEqual(arr.length, 1);
});
it('should remove elements', () => {
const arr = [1, 2, 3];
arr.pop();
assert.strictEqual(arr.length, 2);
});
});
// ============================================
// PART 8: SKIPPED TEST
// ============================================
test('not yet implemented', { skip: 'waiting on feature' }, (t) => {
assert.fail('should not run');
});
# ============================================
# PART 9: RUN THE TESTS
# ============================================
node --test math.test.js
# Output (spec reporter):
# ▶ add returns the sum
# ✔ add returns the sum (0.5ms)
# ▶ fetchValue resolves to 42
# ✔ fetchValue resolves to 42 (0.3ms)
# ▶ divide throws on zero
# ✔ divide throws on zero (0.2ms)
# ▶ math operations
# ✔ add works
# ✔ divide works
# ▶ Array operations
# ✔ should add elements
# ✔ should remove elements
# ﹣ not yet implemented (skipped: waiting on feature)
# ℹ tests 8
# ℹ pass 7
# ℹ fail 0
# ℹ skipped 1
The nine parts cover the module under test, the test file imports, synchronous tests, async tests, error assertions, subtests, the describe/it syntax, a skipped test, and the run command with expected output. What the session demonstrates is that the built-in runner handles the same test styles as third-party frameworks, with assertions from node:assert and no external dependencies.
Quick Reference
Test Styles
| Style | Pass Condition |
|---|---|
| Synchronous | Function does not throw |
| Promise | Promise fulfills |
| Callback | done() called with falsy argument |
Default Discovery Patterns
| Pattern | Example |
|---|---|
**/*.test.js | math.test.js |
**/*-test.js | math-test.js |
**/*_test.js | math_test.js |
**/test-*.js | test-math.js |
**/test/**/*.js | test/unit/math.js |
Assertion Functions
| Function | Purpose |
|---|---|
assert.strictEqual(a, b) | Strict equality |
assert.deepStrictEqual(a, b) | Deep equality |
assert.ok(value) | Truthy check |
assert.throws(fn, matcher) | Function throws |
assert.rejects(fn, matcher) | Promise rejects |
assert.match(str, regex) | String matches |
assert.doesNotMatch(str, regex) | String does not match |
assert.fail(msg) | Unconditional failure |
Test Options
| Option | Effect |
|---|---|
skip: true | Skip the test |
todo: true | Run but do not fail |
only: true | Focus when --test-only |
timeout: 500 | Per-test timeout |
CLI Flags
| Flag | Purpose |
|---|---|
--test | Run tests |
--test-name-pattern | Filter by name |
--test-skip-pattern | Skip by name |
--test-reporter | Output format |
--test-concurrency | Parallel child processes |
Best Practices
✅ Do This:
assert.strictEqual(actual, expected); // ✅ strict equality
await t.test('subtest', async () => { ... }); // ✅ await subtests
assert.throws(() => fn(), /error message/); // ✅ match error
test('skip', { skip: 'reason' }, () => {}); // ✅ skip with reason
❌ Don’t Do This:
assert.equal(1, '1'); // ❌ loose equality
t.test('subtest', () => {}); // not awaited // ❌ cancelled subtest
// no assertion in test // ❌ passes without testing
assert.strictEqual(obj, { a: 1 }); // ❌ use deepStrictEqual
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Subtest cancelled | Parent finishes before subtest | await t.test(...) |
| Test passes without assertions | No assertion called | Use assert or assert.fail |
| Object comparison fails | Used strictEqual | Use deepStrictEqual |
| Test file not discovered | Wrong naming pattern | Rename to *.test.js |
| Callback test never completes | done() not called | Ensure done() is invoked |
Real-World Examples
1. Basic test
test('adds', (t) => { assert.strictEqual(1 + 1, 2); });
2. Async test
test('fetches', async (t) => { assert.strictEqual(await fetchValue(), 42); });
3. Error assertion
assert.throws(() => divide(1, 0), /Division by zero/);
4. Deep equality
assert.deepStrictEqual(result, { id: 1, name: 'Alice' });
5. Subtests
await t.test('nested', () => { assert.ok(true); });
6. Skip
test('later', { skip: true }, () => {});
7. Describe/it
describe('suite', () => { it('works', () => { assert.ok(true); }); });
8. Run specific file
node --test math.test.js
9. Filter by name
node --test --test-name-pattern="divide"
10. JUnit output
node --test --test-reporter=junit --test-reporter-destination=results.xml
Visual
Test Execution Model
┌──────────────────────────────────────────────┐
│ node --test │
│ │ │
│ ▼ │
│ Discover files matching patterns │
│ │ │
│ ▼ │
│ For each file: │
│ fork child process │
│ │ │
│ ▼ │
│ Run test functions in child │
│ │ │
│ ┌────┴────┐ │
│ │ │ │
│ exit 0 exit != 0 │
│ │ │ │
│ ▼ ▼ │
│ pass fail │
└──────────────────────────────────────────────┘
Test Styles
┌──────────────────────────────────────────────┐
│ SYNCHRONOUS │
│ test('name', (t) => { assert... }); │
│ Pass if no throw │
│ │
│ PROMISE │
│ test('name', async (t) => { ... }); │
│ Pass if promise fulfills │
│ │
│ CALLBACK │
│ test('name', (t, done) => { done(); }); │
│ Pass if done() called without error │
└──────────────────────────────────────────────┘
Assertion Flow
┌──────────────────────────────────────────────┐
│ assert.strictEqual(actual, expected) │
│ │ │
│ ┌────┴────┐ │
│ │ │ │
│ equal not equal │
│ │ │ │
│ ▼ ▼ │
│ return throw AssertionError │
│ │ │
│ ▼ │
│ test fails │
└──────────────────────────────────────────────┘
Subtests
┌──────────────────────────────────────────────┐
│ test('parent', async (t) => { │
│ await t.test('child1', () => {...}); │
│ await t.test('child2', () => {...}); │
│ }); │
│ │
│ Parent waits for all awaited subtests │
│ Un-awaited subtests are cancelled │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Runner | node:test, built into Node.js |
| Stable since | Node.js 20 |
| Run command | node --test |
| Discovery | *.test.js, *-test.js, etc. |
| Assertions | node:assert |
| Equality | strictEqual, deepStrictEqual |
| Error checks | throws, rejects |
| Subtests | t.test(), must await |
| Skip | { skip: true } or t.skip() |
| TODO | { todo: true } |
| Isolation | Each file in separate child process |
Key takeaways:
- The runner is built in. No installation, no configuration, no dependency tree.
node --testdiscovers and runs test files . - Each test file runs in a separate child process. Global state and module caches are isolated between files .
- Three test styles are supported. Synchronous, promise-returning, and callback-based functions are all valid test functions .
assert.strictEqualandassert.deepStrictEqualare the primary assertions. The legacyassert.equaluses loose equality and is deprecated for most uses .- Subtests must be awaited. An un-awaited subtest is cancelled when the parent finishes and treated as a failure .
describeanditare aliases. They provide the BDD syntax for developers coming from Mocha .- Skipping and TODO are built in.
{ skip: true }and{ todo: true }control test behavior without external plugins . - The runner outputs TAP or spec format.
--test-reporterswitches to JUnit XML, LCOV, or a custom reporter .
Remember: The Node.js test runner is not a replacement for Jest in every scenario. It does not have snapshot testing built in (though experimental support exists in newer versions), it does not have JSDOM for browser-like testing, and its assertion library is less expressive than Chai or Jest’s expect . But for Node-only libraries, CLI tools, and services that do not need those features, it eliminates an entire category of dependencies. The workflow is the same as any framework: write a test file, import test and assert, run node --test. The difference is that there is nothing to install, nothing to configure, and nothing to keep updated. The runner is the runtime, and the runtime is already there.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!