Skip to content

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

FixDevs ·

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.

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.

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.

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