Node.js node:test Suites and Subtests Guide

The native node:test runner in Node.js provides a robust, zero-dependency way to structure and execute test suites. This article explains how the framework handles test suites and subtests, demonstrating how to group test cases logically using both the programmatic nested test syntax and the traditional BDD (Behavior-Driven Development) describe and it syntax.

Subtests Using Nested Test Functions

The primary way the node:test module handles hierarchy is through nested tests. When a test function is executed, it receives a test context object (commonly named t) as its first argument. You can define subtests by calling the test() method directly on this context object.

When you use this approach, parent tests will wait for all of their subtests to complete before finishing.

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

test('Database Operations Suite', async (t) => {
  // Setup logic for the suite can go here

  await t.test('should successfully write to the database', () => {
    const status = saveToDb({ id: 1, name: 'Alice' });
    assert.strictEqual(status, true);
  });

  await t.test('should fail when writing duplicate keys', () => {
    const status = saveToDb({ id: 1, name: 'Bob' });
    assert.strictEqual(status, false);
  });
});

function saveToDb(user) {
  // Mock database logic
  return user.id !== 1 || user.name === 'Alice';
}

In this structure, the parent test acts as the suite. If any subtest fails, the parent test is marked as failed.

Test Suites Using describe and it

For developers transitioning from frameworks like Mocha or Jest, node:test offers a BDD-style syntax using describe and it blocks. These functions are imported from the node:test module and allow you to declare suites and test cases cleanly.

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

describe('User Service Suite', () => {
  describe('Validation', () => {
    it('should reject usernames shorter than 3 characters', () => {
      assert.throws(() => validateUsername('ab'), /Too short/);
    });

    it('should accept valid usernames', () => {
      assert.doesNotThrow(() => validateUsername('alice'));
    });
  });
});

function validateUsername(username) {
  if (username.length < 3) {
    throw new Error('Too short');
  }
}

The describe block creates a test suite container, and it defines individual test cases. Just like nested test contexts, describe blocks can be nested inside other describe blocks to create multi-layered test hierarchies.

Concurrency and Execution Order

By default, subtests defined within a parent test or describe block run sequentially in the order they are declared. This ensures predictable execution and prevents race conditions when interacting with shared state.

If you want subtests to run concurrently to speed up execution, you can pass the concurrency option. When concurrency is enabled, the runner will execute multiple subtests simultaneously.

test('Concurrent Subtests Suite', { concurrency: true }, async (t) => {
  await t.test('Fast task', async () => {
    assert.ok(true);
  });

  await t.test('Slow task', async () => {
    await new Promise((resolve) => setTimeout(resolve, 100));
    assert.ok(true);
  });
});

Filtering Suites and Subtests

You can selectively run or skip specific suites and subtests using configuration options:

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

describe('Payment Gateway', () => {
  // This entire block will be skipped
  describe.skip('Legacy Integration', () => {
    it('should process transactions', () => {
      // test code
    });
  });

  describe('Stripe Integration', () => {
    it('should process successful payments', () => {
      // test code
    });
  });
});