Home / Articles / Replacing Jest with Node's Native Test Runner in Node 24

This article is published in English.

Node.jsTypeScriptTestingJestVitestPerformance

Replacing Jest with Node's Native Test Runner in Node 24

A real-world migration shows how Node 24's built-in test runner and native TypeScript support cut CI time while removing four dependencies.

1565 words

The pull request was titled "chore: remove jest, vitest config," and it stripped out 340 lines along with four devDependencies. There was a brief moment of bracing for CI to complain. It never did. Every test executed, every test passed, coverage reporting kept working, and the entire run finished roughly a third quicker than before. That was the moment it became clear that Node's built-in test runner had quietly stopped being an experiment and turned into a legitimate option — one that had been overlooked out of sheer habit for a couple of years.

This wasn't a toy codebase either. The project had close to 180 test files, covering both unit and integration cases, with some genuinely tricky mocking around timers and fetch requests — exactly the kind of suite where reaching for a full-featured framework feels like the safe default.

What Actually Changed

The node:test module first appeared as an experimental feature in Node 18, and for good reason it didn't get much attention: early builds were missing solid mocking support, a usable watch mode, and coverage output that felt intentionally designed rather than bolted together. Node 24 fixes most of that. Coverage now comes from V8 directly, with no need to wire up nyc or c8. Watch mode is smart enough to figure out which test files are affected by a given change instead of blindly rerunning the entire suite. And mocking of timers, modules, and functions is built in, so there's no need for an additional dependency just to fake a clock or stub a function.

import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
import { fetchUser } from './users.js';
test('fetchUser returns normalized data', async (t) => {
  const fetchMock = mock.method(global, 'fetch', async () =>
    new Response(JSON.stringify({ id: 1, name: 'test' }))
  );  const user = await fetchUser(1);  assert.equal(user.id, 1);
  assert.equal(fetchMock.mock.callCount(), 1);
});

You run it with node --test — no configuration file to maintain, no Babel pass, no ts-jest transformation layer quietly adding a few seconds to every file. That last point ended up mattering more than the test runner itself.

The Native TypeScript Piece

Node can now execute .ts files directly by stripping types at parse time rather than compiling them in the traditional sense, and starting with Node 24 that capability moved from an experimental flag to the default for most syntax. There's no need for ts-node, no tsx, and no separate build step just to run scripts or tests.

node app.ts
node --test tests/

There's a genuine catch here: type stripping does not perform any type checking. It simply removes the annotations and executes whatever code remains. If your codebase depends on features like enums that carry runtime values, namespaces, or constructor parameter property shorthand, some of these either need extra flags or aren't supported yet, since they actually produce JavaScript output rather than being erasable outright. This isn't meant to replace the TypeScript compiler, and it doesn't try to. You still need tsc --noEmit or editor tooling to catch actual type errors. What it does eliminate is the long-standing overhead of compiling a file just to execute it, a tax that's always been baked into the TypeScript workflow.

Why the Test Suite Got Faster

Getting rid of Jest didn't just strip out one dependency, it also stripped out a whole transform pipeline. By default, Jest handles TypeScript in one of two ways: it either hands things off to ts-jest, which is thorough but slow because it runs full type checking on every file unless you explicitly disable that, or it uses babel-jest, which is quicker but adds its own configuration layer with its own edge cases around decorators and newer syntax. When Node executes .ts files natively, the runner itself skips that transformation step altogether. Add to that the fact that node:test is reportedly around 40% faster than earlier versions of Node's test runner on similar workloads, and a one-third reduction in overall suite time stops looking like a coincidence, it's really two separate speed gains compounding.

Coverage reporting was the piece expected to cause friction. It didn't.

node --test --experimental-test-coverage tests/

The formatting isn't as refined as what Istanbul produces in HTML by default, but the underlying coverage percentages lined up with c8's numbers to within a single point, and for gating pull requests in CI, that level of accuracy is really all that matters.

Where It Still Falls Short

Snapshot testing is the real limitation here. Teams that rely heavily on Jest's snapshot feature, whether for component rendering or for capturing API response shapes, won't find a built-in replacement yet. You're left writing your own comparison logic or bringing in a separate snapshot library just to cover that use case. The same applies to anything built around Jest's automatic module mocking through jest.mock('./path') with its hoisting behavior. Node's mock.method and mock.module handle much of that ground, but using them means being more explicit and hands-on about exactly what gets swapped out and at what point.

For a project centered on React components with extensive snapshot-based UI tests, expect this transition to be considerably rougher than it was for the backend service used in this evaluation. Where the swap works almost seamlessly right now is in server-side code and CLI tools.

Parallel execution is another factor worth checking before committing to this approach on a sizable test suite. Jest's worker-pool architecture doesn't distribute test files across processes the same way Node's built-in runner does, and depending on your suite's layout, total wall-clock time could shift in an unwelcome direction on a large monorepo, even though individual test files run faster in isolation. It's worth measuring this on the actual machines your CI pipeline uses rather than a local laptop with several idle cores and nothing competing for resources.

What the Migration Actually Looked Like

For anyone considering this switch, here's roughly how it unfolded: both test runners ran in parallel inside CI for about a week instead of switching over all at once in a single pull request. The same test files ran through two separate CI jobs, with results and timing compared directly. That process surfaced two tests that had been silently depending on Jest-specific global variables nobody remembered introducing, both of which were fixed within the hour once identified. Only once the two CI jobs had agreed consistently for a full week did the pull request removing Jest actually go up. It's a slow, unglamorous way to do it, but when you're touching the safety net meant to catch mistakes elsewhere, a process that's dull but reversible beats one that's quick but impossible to undo.

What About Vitest

This is worth tackling head-on, since it's the alternative most people mention first. Vitest does run faster than Jest, offers a more pleasant API, and fits naturally into Vite-driven frontend workflows — none of that is in dispute. Still, it remains an added dependency, complete with its own configuration, capable of drifting away from whatever Node version you're actually running and causing failures that swallow an afternoon. If a frontend codebase is already built around Vite, choosing Vitest continues to make sense, since nothing native replaces jsdom-style component testing yet. For a backend service or a command-line tool with no bundler involved, though, pulling in Vitest solely for a nicer assertion syntax lost its appeal once node:test closed the gap on mocking and coverage. It's a tool matched to a particular kind of project, not a wholesale replacement for every setup.

Where That Leaves Things

This isn't a call to rewrite every existing codebase right away. Looking forward, though, there's no longer a clear default reason to bring in an outside test runner when starting a new Node service, a statement that wouldn't have held up a year earlier. The ecosystem spent close to ten years building intricate tooling around gaps the runtime itself left open, and now that some of those gaps have closed, a share of that tooling amounts to unnecessary weight. Not the entire toolchain, to be clear. Just a larger portion than anticipated.