| |

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

StylePass Condition
SynchronousFunction does not throw
PromisePromise fulfills
Callbackdone() called with falsy argument

Default Discovery Patterns

PatternExample
**/*.test.jsmath.test.js
**/*-test.jsmath-test.js
**/*_test.jsmath_test.js
**/test-*.jstest-math.js
**/test/**/*.jstest/unit/math.js

Assertion Functions

FunctionPurpose
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

OptionEffect
skip: trueSkip the test
todo: trueRun but do not fail
only: trueFocus when --test-only
timeout: 500Per-test timeout

CLI Flags

FlagPurpose
--testRun tests
--test-name-patternFilter by name
--test-skip-patternSkip by name
--test-reporterOutput format
--test-concurrencyParallel 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

PitfallWhy It HappensFix
Subtest cancelledParent finishes before subtestawait t.test(...)
Test passes without assertionsNo assertion calledUse assert or assert.fail
Object comparison failsUsed strictEqualUse deepStrictEqual
Test file not discoveredWrong naming patternRename to *.test.js
Callback test never completesdone() not calledEnsure 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

ItemValue
Runnernode:test, built into Node.js
Stable sinceNode.js 20
Run commandnode --test
Discovery*.test.js, *-test.js, etc.
Assertionsnode:assert
EqualitystrictEqual, deepStrictEqual
Error checksthrows, rejects
Subtestst.test(), must await
Skip{ skip: true } or t.skip()
TODO{ todo: true }
IsolationEach file in separate child process

Key takeaways:

  • The runner is built in. No installation, no configuration, no dependency tree. node --test discovers 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.strictEqual and assert.deepStrictEqual are the primary assertions. The legacy assert.equal uses 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 .
  • describe and it are 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-reporter switches 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!