Testing Without Jest: How We Use the Native Node.js Test Runner
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗Key takeaways
The Node.js test runner is built into the node:test module and runs with node --test. It needs no dependency, no config file, and has been stable since Node.js 20, which is why our team uses it as the default for backend and library code.
Assertions come from the built-in node:assert module. We import node:assert/strict so that assert.equal uses strict, === comparison instead of loose equality.
The mock object from node:test provides mock.fn(), mock.method(), and mock.timers without a separate mocking library. Full ES module mocking via mock.module() is still experimental.
Combined with type stripping, node --test with a glob such as src/**/*.test.ts runs TypeScript tests with no build step on Node.js 22.18 and newer and on Node.js 24.
Coverage is available behind the --experimental-test-coverage flag, and the output format is chosen with --test-reporter, which supports spec, tap, dot, junit, and lcov.
Can Node.js run tests without Jest or Vitest?
Yes, and it needs zero dependencies. The runner is the node:test core module, invoked with node --test. It landed experimentally in Node.js 18 and became stable in Node.js 20. It auto-discovers test files that match patterns like *.test.* or live under a test directory, runs each file in its own child process for isolation, and reports results in a TAP-based format.
A minimal test imports test from node:test and assert from node:assert/strict, then asserts that a function returns the expected value. Running node --test executes the whole suite. There is no jest.config file, no transform pipeline, and no ts-jest — the test file is the entire setup. For our scripts and internal libraries, that removed a whole category of tooling.
How do we structure tests with node:test?
The node:test module exports test, plus describe and it for BDD-style grouping, and the four lifecycle hooks before, after, beforeEach, and afterEach. Nested describe blocks and subtests give the same organization Jest offers, so migrating the shape of an existing suite is mechanical.
We focus a single test with the only option plus the --test-only flag, or filter by name with --test-name-pattern. Skips and TODOs use the skip and todo options. None of this requires a plugin.
How do we mock functions, methods, and timers?
Mocking is built in through the mock object exported from node:test, so there is no jest.fn equivalent to install. mock.fn() creates a spy whose calls are recorded on its mock.calls array and mock.callCount() method.
mock.method(object, name) replaces a real method with a spy and restores it automatically when we use the test context's mock helper. Fake timers come from mock.timers.enable, after which mock.timers.tick advances time deterministically. Full ES module mocking exists as mock.module() but is still experimental, so we lean on dependency injection — passing collaborators in as arguments — which needs no module interception at all.
Can we run TypeScript tests without a build step?
Yes, on Node.js 22.18 and newer and on Node.js 24, because Node.js strips type annotations at load time. We point the runner at our .ts files with a glob and they execute directly. Two rules carry over from native TypeScript execution: relative imports must include the explicit .ts extension, and Node.js never type-checks, because it discards annotations without validating them.
So we run tsc with the noEmit flag as a separate CI gate for types and let node --test own runtime behavior. This combination removed ts-jest and the Vitest transform from every script-shaped and library repository we maintain.
How do we get coverage, watch mode, and CI reports?
Watch mode is node --test --watch, which reruns affected tests when files change. Coverage is a flag rather than a package: node --test --experimental-test-coverage prints a per-file table of line, branch, and function coverage. Reporters are selected with --test-reporter, and several can be emitted at once — for example a spec reporter for humans plus junit and lcov files for CI.
Coverage thresholds are enforceable with --test-coverage-lines, --test-coverage-branches, and --test-coverage-functions on Node.js 22 and newer. They fail the run below the given percentage, which is enough to gate a pull request without paying for a coverage service.
When should we keep Vitest or Jest?
We keep Vitest or Jest whenever a test needs a browser-like environment or a build transform the runtime does not provide. The Node.js test runner has no DOM, does not transform JSX or TSX, and its snapshot support is newer and thinner than Vitest's.
The split we use in practice: backend and library code that runs on Node.js gets the built-in runner with zero dependencies and native TypeScript; anything that renders a component or reaches for the document object stays on Vitest with jsdom and a JSX transform. Node.js is best for scripts, libraries, and backend logic, while Vitest and Jest are best for frontend, component tests, and large application suites.
FAQ
Is the Node.js test runner production-ready? Yes. The core runner and the node:test API are stable as of Node.js 20. Module mocking and coverage are still behind experimental flags, but the test, assert, and mock core is stable.
Do we need a config file? No. node --test auto-discovers test files and needs no config. Options are passed as CLI flags rather than a jest.config file.
Which assertion library does it use? The built-in node:assert module. We import node:assert/strict so equality checks are strict by default, and any third-party assertion library still works if preferred.
Can it test React components? Not well. It has no DOM environment and does not transform JSX. For React component tests we use Vitest or Jest with jsdom, and reserve the Node.js runner for logic, utilities, and backend code.
How does it mock ES modules? Through mock.module(), which is still experimental. For stable code we prefer dependency injection, passing collaborators in as arguments, which needs no module mocking at all.
Further Reading
Developer Tooling
Running TypeScript Natively in Node.js: How We Dropped the Build Step for Scripts
Node.js now executes TypeScript files directly through type stripping. Here is how our team moved scripts, configs, and cron jobs to native execution, how we keep code erasable with TypeScript 5.8's erasableSyntaxOnly flag, and why our NestJS services kept their build.
Developer Tooling
Replacing ESLint and Prettier with Biome: Field Notes from Two Production Repos
Biome is a single Rust toolchain that formats and lints JavaScript and TypeScript in one pass, replacing ESLint and Prettier. We migrated two production repositories — here is where it won, what Biome 2.0's type-aware linting changes, and where we still keep ESLint.