What you'll learn

By the end of this tutorial you'll be able to write a complete test suite for a Node.js project using only the built-in node:test and node:assert modules. You'll know how to structure tests with describe and it, write async tests that correctly wait for Promises, mock functions to isolate units under test, run tests from the command line with filtering and watch mode, and read the output clearly enough to know exactly what failed and why.

No Jest, no Mocha, no Vitest. node:test ships with Node.js 18 and above — it's already on your machine.

Who this is for

  • Node.js developers who want to add tests without adding framework dependencies
  • Anyone who's tested with Jest and wants to understand what Node.js provides natively
  • Developers starting a new project who want a minimal, fast test setup from day one

You can skip this if your project is already on a framework like Jest or Vitest and you have no reason to migrate. The concepts are very similar, but the API surface differs enough that mixing them in the same codebase would be confusing. Come back if you're starting fresh or want to understand what you'd get from zero dependencies.

What is node:test?

It's the test runner built into Node.js — no installation, no configuration files required. You write test files, run them with the node CLI, and get pass/fail output with a TAP or human-readable reporter.

Plain English: it's Node.js saying "you don't need to install Jest to run tests." Write test files, pass them to node --test, get results. That's it.

Simple idea: think of it like fs or http — a module that ships with Node.js and covers a real need. You import it, you use it, no configuration files required.

Prerequisites

  • Node.js 20 or higher — node:test exists from 18 but is stable and feature-complete in 20
  • Comfortable with async/await
  • A module or function to test — the examples use a small utility module

Setup from zero

Step 1 — Check your Node version

node --version

Must be 18 or above. Node 20 is recommended — several features (mock timers, improved async support) were added after 18.

Step 2 — Create a module to test

// math.js
function add(a, b) {
  if (typeof a !== "number" || typeof b !== "number") {
    throw new TypeError("Both arguments must be numbers");
  }
  return a + b;
}

async function fetchUser(id, httpClient) {
  const response = await httpClient.get(`/users/${id}`);
  if (!response.ok) throw new Error("User not found");
  return response.json();
}

module.exports = { add, fetchUser };

Step 3 — Write your first test file

// math.test.js
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { add } = require("./math");

describe("add()", () => {
  it("returns the sum of two numbers", () => {
    assert.equal(add(2, 3), 5);
  });

  it("throws TypeError when arguments are not numbers", () => {
    assert.throws(() => add("a", 2), TypeError);
  });
});

Run it:

node --test math.test.js

You'll see pass/fail output in the terminal. Each test reports individually.

> Little tip: import from node:assert/strict rather than node:assert. The strict variant uses strict equality (===) by default. The non-strict version uses loose equality (==) which can hide bugs — assert.equal(0, false) passes in non-strict mode. Strict mode catches those.

The mental model

node:test follows the same structure as Jest, Mocha, and every other test framework you've likely encountered:

describe groups related tests. Use it to name the unit being tested — a function, a class, a module. Describe blocks can be nested.

it (or test) defines one test case. It passes if the function returns without throwing, fails if it throws.

Assertions check conditions. If an assertion fails, it throws an AssertionError with a clear message. That's how it knows a test failed.

Hooks (before, after, beforeEach, afterEach) run setup and teardown code around tests. beforeEach is the most common — use it to reset shared state between tests.

The key mental shift compared to writing application code: tests are documentation. The describe/it names should read like sentences that describe the expected behavior. describe("add()") → it("returns the sum of two numbers") is a specification, not just a test.

Key terms

Test runner — the program that discovers, runs, and reports on test files. node --test is the test runner for node:test.

Assertion — a check that a value equals, throws, or matches an expected condition. assert.equal, assert.throws, assert.rejects, assert.deepEqual.

Mock — a substitute for a real function or module that records how it was called and returns a controlled value. Used to isolate the unit under test from dependencies.

Test isolation — the property that one test's state doesn't affect another. Achieved with beforeEach hooks that reset shared state.

TAP (Test Anything Protocol) — the default output format of node:test. Lines start with ok or not ok. Use the --test-reporter=spec flag for a human-readable alternative.

Watch modenode --test --watch re-runs tests automatically when a file changes. Useful during development.

Coverage — measuring what percentage of your code is executed by tests. node --test --experimental-test-coverage generates a coverage report.

Step-by-step

Testing async functions

// math.test.js
const { describe, it, mock } = require("node:test");
const assert = require("node:assert/strict");
const { fetchUser } = require("./math");

describe("fetchUser()", () => {
  it("returns a user when the HTTP call succeeds", async () => {
    const fakeUser = { id: 1, name: "Alex" };
    const mockClient = {
      get: mock.fn(async () => ({
        ok: true,
        json: async () => fakeUser,
      })),
    };

    const result = await fetchUser(1, mockClient);

    assert.deepEqual(result, fakeUser);
    assert.equal(mockClient.get.mock.calls.length, 1);
    assert.equal(mockClient.get.mock.calls[0].arguments[0], "/users/1");
  });

  it("throws when the HTTP call fails", async () => {
    const mockClient = {
      get: mock.fn(async () => ({ ok: false })),
    };

    await assert.rejects(fetchUser(1, mockClient), { message: "User not found" });
  });
});

Async tests just use async on the callback. node:test awaits the Promise automatically — if it rejects, the test fails.

Using beforeEach to isolate state

const { describe, it, beforeEach } = require("node:test");
const assert = require("node:assert/strict");

describe("counter module", () => {
  let counter;

  beforeEach(() => {
    counter = { value: 0 };
  });

  it("increments from zero", () => {
    counter.value += 1;
    assert.equal(counter.value, 1);
  });

  it("still starts from zero", () => {
    assert.equal(counter.value, 0); // reset by beforeEach
  });
});

Without beforeEach, the second test would see value: 1 left over from the first test. Tests that share state are tests that can fail depending on order — a common source of confusing failures.

Working examples

Run tests with filtering and watch mode

# Run all test files in the project
node --test

# Run only tests matching a pattern
node --test --test-name-pattern="fetchUser"

# Watch mode — re-run on file changes
node --test --watch

# Human-readable reporter
node --test --test-reporter=spec

# Coverage report (experimental)
node --test --experimental-test-coverage

Mocking a module import

const { describe, it, mock } = require("node:test");
const assert = require("node:assert/strict");

describe("mock.fn() basics", () => {
  it("records calls and arguments", () => {
    const fn = mock.fn((x) => x * 2);

    assert.equal(fn(5), 10);
    assert.equal(fn.mock.calls.length, 1);
    assert.deepEqual(fn.mock.calls[0].arguments, [5]);
  });

  it("can override the return value", () => {
    const fn = mock.fn(() => "original");
    fn.mock.mockImplementationOnce(() => "override");

    assert.equal(fn(), "override");
    assert.equal(fn(), "original"); // back to default
  });
});

> Little tip: call mock.restore() in an afterEach or after hook if you're mocking global objects or methods on imported modules. Mocks that persist between test files can cause other tests to pass or fail based on state they didn't set up, which produces the worst kind of intermittent failures.

Patterns

Test one thing per it block — a test that asserts five unrelated things is hard to read and gives ambiguous output when it fails. Each it should verify one behavior: one input/output pair, one error case, one state change.

Name tests as specifications — write it("returns null when the list is empty"), not it("test 1") or it("handles edge case"). A failing test name should tell you exactly what the expected behavior is.

Arrange-Act-Assert — structure the body of each test in three sections: set up the preconditions (Arrange), run the function (Act), check the result (Assert). This structure makes tests readable without comments.

Common mistakes

Forgetting await on assert.rejectsassert.rejects returns a Promise. If you don't await it, the assertion runs after the test finishes and uncaught rejections appear as warnings unconnected to the failing test. Always await assert.rejects(...).

Sharing mutable state between tests — a variable defined outside describe and mutated inside it blocks will carry state from one test to the next. Use beforeEach to reset any state that tests modify.

Testing implementation details — tests that check which internal functions were called are fragile: they break whenever you refactor, even when the observable behavior is unchanged. Test what a function returns and what side effects it produces, not how it produces them.

Troubleshooting

Tests pass individually but fail when run together — shared mutable state or shared mocks. Find the state that's being mutated across tests and reset it in beforeEach. Call mock.restore() in afterEach if you're mocking module-level things.

assert.deepEqual fails on objects that look identical — check for hidden properties or prototype differences. If two objects look the same but have different prototypes (one is a plain object, one is a class instance), deepEqual in strict mode will reject them. Use assert.deepEqual from node:assert (non-strict) or compare individual properties.

Tests hang and never finish — an open handle is keeping the event loop alive. Common causes: a setTimeout that wasn't cleared, a database connection that wasn't closed, or a server that wasn't shut down after the tests. Use --exit if you need to force-exit after tests complete, or identify and close the handle properly.

Checklist

  • [ ] Test files end in .test.js, .test.ts, or .spec.js so node --test discovers them automatically
  • [ ] All async callbacks in it blocks are properly awaited by the runner (they are, automatically)
  • [ ] assert.rejects calls are awaited with await
  • [ ] Shared state is reset in beforeEach hooks
  • [ ] Mocks are restored after tests with mock.restore() or afterEach
  • [ ] At least one test for the happy path and one for each error case per function

Practice task

Write a test suite for a simple UserService module with two functions: createUser(email, password) that validates the inputs and hashes the password, and getUser(id, db) that fetches a user from a database client. Use mock.fn() to mock the database client. Test the happy path for both functions, the validation error for createUser (empty email, short password), and the not-found case for getUser. Run with node --test --test-reporter=spec and confirm every test is named clearly enough that the output reads like a specification.

FAQ

Should I use node:test or Jest?

For new Node.js projects without a React frontend, node:test is a reasonable default — no dependencies, fast startup, good async support. If you're in a monorepo with a React app that already uses Jest, stay on Jest for consistency. node:test is still catching up on features like snapshot testing and module-level mocking. For most backend-only test suites, it's sufficient today.

Does node:test support TypeScript?

Not natively — you need to compile TypeScript first or use a loader like tsx or ts-node. With tsx: node --import tsx --test **/*.test.ts. TypeScript support in node:test is getting better with each Node.js release.

How do I test Express routes with node:test?

Use a tool like supertest to make HTTP requests to your Express app in tests, or extract the route handler logic into separate, testable functions that don't depend on the HTTP layer. Testing the business logic in isolation is faster and more reliable than testing through full HTTP.

What to learn next

After node:test basics: integration testing patterns (spinning up a real database and running tests against it), test coverage analysis (--experimental-test-coverage), and CI configuration — running node --test on every pull request with GitHub Actions takes about five minutes to set up.

  • Node.js async patterns — writing async code that's testable by design
  • Node.js error handling — testing error paths and custom error classes
  • Node.js streams intro — testing Transform streams with node:test

Takeaways

node:test ships with Node.js 18 and above — no installation needed. Structure tests with describe and it, assert with node:assert/strict, and mock with mock.fn(). Use beforeEach to reset shared state. Async tests use async callbacks and await assert.rejects(). Run with node --test and --test-reporter=spec for readable output.

If you remember only one thing: always await calls to assert.rejects(). Missing the await is the single most common mistake when writing async tests with node:test — the assertion returns a Promise that resolves after the test function returns, so the failure never registers and the test passes incorrectly.