How each runner works, end to end

All three runners follow the same broad loop: find test files, schedule them onto processes or workers, load each file's module graph, run hooks and tests, and stream results back to a reporter in the parent process. They differ at each of those steps.

Jest crawls the project to build a file map, selects test files with testMatch or testRegex, and distributes them across a pool of worker processes. Each file gets a fresh test environment (testEnvironment: 'node' for API code) and its own module registry. Every require or import goes through Jest's resolver and the configured transform. Workers send results back to the parent, which runs the reporters. --runInBand skips the pool and runs every file serially in the main process, which is useful for debugging and for suites that cannot tolerate parallel database access.

Vitest collects files from its include globs in the main process, then hands them to a pool of worker threads or child processes, set by the pool option. The default pool has changed between major versions, so check which one your version uses. Inside the worker, modules are transformed on demand through the Vite pipeline, so only files a test actually imports get compiled. Results stream back to the main process, which also drives watch mode by mapping changed files to the tests that import them.

node:test is started by node --test. Without arguments it discovers files by name patterns such as *.test.js and files under a test directory. You can pass explicit paths or globs instead. The parent process spawns a child Node process for each file, runs them with limited concurrency, and collects their results as a structured event stream. Built-in reporters such as spec, tap and junit format that stream. Inside a file, test, describe, before and after come from the node:test module, and assertions come from node:assert. No transform runs unless you add one with --import.

The rest of this article follows from these designs: how TypeScript enters the pipeline, what a "fresh" file means for shared state, and how far each runner can intercept imports.

How node:test, Vitest and Jest execute TypeScript and modules

Jest runs each test file inside a vm context and uses a transform to compile TypeScript before execution. The two usual transforms are ts-jest and babel-jest with @babel/preset-typescript, configured through the transform key in jest.config. Vitest runs files through a Vite-based pipeline that compiles TypeScript with esbuild, so vitest run works without a separate transform. node:test is Node's built-in runner, started with node --test. Node has no compiler of its own, so it relies on a register hook such as node --import tsx --test. On release lines that support it, Node can instead strip type annotations natively. Type stripping only handles erasable syntax, so enums and parameter properties can fail.

Babel, esbuild and Node's stripping all discard types without checking them. ts-jest reports diagnostics, but only for the files a test run actually loads. In every case, CI needs a separate step such as "typecheck": "tsc --noEmit".

Module format is the bigger practical difference:

Runner TypeScript path Module model ESM-only dependencies
Jest ts-jest or Babel transform CommonJS by default Need --experimental-vm-modules or a transform that covers node_modules
Vitest esbuild via Vite ESM-first Import directly
node:test Loader, register hook or type stripping Node's native loader Import directly, same as production

This matters because many maintained packages now ship as ESM only. Under Jest's default CommonJS mode, importing one fails with a syntax error unless you add extra configuration. With node:test, tests resolve modules exactly the way production does, which is why it has the fewest surprises at runtime.

#!/usr/bin/env bash
set -euo pipefail

npx jest

NODE_OPTIONS=--experimental-vm-modules npx jest

npx vitest run

node --import tsx --test "test/**/*.test.ts"

node --experimental-strip-types --test "test/**/*.test.ts"

npx tsc --noEmit -p tsconfig.json

Commands that run a TypeScript API test suite under Jest (CommonJS and ESM mode), Vitest, and node:test (register hook and type stripping), followed by the separate tsc --noEmit type-check that CI still needs.

Isolation models and shared state in backend tests

Isolation matters for API suites because backend code holds resources at module scope: connection pools, cached config, clients that keep sockets open. A runner decides which tests share those objects, and that decides whether a test passes alone but fails in the full suite.

Jest                         Vitest                        node:test
worker process               worker or fork                child process per file
 ├─ file A: own registry      ├─ file A: fresh modules      ├─ file A (own process)
 ├─ file B: own registry      │   (isolation on)            └─ file B (own process)
 └─ shared: process, env,     └─ isolation off: modules     shared: nothing but the
    sockets, native handles      shared across files           database you point at

Jest gives each test file a fresh module registry inside a worker. A db.ts singleton is rebuilt per file, but the pool from the previous file is never closed. The worker survives across files, so its sockets survive too. On a large suite, connections accumulate until the database refuses new ones. The registry also hides a real bug: production shares one pool, while tests quietly create many. You see the symptom as "flaky timeouts in file 40", not as a singleton problem.

Vitest isolates modules per file by default. You can turn isolation off for speed, and then singletons and module-level caches become genuinely shared. Code that relies on implicit reset will break.

node:test gives each file a separate process. Pools, timers and process.env mutations die with that process. An unclosed handle, however, keeps the process alive, so leaks show up as hangs instead of silent accumulation.

In every model, the database itself is shared state. Process boundaries do not isolate rows.

+-- Jest ---------------+ +-- Vitest -------------+ +-- node:test ----------+
| worker process        | | worker or fork        | | runner (parent proc)  |
| (lives across files)  | | isolate: true         | |     |          |      |
| +--------+ +--------+ | | +--------+ +--------+ | |     v          v      |
| | file A | | file B | | | | file A | | file B | | | +--------+ +--------+ |
| |registry| |registry| | | | fresh  | | fresh  | | | | proc A | | proc B | |
| | pool 1 | | pool 2 | | | | modules| | modules| | | | file A | | file B | |
| +---+----+ +---+----+ | | +--------+ +--------+ | | | pool,  | | pool,  | |
|     |          |      | |                       | | | env,   | | env,   | |
|     v          v      | | isolate: false        | | | timers | | timers | |
| shared by A and B:    | | +--------------------+| | +--------+ +--------+ |
| env, sockets, handles | | | shared modules,    || | dies with its process |
| pool 1 never closed   | | | singletons, caches || | open handle => hang   |
| => connections pile up| | +--------------------+| | (leaks block exit)    |
+-----------------------+ +-----------------------+ +-----------------------+
            |                         |                         |
            v                         v                         v
+---------------------------------------------------------------------------+
|  shared database: process and registry boundaries do not isolate rows     |
+---------------------------------------------------------------------------+

Jest rebuilds the module registry per file inside a long-lived worker, Vitest isolates modules per file unless isolation is turned off, and node:test gives each file its own process, but all three share the same database.

Mocking: module mocks versus dependency injection

jest.mock calls are hoisted above imports by Jest's transform, so the mocked module is swapped in before the code under test loads it. That hoisting depends on the transform. Under native ESM, Jest needs jest.unstable_mockModule followed by a dynamic import. vi.mock is also hoisted, but its factory cannot reference variables declared in the test file. Shared values must be created through vi.hoisted, and code ported from Jest often breaks on exactly this rule. node:test provides a mock object with mock.fn, mock.method and mock.timers, which handle functions, object methods and fake timers well. Module mocking is either unavailable or behind an experimental flag, depending on the Node version.

In API code, that gap matters less than it seems. If a service receives its repository, HTTP client and clock as constructor or factory arguments, the test passes a fake object, and no runner has to intercept the module graph. Take a service that loads a user and sends a welcome email:

  • Module-mock version: the test mocks ./mailer by path.
  • Injected version: the test passes a fake with a send method that records its calls.

The injected version runs unchanged in all three runners.

Heavy module mocking has a cost. Tests become coupled to file paths and import structure. If you split a module or re-export it from an index file, mocks silently stop applying, or dozens of tests fail at once. Module mocks also replace real behavior with assumptions. A suite that passes against mocked database modules says little about the SQL itself, which is why the integration setup in the next section carries the real verification.

// --- welcome.module.test.js (Jest, CommonJS): service requires ./users and ./mailer itself
// jest.mock is hoisted above the require calls, so these paths must match welcome.js imports exactly.
jest.mock('./users', () => ({
  findById: jest.fn(async (id) => ({ id, email: 'ada@example.com' })),
}));
jest.mock('./mailer', () => ({ send: jest.fn(async () => {}) }));

const { send } = require('./mailer');
const { welcome } = require('./welcome');

test('sends welcome email (module mock)', async () => {
  await welcome('u1');
  expect(send).toHaveBeenCalledWith('ada@example.com', 'welcome');
});

// --- welcome-service.js: dependencies arrive as arguments instead of imports
export function createWelcomeService({ users, mailer }) {
  return {
    async welcome(id) {
      const user = await users.findById(id);
      await mailer.send(user.email, 'welcome');
    },
  };
}

// --- welcome.injected.test.js (node:test): plain fakes, no module graph interception
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createWelcomeService } from './welcome-service.js';

test('sends welcome email (injected fake)', async () => {
  const sent = []; // the fake records calls, so no runner-specific spy API is needed
  const service = createWelcomeService({
    users: { findById: async (id) => ({ id, email: 'ada@example.com' }) },
    mailer: { send: async (to, template) => { sent.push({ to, template }); } },
  });
  await service.welcome('u1');
  assert.deepEqual(sent, [{ to: 'ada@example.com', template: 'welcome' }]);
});

The same welcome-email test written twice: once with Jest module mocks tied to import paths, and once with injected fakes. The second test's fakes are plain objects that work in any runner, and only the test and assert imports change between runners.

Implementing an integration test setup that works in all three

When unit tests use fakes, the integration suite carries most of the weight of verifying real behavior, so it has to run identically under each runner.

Server and database lifecycle

Start the app by calling listen(0) and reading the assigned port from server.address(), or skip the socket entirely with an in-process request helper that calls your handler directly. A fixed port collides the moment two files run at once. Every runner handles this the same way. Database lifecycle is where they diverge. Jest's globalSetup and globalTeardown run once, outside the test environments, so they can only pass values to tests through environment variables or files. Vitest's globalSetup file exports setup and teardown functions that run once in the main process, with the same restriction. node:test runs each file as its own process. A shared migration step fits best as a script that runs before node --test, while each file opens its connection in before and closes it in after.

Per-test isolation

Run each test inside a transaction and roll it back afterward. This is fast, but it breaks when the code under test opens its own transaction or uses a separate pool connection. Truncating tables between tests is slower but makes no assumptions about how the code uses connections.

Contention

Parallel files that share one database, port or temp directory produce flaky failures that depend on ordering. You can fix this by giving each worker its own schema or database, keyed off JEST_WORKER_ID or VITEST_POOL_ID. Other options are running integration files serially, or generating unique IDs and emails in every test. Serial execution is the easy toggle, but CI time then grows linearly with the number of files. Plan the isolation strategy up front so you can keep parallelism.

// test/helpers/env.js: shared by Jest, Vitest and node:test files
import http from 'node:http';
import { randomUUID } from 'node:crypto';

// One schema per worker so parallel files never share tables.
// Jest sets JEST_WORKER_ID and Vitest sets VITEST_POOL_ID.
// node:test runs each file in its own process, so the PID is unique per file.
export function workerSchema() {
  const id = process.env.JEST_WORKER_ID ?? process.env.VITEST_POOL_ID ?? String(process.pid);
  return `test_w${id.replace(/\D/g, '')}`;
}

// Listen on port 0 so the OS assigns a free port. Fixed ports collide
// as soon as two files run at once. Tests build request targets from host and port.
export async function startServer(handler) {
  const server = http.createServer(handler);
  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
  const { address, port } = server.address();
  return {
    host: address,
    port,
    close: () => new Promise((resolve) => server.close(resolve)),
  };
}

// Unique values per test, so rows from one test never satisfy
// another test's uniqueness constraints or lookups.
export function uniqueEmail(prefix = 'user') {
  return `${prefix}+${randomUUID()}@example.test`;
}

// Usage is the same in all three runners: beforeAll/afterAll in Jest and Vitest,
// before/after from node:test.
//   let app;
//   before(async () => { app = await startServer(handler); });
//   after(async () => { await app.close(); });

This runner-agnostic helper starts the server on an OS-assigned port, derives a per-worker schema name from JEST_WORKER_ID, VITEST_POOL_ID or the process ID, and generates unique test data so integration files can stay parallel.

Developer workflow, tooling and dependency footprint

All three runners cover the daily basics: a watch mode, filtering by file path or test name, and V8-based coverage. They differ in how much of that is built in and how stable it is.

Concern node:test Vitest Jest
Install Ships with Node npm package plus transitive tree npm package plus transitive tree
Watch mode --watch flag Default in interactive terminals --watch / --watchAll
Name filtering --test-name-pattern -t -t
Snapshots Built in, maturity varies by Node line Built in Built in
Coverage V8, built in V8 or Istanbul provider (separate package) Istanbul by default, V8 optional
JUnit / TAP Both built in Both built in Community reporter packages
Matchers node:assert only Rich, Jest-compatible expect Rich expect
Config CLI flags, optional Config file, often shared with the build tool Config file plus transform setup

The zero-install advantage of node:test is real but smaller than it looks. node:assert has no partial object matching comparable to toMatchObject, and it has no asymmetric matchers for timestamps or generated IDs. API tests depend on both. Teams usually add an assertion library or write helpers, and that brings back a dependency or code they have to maintain.

The runner is also tied to the runtime. Snapshot support, coverage and mocking APIs have reached different stability levels in different Node release lines. A Node upgrade can therefore change how the test suite behaves, so you cannot treat a runtime bump and a test-tooling change as separate decisions.

Vitest and Jest separate the two: you pin the runner version independently of Node. The cost is a larger dependency tree to audit and update, plus config files that tend to drift from the production build setup.

Migrating between runners without breaking CI

Jest to Vitest is mostly mechanical: swap jest.fn for vi.fn, translate the config, and most suites pass. The failures that remain are the ones find-and-replace cannot catch:

  • Mock hoisting. Vitest hoists vi.mock above ES imports, so a factory that references a top-level variable fails or reads it before initialization. Move that state into vi.hoisted.
  • Fake timers. The two runners fake different sets of timer APIs by default. Code that awaits process.nextTick or microtasks under fake timers can hang or resolve in a different order.
  • Module resolution. moduleNameMapper regexes do not map one-to-one onto resolve.alias. CJS/ESM interop also differs, so default-export shapes can change.
  • Globals. describe and expect are not ambient in Vitest unless globals is enabled. Type definitions need the same change.

Moving to node:test is a rewrite, not a rename. toBe becomes assert.strictEqual, toEqual becomes assert.deepStrictEqual, and rejects.toThrow becomes assert.rejects. Call assertions read from fn.mock.calls[i].arguments instead of toHaveBeenCalledWith. Module mocking in node:test is newer and less mature, so treat the migration as the point where you replace module mocks with injected dependencies.

Migrate one service or test directory at a time, and run both runners in CI during the transition. Give each runner an explicit, non-overlapping file glob and run them as separate steps, so a failure names its runner. Add a check that fails the build if any test file matches both globs or neither. Without that check, a file can silently drop out of both suites and coverage disappears with no visible failure.

Who should use each runner

node:test fits teams writing plain Node backend services who want tests to load modules exactly as production does, and who already structure code around dependency injection. It suits small and medium services, internal tools, libraries that must stay dependency-light, and teams that upgrade Node regularly and are willing to track the runner's stability across release lines. It is a poor fit for teams that rely on rich matchers, heavy module mocking, or a polished interactive watch experience.

Vitest fits teams that are ESM-first, already use Vite elsewhere in the repository, or want Jest's expect and mocking style without Jest's transform setup. It is also a reasonable target for Jest users who struggle with ESM-only dependencies but don't want to rewrite their assertions.

Jest fits teams with a large existing Jest suite, shared Jest presets across many repositories, or tooling and reporters built around Jest's output. For them, the cost of switching usually exceeds the benefit. New projects choose it less often when they are ESM-first, because of the extra configuration that requires.

How the right runner saves engineering time

Most of a test runner's time cost comes from three places, and each runner reduces a different one.

  • Configuration upkeep. Every transform, preset and module mapper is something to update when TypeScript, Node or a dependency changes. node:test with a register hook or type stripping has the least to maintain. Vitest avoids a separate transform. Jest carries the most, especially once ESM is involved.
  • Flaky-test investigation. Failures from leaked pools or shared singletons take hours to trace because the symptom appears far from the cause. A runner whose isolation model matches what your code assumes, combined with per-worker schemas and unique test data, removes a whole class of these investigations. Under node:test, leaks surface as hangs in the file that caused them, which is easier to trace than gradual accumulation.
  • Mock maintenance. Tests built on injected fakes survive refactors that move or re-export modules. Tests built on path-based module mocks need edits every time the import structure changes. Moving toward injection saves time regardless of runner, and it keeps a later runner switch cheap.

A worked example shows how to compare runners on CI time. This is an illustrative estimate. Every input is assumed, not measured. Assume a suite of 400 test files, an assumed transform overhead of 50 ms per file under one setup and 10 ms under another, and an assumed 60 CI runs per day. The difference is 400 × 40 ms × 60 = 960 seconds, about 16 minutes of CI time per day. Whether that matters depends on your queue times and costs, and the real per-file figures must come from timing your own suite. Often a single flaky-test investigation costs more engineer time than that difference, which is why configuration and isolation tend to matter more than raw speed.

Which runner to choose: scenarios, costs and when to avoid each

Weigh three factors: how TypeScript gets executed, how much the suite relies on module-level isolation and mocking, and what existing tests and helpers already depend on.

Scenario Recommendation Avoid What changes the decision
New small TypeScript service node:test with dependency-injected fakes Jest, which adds transform config and a mocking style you would later need to unlearn Heavy module mocking or richer watch-mode needs point to Vitest
Large legacy Jest suite with heavy jest.mock Stay on Jest and move toward dependency injection first node:test, where every hoisted module mock becomes a rewrite Transform time dominating CI, or ESM-only dependencies Jest struggles with
Monorepo already on Vite Vitest, reusing the existing Vite config Jest, which needs a second transform pipeline kept in sync Packages that are plain Node services with no Vite config

Engineering time matters more than raw speed. Configuration is a recurring cost because every transformer, preset or loader must be updated alongside TypeScript and Node upgrades. You can estimate transform overhead with this formula: daily transform time = test files × per-file transform time × CI runs per day. Measure the per-file figure on your own suite before treating it as the deciding factor.

Flaky-test debugging usually costs more than either of those. It traces back to shared state leaking across files or workers, so pick the isolation model your tests actually assume.

Allowing several runners also has a cost. It splits shared fixtures, CI templates and team knowledge. Standardize on one runner per repository, and keep shared helpers free of runner-specific APIs so a later switch stays cheap.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to node test runner vs vitest vs jest, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.