node --test: The Test Runner You Already Have Installed
DEV Community

node --test: The Test Runner You Already Have Installed

node --test: The Test Runner You Already Have Installed

Every new Node library starts the same way. You install a test framework, a config file, and a transitive dependency tree that dwarfs the thing you're actually testing. Node has shipped a test runner since v18. It went stable in v20. Most of us kept reaching for the npm install anyway, mostly out of habit, partly because the early version really was thin. That's no longer a fair read of it.

The 2026 Version Has Come a Long Way

The 2026 version has mocking, fake timers, watch mode, coverage output, global setup hooks, and it runs your .ts files without a build step. For a backend library, it's frequently enough.

What You Get for Zero Dependencies

Run node --test in a project with no arguments and it walks the tree looking for files matching a fixed set of patterns:

  • **/*.test.js
  • **/*-test.js
  • **/*_test.js
  • **/test-*.js
  • **/test.js
  • Anything under a **/test/ directory
    The .cjs and .mjs variants are included too. So are the TypeScript equivalents ( .ts, .cts, .mts), unless you pass --no-strip-types.

Writing and Running Tests

You can write this and run it directly:

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { parseDuration } from "./duration.ts";

describe("parseDuration", () => {
  it("handles compound units", () => {
    assert.equal(parseDuration("1h30m"), 540000);
  });
  it("throws on garbage", () => {
    assert.throws(() => parseDuration("soon"), /invalid duration/);
  });
});

No ts-node, no tsx, no build step. Node strips the type annotations and runs the result.

The Important Caveat

Node strips the type annotations and runs the result. The important caveat: it strips, it does not check. You still need tsc --noEmit in CI if you want type errors to fail the build.

Isolation and Subtests

By default, each test file runs in its own child process, which gives you real isolation between files without any config. The programmatic run() API exposes this as isolation: 'process' | 'none' if you need to flip it.

The Subtest Gotcha

Tests created inside a bare test() do not wait for their subtests. Suites do.

Correcting the Subtest Gotcha

test("user flow", async (t) => {
  await t.test("creates the user", async () => {
    await createUser();
  });
});

Describe/It is an Alias Pair

describe() and it() are an alias pair for suite() and test(), so picking the suite style sidesteps the problem entirely.

Mocking and Fake Timers

Mocking and fake timers are already there. mock.fn() gives you a spy with call metadata, and t.mock.method() patches an object method and auto-restores it when the test ends, which is the behavior you want and rarely get for free.

Example: Retries on Failure

test("retries on failure", async (t) => {
  t.mock.timers.enable({ apis: ["setTimeout"] });
  const fetchSpy = t.mock.method(client, "fetch");
  fetchSpy.mock.mockImplementationOnce(() => Promise.reject(new Error("503")));
  const result = withRetry(() => client.fetch("/health"));
  t.mock.timers.tick(1000);
  await result;
  assert.equal(fetchSpy.mock.callCount(), 2);
});

One Sharp Edge on Timers

Destructured imports like import { setTimeout } from 'node:timers' are not mockable. Reference the timer functions off the global or the module namespace and it works.

Where It Still Isn't

Vitest coverage is real but still behind --experimental-test-coverage. It works, and the lcov reporter plugs straight into Codecov or SonarQube:

node --test --experimental-test-coverage \
  --test-reporter=lcov \
  --test-reporter-destination=lcov.info

Note that the lcov reporter emits no human-readable results, so pair it with a second reporter in CI.

Global Setup and Teardown

Global setup and teardown landed in v24 via --test-global-setup <path>, pointing at a module that exports globalSetup and globalTeardown functions, but it's still marked early development.

--watch is Experimental

And there's no JSDOM, no browser mode, no snapshot ecosystem to speak of, no plugin API.

The Honest Heuristic

For a server-side library, a CLI, or anything where the test story is "call a function, assert on the result," node --test is probably enough, and every dependency you don't add is one you don't have to audit later. Start with the built-in runner and let the project tell you when it's outgrown it.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.