Skip to main content

Overview

Node.js includes a stable, built-in test runner available through the node:test module. It provides a modern testing experience without requiring external dependencies.
The test runner has been stable since Node.js v20.0.0.

Getting Started

Import the Test Module

import test from 'node:test';
import assert from 'node:assert';

Your First Test

import test from 'node:test';
import assert from 'node:assert';

test('synchronous passing test', (t) => {
  // This test passes because it does not throw an exception
  assert.strictEqual(1, 1);
});

test('asynchronous passing test', async (t) => {
  // This test passes because the Promise fulfills
  assert.strictEqual(1, 1);
});

Running Tests

Run tests using the Node.js test runner:
node --test
This automatically discovers and runs all test files matching these patterns:
  • **/*.test.{js,cjs,mjs}
  • **/*-test.{js,cjs,mjs}
  • **/*_test.{js,cjs,mjs}
  • **/test-*.{js,cjs,mjs}
  • **/test.{js,cjs,mjs}
  • **/test/**/*.{js,cjs,mjs}

Run Specific Tests

# Run a single test file
node --test path/to/test.js

# Run multiple test files
node --test test/unit/*.test.js

# Run with glob pattern
node --test "test/**/*.test.js"

Writing Tests

Test Styles

Node.js supports three test styles:
test('synchronous test', (t) => {
  assert.strictEqual(1 + 1, 2);
});

describe() and it() Syntax

Use familiar BDD-style syntax:
import { describe, it } from 'node:test';
import assert from 'node:assert';

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);
  });

  describe('nested suite', () => {
    it('should work in nested suites', () => {
      assert.strictEqual(true, true);
    });
  });
});

Subtests

Create hierarchical test structures:
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);
  });
});
Always await subtests to ensure they complete. Subtests that are still running when their parent finishes are cancelled and treated as failures.

Assertions

Use the built-in assert module:

Equality Assertions

import assert from 'node:assert';

// Strict equality (recommended)
assert.strictEqual(actual, expected);
assert.notStrictEqual(actual, expected);

// Deep equality for objects
assert.deepStrictEqual({ a: 1 }, { a: 1 });
assert.notDeepStrictEqual({ a: 1 }, { a: 2 });

Boolean Assertions

assert.ok(value); // Truthy
assert.strictEqual(value, true); // Exactly true
assert.strictEqual(value, false); // Exactly false

Error Assertions

// Assert function throws
assert.throws(
  () => {
    throw new Error('expected error');
  },
  Error
);

// Assert async function rejects
await assert.rejects(
  async () => {
    throw new Error('expected error');
  },
  Error
);

// Assert function doesn't throw
assert.doesNotThrow(() => {
  // code that should not throw
});

Pattern Matching

// Match string patterns
assert.match('hello world', /world/);
assert.doesNotMatch('hello', /goodbye/);

Test Hooks

Run setup and teardown code:

beforeEach and afterEach

import { describe, it, beforeEach, afterEach } from 'node:test';

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

  beforeEach(async () => {
    db = await connectToDatabase();
  });

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

  it('should insert record', async () => {
    await db.insert({ name: 'test' });
    const count = await db.count();
    assert.strictEqual(count, 1);
  });
});

before and after

import { describe, it, before, after } from 'node:test';

describe('Suite setup', () => {
  before(async () => {
    // Runs once before all tests in this suite
    await setupTestEnvironment();
  });

  after(async () => {
    // Runs once after all tests in this suite
    await teardownTestEnvironment();
  });

  it('test 1', () => { /* ... */ });
  it('test 2', () => { /* ... */ });
});

Skipping Tests

Skip Individual Tests

// Using skip option
test('skip this test', { skip: true }, (t) => {
  // This code is never executed
});

// With message
test('skip with message', { skip: 'Not implemented yet' }, (t) => {
  // This code is never executed
});

// Using skip() method
test('conditional skip', (t) => {
  if (process.platform === 'win32') {
    t.skip('Not supported on Windows');
    return;
  }
  // Test continues on other platforms
});

Skip Suites

describe.skip('Skip entire suite', () => {
  it('test 1', () => { /* never runs */ });
  it('test 2', () => { /* never runs */ });
});

TODO Tests

Mark tests as incomplete or flaky:
test('todo test', { todo: true }, (t) => {
  // This executes but doesn't fail the test suite
  throw new Error('this does not fail the test');
});

test('todo with message', { todo: 'Feature not implemented' }, (t) => {
  // Test runs but is marked as TODO
});

it.todo('implement this feature');
TODO tests are executed but not treated as failures, and don’t affect the exit code.

Test Filtering and Selection

Run Only Specific Tests

test.only('this test runs', () => {
  assert.ok(true);
});

test('this test is skipped', () => {
  assert.ok(true);
});

it.only('only this test runs', () => {
  assert.ok(true);
});

Filter by Name Pattern

# Run tests matching pattern
node --test --test-name-pattern="API"

# Run tests with regex
node --test --test-name-pattern="/user.*test/"

Test Coverage

Generate code coverage reports:
# Run tests with coverage
node --test --experimental-test-coverage

# Save coverage report
node --test --experimental-test-coverage > coverage.txt

Parallel Execution

Tests run in parallel by default for better performance:
# Run tests in parallel (default)
node --test

# Force sequential execution
node --test --test-concurrency=1

Test Reporters

Choose different output formats:
# TAP output (default)
node --test

# Spec reporter
node --test --test-reporter=spec

# Dot reporter
node --test --test-reporter=dot

# JSON output
node --test --test-reporter=json

Mocking and Spying

Use the built-in mock module:
import { mock } from 'node:test';

test('mocking functions', (t) => {
  const mockFn = mock.fn();
  
  mockFn('arg1', 'arg2');
  mockFn('arg3');

  assert.strictEqual(mockFn.mock.calls.length, 2);
  assert.deepStrictEqual(mockFn.mock.calls[0].arguments, ['arg1', 'arg2']);
  assert.deepStrictEqual(mockFn.mock.calls[1].arguments, ['arg3']);
});

test('mocking with return values', (t) => {
  const mockFn = mock.fn(() => 'mocked value');
  
  const result = mockFn();
  assert.strictEqual(result, 'mocked value');
});

Best Practices

Structure tests to mirror your source code:
src/
  users/
    user.js
test/
  users/
    user.test.js
// Good
it('should return 404 when user is not found', () => {});

// Avoid
it('test 1', () => {});
Keep tests focused and easy to debug:
// Good
it('should create user', async () => {
  const user = await createUser({ name: 'Test' });
  assert.ok(user.id);
});

it('should set user name', async () => {
  const user = await createUser({ name: 'Test' });
  assert.strictEqual(user.name, 'Test');
});
Always clean up resources:
afterEach(async () => {
  await database.clear();
  await cache.flush();
});

Rerunning Failed Tests

Persist test state to rerun only failures:
# First run - creates state file
node --test --test-rerun-failures=./test-state.json

# Subsequent runs - only failed tests
node --test --test-rerun-failures=./test-state.json

Watch Mode

Automatically rerun tests when files change:
node --test --watch

Next Steps

Debugging

Debug failing tests

Performance

Profile test performance