Fix: Node.js Test Runner Not Working — node --test, TypeScript, Mocks, Coverage, and Watch Mode
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 0Or the imports break with ESM errors:
SyntaxError: Cannot use import statement outside a module
at file:///app/test/foo.test.jsOr 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 getUserOr 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
.jsnatively. For.ts, you need--experimental-strip-types(Node 22+) or a loader liketsx. - Mocking is per-test by default.
mock.methodandmock.fnare scoped to the test/suite;mock.restoreAll()resets them. Butmock.module(when used) has process-wide scope similar to Bun and Jest. --watchtriggers 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/*.jsOr 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.tsThis 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.tstsx 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/**/*.jsCompile 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-coverageOutput 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.infoCombine multiple reporters:
node --test \
--experimental-test-coverage \
--test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=lcov --test-reporter-destination=coverage/lcov.infoThe 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 --watchWatch 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/**/*.tsPass explicit globs to control the watch set.
Pro Tip: For TS projects, combine watch with tsx:
node --import tsx --test --watchFix 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. Useimport.meta.url:import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url));.fetchglobal is not the Node one. Node shipsfetchsince 18. If a test depends on the Nodefetchand you’ve overridden it elsewhere, it might be the wrong implementation. Useimport { 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-levelnode --testexits non-zero on failure; ensure your script doesn’t swallow it. SIGTERMdoesn’t stop tests. Tests with active handles (sockets, intervals, child processes) keep the process alive. Use--test-force-exitto force exit on completion.process.envmutations persist across tests. They share the process. Snapshot/restore env inbeforeEach/afterEach.- Subtests not showing. Subtests need
t.test(...)from the test callback’starg, not the importedtest. 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 ofspec(uses ANSI/Unicode that some Windows terminals mishandle). - Tests pass locally, fail in CI. Different Node versions. Pin via
.nvmrcorpackage.jsonengines. 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Jest Cannot Transform ES Modules — SyntaxError: Cannot use import statement
How to fix Jest failing with 'Cannot use import statement outside a module' — configuring Babel transforms, using experimental VM modules, migrating to Vitest, and handling ESM-only packages.
Fix: Bun Test Not Working — Module Mocking, DOM Setup, Coverage, and Watch Mode
How to fix Bun test runner issues — mock.module not isolating, happy-dom setup for DOM tests, --coverage missing files, timer mocks, snapshot updates, TypeScript path aliases, and preload files.
Fix: Jest Setup File Not Working — setupFilesAfterFramework Not Running or Globals Not Applied
How to fix Jest setup file issues — setupFilesAfterFramework vs setupFiles, global mocks not applying, @testing-library/jest-dom matchers, module mocking in setup, and TypeScript setup files.
Fix: Jest Async Test Timeout — Exceeded 5000ms or Test Never Resolves
How to fix Jest async test timeouts — missing await, unresolved Promises, done callback misuse, global timeout configuration, fake timers, and async setup/teardown issues.