Node.js Built-in Test Runner: Write Tests Without Any Dependencies

Node.js Built-in Test Runner: Write Tests Without Any Dependencies

Node.js has shipped a built-in test runner since v18 (stable in v20). It requires zero dependencies — no Jest, no Mocha, nothing to install. The API is familiar if you've used any other test framework.

Basic Test Structure

import { test, describe, it } from 'node:test';
import assert from 'node:assert/strict';

test('adds two numbers', () => {
  assert.equal(1 + 1, 2);
});

describe('string utilities', () => {
  it('converts to uppercase', () => {
    assert.equal('hello'.toUpperCase(), 'HELLO');
  });

  it('trims whitespace', () => {
    assert.equal('  hello  '.trim(), 'hello');
  });
});

Run with:

node --test
<span class="hljs-comment"># or a specific file
node --<span class="hljs-built_in">test src/utils.test.js

Assertions

The test runner uses Node's built-in assert module:

import assert from 'node:assert/strict';

// Equality
assert.equal(actual, expected);          // ==
assert.strictEqual(actual, expected);    // ===
assert.deepEqual(obj1, obj2);            // deep equality
assert.deepStrictEqual(obj1, obj2);      // deep strict equality

// Truthiness
assert.ok(value);                        // truthy
assert.ok(!value);                       // falsy (or use assert.equal(value, false))

// Errors
assert.throws(() => badFn(), TypeError); // throws specific error type
assert.rejects(asyncFn(), /message/);   // async throws

// Negation
assert.notEqual(a, b);
assert.notDeepStrictEqual(obj1, obj2);

Async Tests

import { test } from 'node:test';
import assert from 'node:assert/strict';

test('fetches user data', async () => {
  const user = await fetchUser(1);
  assert.equal(user.id, 1);
  assert.ok(user.name);
});

test('rejects on invalid id', async () => {
  await assert.rejects(
    () => fetchUser(-1),
    { message: 'Invalid user ID' }
  );
});

Subtests

Nest tests with t.test() for organization:

import { test } from 'node:test';
import assert from 'node:assert/strict';

test('user service', async (t) => {
  await t.test('creates a user', async () => {
    const user = await createUser({ name: 'Alice' });
    assert.ok(user.id);
    assert.equal(user.name, 'Alice');
  });

  await t.test('throws on duplicate email', async () => {
    await createUser({ email: 'alice@example.com' });
    await assert.rejects(
      () => createUser({ email: 'alice@example.com' }),
      { code: 'DUPLICATE_EMAIL' }
    );
  });
});

Before/After Hooks

import { describe, it, before, after, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';

describe('database tests', () => {
  let db;

  before(async () => {
    db = await connectDB(':memory:');
    await db.migrate();
  });

  after(async () => {
    await db.close();
  });

  beforeEach(async () => {
    await db.seed();
  });

  afterEach(async () => {
    await db.truncate();
  });

  it('finds users by email', async () => {
    const user = await db.users.findByEmail('alice@example.com');
    assert.equal(user.name, 'Alice');
  });
});

Skipping and Only

import { test } from 'node:test';

// Skip a test
test.skip('not yet implemented', () => {
  // ...
});

// Mark as todo
test.todo('add payment tests');

// Run only this test (useful during development)
test.only('focused test', () => {
  // only this runs when --test-only flag is passed
});

Run with node --test --test-only to execute only tests marked with .only.

Mocking

The built-in test runner includes a mock module:

import { test, mock } from 'node:test';
import assert from 'node:assert/strict';

test('calls the logger on error', () => {
  const logger = { error: mock.fn() };

  handleError(new Error('boom'), logger);

  assert.equal(logger.error.mock.calls.length, 1);
  assert.equal(logger.error.mock.calls[0].arguments[0], 'boom');
});

test('mocks a module function', () => {
  // Mock Date.now for deterministic tests
  const dateMock = mock.method(Date, 'now', () => 1000000000000);
  
  assert.equal(getTimestamp(), 1000000000000);
  
  dateMock.mock.restore();
});

Reporters and Output

# TAP output (default)
node --<span class="hljs-built_in">test

<span class="hljs-comment"># Spec-style output
node --<span class="hljs-built_in">test --test-reporter=spec

<span class="hljs-comment"># JUnit XML (for CI)
node --<span class="hljs-built_in">test --test-reporter=junit --test-reporter-destination=test-results.xml

<span class="hljs-comment"># Multiple reporters
node --<span class="hljs-built_in">test \
  --test-reporter=spec --test-reporter-destination=stdout \
  --test-reporter=junit --test-reporter-destination=results.xml

Running Tests in CI

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      - run: npm ci
      - run: node --test --test-reporter=spec

Glob Patterns

# Test all files matching a pattern
node --<span class="hljs-built_in">test <span class="hljs-string">'src/**/*.test.js'

<span class="hljs-comment"># Multiple patterns
node --<span class="hljs-built_in">test <span class="hljs-string">'src/**/*.test.js' <span class="hljs-string">'tests/**/*.spec.js'

When to Use the Built-in Runner

Use node:test when:

  • You want zero test dependencies
  • You're building a library or CLI tool
  • You're on Node 20+ and want the simplest setup

Stick with Jest/Vitest when:

  • You need snapshot testing
  • You need module mocking with jest.mock() / automatic mock hoisting
  • Your team already has deep Jest knowledge

Key Takeaways

  • node:test is stable since Node 20 — no install, no config
  • Use node:assert/strict for assertions
  • Hooks (before, after, beforeEach, afterEach) work the same as in other frameworks
  • Built-in mocking handles function spies and method replacement
  • Multiple built-in reporters including spec and JUnit for CI

Read more