Fix: Node.js Test Runner Not Working — node --test, TypeScript, Mocks, Coverage, and Watch Mode
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 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.
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/*.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.
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@v4andnode-version-file: '.nvmrc'. Drift between developer Node and CI Node is the most common “why does this only fail on CI” cause fornode --test.Don’t use
--experimental-*flags in committed scripts unless you also commit a.node-versionthat’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. 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. - Snapshot tests aren’t writing files. Node’s built-in snapshots write to
__snapshots__/<file>.snapshotnext 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
tsxor--experimental-strip-types, source maps must be present for V8 to attribute coverage to your.tssource. Use--enable-source-maps(or setNODE_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. AbortSignalfrom tests doesn’t cancel children. Tests receive asignalon thetcontext (t.signal). Long-running async work needs to observe the signal explicitly — the runner cannot kill asetTimeoutchain 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.
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.