Skip to content

Fix: Node.js Test Runner Not Working — node --test, TypeScript, Mocks, Coverage, and Watch Mode

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix Node.js built-in test runner errors — node --test not finding files, ESM vs CJS imports, TypeScript with --experimental-strip-types, mock.method isolation, coverage reporting, and watch mode setup.

The Error

You run node --test and it can’t find your tests:

$ node --test
# tests 0
# pass 0
# fail 0

Or the imports break with ESM errors:

SyntaxError: Cannot use import statement outside a module
    at file:///app/test/foo.test.js

Or mock.method from one test leaks into another:

// a.test.js
mock.method(db, "getUser", () => fakeUser);
// b.test.js (runs later)
import { getUser } from "./db.js";
console.log(getUser()); // Returns fakeUser, not the real getUser

Or running TypeScript directly fails:

$ node --test test/foo.test.ts
Unknown file extension ".ts"

Or --watch doesn’t re-run tests when you save:

$ node --test --watch
# Save a file. Nothing re-runs.

Why This Happens

Node’s built-in test runner reached stable in Node 20. It’s intentionally minimal compared to Jest or Vitest:

  • Test discovery scans test/**/* plus **/*.test.* and **/*.spec.* by default. Files outside those patterns aren’t picked up.
  • No transpilation. Node runs .js natively. For .ts, you need --experimental-strip-types (Node 22+) or a loader like tsx.
  • Mocking is per-test by default. mock.method and mock.fn are scoped to the test/suite; mock.restoreAll() resets them. But mock.module (when used) has process-wide scope similar to Bun and Jest.
  • --watch triggers reruns on imported file changes. If a test isn’t actually importing the file you edit (or it’s outside Node’s watch root), the test doesn’t re-run.

A second reason behind “tests pass locally but fail on CI” is the Node version itself. The built-in runner has evolved rapidly. Node 18.7 (July 2022) introduced node:test and node --test behind an experimental flag. Node 18.13 made it stable for the basic surface. Node 19 added the test reporter API and --experimental-test-coverage. Node 20 promoted the core API to stable. Node 22 (April 2024) folded type stripping in (--experimental-strip-types) and graduated coverage. By the time you read this on Node 24, watch mode and module mocks behave differently again. If your team has any developer on Node 18 while CI runs on Node 20 or 22, expect occasional mismatched behaviour around mocks, coverage flags, and reporter output — these are not bugs in your tests.

A third surprise is the way the runner interacts with ESM. Unlike Jest, which uses CJS by default and grafts ESM support on, node --test is ESM-first. import.meta.url is available, require is not, and Jest’s __dirname shim is missing. This catches developers porting Jest suites: tests that rely on __dirname, __filename, or implicit CJS interop suddenly fail with ReferenceError: __dirname is not defined. The fix is to compute them from import.meta.url (shown in “Still Not Working?”) rather than try to coerce the file into CJS — Node’s runner deliberately picks the ESM path.

Fix 1: Match the Default Test File Patterns

Default discovery patterns:

  • test/**/*.{js,mjs,cjs,ts,mts,cts}
  • **/*.test.{js,mjs,cjs,ts,mts,cts}
  • **/*.spec.{js,mjs,cjs,ts,mts,cts}
  • **/test-*.{js,mjs,cjs,ts,mts,cts}

For files outside these patterns, pass them explicitly:

node --test ./custom-tests/*.js

Or use --test-name-pattern to filter by test name:

node --test --test-name-pattern="user creation"

Pro Tip: Stick to one convention across your repo. Mixing __tests__/foo.js, test/foo.js, and foo.test.js is a maintenance burden. The framework default of *.test.js next to source files works for most projects.

Fix 2: Import From node:test and node:assert

The test API lives in node:test. Use the node: prefix to avoid resolver ambiguity:

// test/user.test.js
import { test, describe, it, before, after, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert/strict";

describe("User", () => {
  beforeEach(() => {
    // setup
  });

  it("creates a user", () => {
    const user = { id: 1, name: "Alice" };
    assert.equal(user.name, "Alice");
    assert.deepEqual(user, { id: 1, name: "Alice" });
  });

  it("rejects invalid input", async () => {
    await assert.rejects(
      async () => createUser({ name: "" }),
      /name cannot be empty/,
    );
  });
});

Common Mistake: Using test from node:test and expect from somewhere else (Chai, Jest globals). Node’s runner ships only node:assert. Pick one and stick with it, or install a third-party expect if you want Jest’s matcher style.

For node:assert/strict vs node:assert — the strict variant uses === semantics by default. Always use strict; the lenient form is a footgun.

Fix 3: Run TypeScript Tests

Three options, in increasing maturity:

Option A — Node 22.6+ with --experimental-strip-types:

node --experimental-strip-types --test test/foo.test.ts

This strips type annotations at parse time. It doesn’t perform type checking — just lets your .ts run. Limits: no enum, no namespaces, no decorators (unless you also use --experimental-transform-types).

Option B — tsx (third-party loader):

npm install -D tsx
node --import tsx --test test/foo.test.ts

tsx handles full TypeScript including enums, decorators, JSX. Faster than Babel, no separate build step.

Option C — Compile first:

tsc -p tsconfig.test.json
node --test ./dist-test/**/*.js

Compile separately, lint the JS output. More steps, but predictable behavior and works with any Node version.

Pro Tip: For libraries, prefer Option C — the same JS you ship runs in tests, catching packaging issues. For apps, Option B is most ergonomic.

Fix 4: Mock Functions Cleanly

mock.fn(impl) creates a mock function:

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

test("logger is called once", () => {
  const logger = mock.fn();
  doWork(logger);
  
  assert.equal(logger.mock.callCount(), 1);
  assert.deepEqual(logger.mock.calls[0].arguments, ["work done"]);
});

mock.method(obj, "name", impl) replaces an object’s method:

import { test, mock, after } from "node:test";

test("getUser returns mock", () => {
  mock.method(db, "getUser", () => ({ id: 1, name: "Mock" }));
  
  const user = db.getUser();
  assert.equal(user.name, "Mock");
});

// Restore after the test:
after(() => mock.restoreAll());

The mocks are scoped to the test by default. To reset between tests:

import { test, mock, beforeEach } from "node:test";

beforeEach(() => {
  mock.restoreAll();
});

For module mocking (similar to jest.mock):

import { test, mock } from "node:test";

test("with mocked module", async (t) => {
  t.mock.module("./db.js", {
    namedExports: { getUser: () => ({ id: 1, name: "Mock" }) },
  });
  
  // Imports of "./db.js" inside this test see the mock.
});

t.mock.module(...) is test-scoped — it auto-restores when the test ends. Use this over global mock.module to avoid the leak issues other test runners have.

Note: t.mock.module requires --experimental-test-module-mocks on most Node versions. Check node --help | grep module-mocks for current status.

Fix 5: Coverage With --experimental-test-coverage

Node has built-in coverage:

node --test --experimental-test-coverage

Output includes line, branch, and function coverage in a table at the end of the run. For LCOV output (for Codecov, Coveralls):

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

Combine multiple reporters:

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

The spec reporter prints test progress to stdout; lcov writes coverage to a file. Both run.

Common Mistake: Forgetting --experimental-test-coverage. Without it, the lcov reporter exists but reports no coverage data — just test results.

Fix 6: Watch Mode

node --test --watch

Watch mode re-runs tests when any file in the dependency graph changes. For tests that pull in untracked files (dynamic require/import(), file reads), watch may not catch every change.

Limit which files trigger reruns:

node --test --watch ./src/**/*.ts ./test/**/*.ts

Pass explicit globs to control the watch set.

Pro Tip: For TS projects, combine watch with tsx:

node --import tsx --test --watch

Fix 7: Parallel Tests and Concurrency

By default, test files run in parallel; tests within a file run sequentially. To run tests within a file concurrently:

test("concurrent test", { concurrency: true }, async () => {
  // ...
});

describe("concurrent suite", { concurrency: true }, () => {
  it("a", async () => { /* ... */ });
  it("b", async () => { /* ... */ });  // Runs in parallel with "a"
});

To limit file-level parallelism (useful for resource-constrained CI):

node --test --test-concurrency=2

--test-concurrency=1 makes everything sequential — useful for debugging flaky tests.

Note: Tests that touch shared state (databases, files) shouldn’t run concurrently. Either set concurrency: false (default) or use unique resources per test.

Fix 8: Skipping, Only, and TODO

The runner supports the same skip/only/todo as Jest:

test("normal", () => { /* runs */ });

test.skip("skipped", () => { /* doesn't run */ });

test.todo("not yet implemented");

test.only("only this runs", () => {
  // When any test.only exists, only those run.
});

// Conditional skip:
test("integration test", { skip: !process.env.RUN_INTEGRATION }, async () => {
  // skipped unless RUN_INTEGRATION is set
});

For an entire file: describe.skip, describe.only, describe.todo work the same way.

Common Mistake: Leaving test.only in committed code. CI passes because the one test passes — but every other test is silently skipped. Add a lint rule or pre-commit check to forbid .only in pushed code.

Version History: node:test From 18.7 to 22 and Beyond

The behaviour you see from node --test depends entirely on which Node release introduced (or changed) the flag you’re using. Tracing the history makes that obvious.

Node 18.7 (July 2022) — initial introduction. The node:test module and node --test CLI shipped behind no flag but were marked experimental. Basic test(), describe(), it(), and before/after/beforeEach/afterEach worked. Mocks were not in the initial release — you reached for Sinon or Jest if you needed them.

Node 19 (October 2022) — reporters and TAP+spec. The reporter API arrived: --test-reporter=spec, --test-reporter=tap, --test-reporter=dot. mock was added as an experimental subsystem. Coverage was still missing.

Node 20 (April 2023) — stable surface. node:test graduated to stable for the documented API. mock.fn, mock.method, and mock.restoreAll shipped. --experimental-test-coverage started landing as preview. From this version on, you can safely depend on node --test for production code.

Node 21 / Node 22 (Apr 2024) — coverage stable, type stripping. --experimental-test-coverage graduated to stable (no flag needed on recent Node 22+ patches). --experimental-strip-types arrived in Node 22.6, enabling node --test foo.test.ts without a loader for simple TypeScript (no enums, namespaces, or experimental decorators). Watch mode was hardened. --test-isolation=process was added so each test file runs in its own process — important for global state pollution.

Node 23–24 (2024–2025+) — module mocks and snapshots. t.mock.module(...) graduated from --experimental-test-module-mocks. Snapshot testing landed (t.assert.snapshot(value)) as a stable API. The reporter API grew streaming hooks for IDE integration.

vs Jest

Jest gives you a much larger surface: built-in expect matchers, jest.mock() with automatic module hoisting, snapshot testing, jsdom, transformers, and testEnvironment switching. The cost is configuration (jest.config.ts, transformer setup) and slower test boot — Jest spins up its own VM per worker.

node --test is the opposite tradeoff: zero install, no config file, fast startup, but you bring your own expect-style matcher library (or stick to node:assert/strict) and you write import.meta boilerplate.

vs Vitest

Vitest is closer to node --test in spirit — both target ESM, both have a small core, both run fast. Vitest wins on developer ergonomics: built-in jsdom/happy-dom, browser mode, watch UI, faster mocks, and Vite-native TypeScript via the same transformer your app uses. node --test wins when you want no third-party tooling at all (libraries that need to test against Node’s actual runtime, monorepo tools, security-conscious projects).

vs Mocha

Mocha pioneered describe/it in 2011 but predates ESM, so configuring it for modern TypeScript-ESM stacks is painful. node --test borrowed the syntax (it’s drop-in compatible at the test-body level) and removed the configuration story. Use Mocha if you’re already deep in it; start new projects on node --test or Vitest.

Practical version-pinning rules

  • Enforce a minimum Node version in package.json:
{
  "engines": { "node": ">=20.10.0" }
}
  • Pin the runtime in CI with actions/setup-node@v4 and node-version-file: '.nvmrc'. Drift between developer Node and CI Node is the most common “why does this only fail on CI” cause for node --test.

  • Don’t use --experimental-* flags in committed scripts unless you also commit a .node-version that’s old enough to require the flag. As features graduate, the flag is removed and the script breaks.

Still Not Working?

A few less-obvious failures:

  • __dirname is not defined. Tests run as ESM. Use import.meta.url: import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url));.
  • fetch global is not the Node one. Node ships fetch since 18. If a test depends on the Node fetch and you’ve overridden it elsewhere, it might be the wrong implementation. Use import { fetch } from "undici" for explicit control.
  • Test exit code is 0 even with failures. You used await test(...) and didn’t propagate errors. The top-level node --test exits non-zero on failure; ensure your script doesn’t swallow it.
  • SIGTERM doesn’t stop tests. Tests with active handles (sockets, intervals, child processes) keep the process alive. Use --test-force-exit to force exit on completion.
  • process.env mutations persist across tests. They share the process. Snapshot/restore env in beforeEach/afterEach.
  • Subtests not showing. Subtests need t.test(...) from the test callback’s t arg, not the imported test. The imported one defines top-level tests; the callback’s defines subtests.
  • Reporter output mangled on Windows terminals. Use --test-reporter=tap (plain text) instead of spec (uses ANSI/Unicode that some Windows terminals mishandle).
  • Tests pass locally, fail in CI. Different Node versions. Pin via .nvmrc or package.json engines. The runner improved fast in 20.x and 22.x; behavior differs.
  • Snapshot tests aren’t writing files. Node’s built-in snapshots write to __snapshots__/<file>.snapshot next to the test, but only when the file is writable. CI containers with read-only mounts silently skip the write and the test passes; locally it fails on the next run because the snapshot didn’t exist. Always run snapshots locally before committing.
  • Coverage shows 0% for transpiled TypeScript. When you use tsx or --experimental-strip-types, source maps must be present for V8 to attribute coverage to your .ts source. Use --enable-source-maps (or set NODE_OPTIONS=--enable-source-maps) alongside --experimental-test-coverage. Without source maps, every covered line maps back to the stripped output, which V8 reports as the original being uncovered.
  • AbortSignal from tests doesn’t cancel children. Tests receive a signal on the t context (t.signal). Long-running async work needs to observe the signal explicitly — the runner cannot kill a setTimeout chain or an open socket on its own.

For related Node.js testing and runtime issues, see Bun test not working, Jest async test timeout, Jest mock not working, and Vitest setup not working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles