Developer Tools

Vitest vs Jest vs node:test in 2026: Benchmarked on a Real TypeScript Project

I ran the same 39-file TypeScript suite under Vitest 5.1, Jest 30.5 (SWC and ts-jest) and Node 24's node:test, and measured cold and warm runs, watch mode, coverage, mocking and the ESM/TypeScript setup problems.

Vitest vs Jest vs node:test cover: the same 39-file TypeScript suite run with all three runners, with measured warm-run medians
On this page
  1. What I tested
  2. Results: the full suite
  3. Results at 4x the size
  4. One file, the inner loop
  5. Watch mode
  6. Coverage
  7. Setup for each runner
  8. Vitest
  9. Jest with @swc/jest
  10. Jest with ts-jest
  11. node:test
  12. Mocking APIs compared
  13. Which test runner should you use?

I ran the same TypeScript test suite under Vitest 5.1, Jest 30.5 and Node's built-in node:test, and timed cold runs, warm runs, single files, watch mode and coverage. The suite is 39 test files and 183 tests against a small NestJS-style orders service, an HTTP API and an MCP server. The result surprised me: with default settings, Jest with @swc/jest was the fastest full run, and Vitest was the slowest of the three. Change one isolation setting and the order flips. Below are the numbers, the setup for each runner, and the TypeScript and ESM problems I hit along the way.

What I tested#

Everything ran on November 3, 2026 on one Linux VM with 8 vCPUs (Intel Xeon) and 16 GB of RAM, on Node.js 24.22.1:

  • Vitest 5.1.0 (Vite 8.3.5), @vitest/coverage-v8 5.1.0
  • Jest 30.5.2 with @swc/jest 0.2.39 and @swc/core 1.16.21, and separately with ts-jest 29.4.15
  • node:test from Node 24.22.1, using Node's built-in type stripping (no loader)
  • TypeScript 6.0.3 for type checks. ts-jest 29.4.15 declares typescript >=4.3 <7, so it doesn't support TypeScript 7.0.2, the current latest on npm.

The code under test is real, not generated: money math with cent allocation, slugs, cursor pagination, an LRU cache, a token-bucket rate limiter, retry with backoff, a CSV parser, an event bus, Zod order schemas, a pricing service, an orders service with status transitions, a node:http JSON API, and the MCP server from my MCP testing guide. That's 387 lines of source and 501 lines of tests. The HTTP and MCP tests start real servers on random ports and call them with fetch and the official MCP SDK client.

To keep the suite identical, every test file imports describe, it and hooks from #t, a one-line shim that each runner maps to its own API, and asserts with node:assert/strict. Fakes are hand-written, so no runner-specific mocking runs inside the benchmark. I compare the mocking APIs separately below.

How I measured: a script ran each configuration 10 times in shuffled order. "Cold" means I deleted the runner's caches first (jest --clearCache, and node_modules/.vite plus node_modules/.vitest-cache for Vitest). "Warm" is the next run with caches in place. Times are whole-process wall clock, and I report the median. Every run had to exit 0 with all 183 tests passing.

One honest caveat: this VM is shared, and other jobs were running (load average between 3 and 13 on 8 cores). Absolute times are noisy, so read the ranking more than the decimals. I ran the main configurations in two separate batches, and Vitest, Jest and node:test each landed within about 0.1 s of their first-batch medians.

Results: the full suite#

39 files, 183 tests, median of 10 runs:

Runner and configColdWarm
Vitest 5.1, defaults (forks pool, isolated)1.59 s1.51 s
Vitest, --pool=threads1.55 s1.42 s
Vitest, --pool=vmThreads0.91 s0.91 s
Vitest, --no-isolate0.75 s0.68 s
Vitest, --fsModuleCache1.63 s1.52 s
Jest 30.5 + @swc/jest (CommonJS output)0.99 s0.81 s
Jest + @swc/jest, native ESM (--experimental-vm-modules)1.19 s1.01 s
Jest + ts-jest (type-checks every file)4.30 s3.13 s
Jest + ts-jest, isolatedModules: true1.44 s1.14 s
node --test (process per file)1.23 s1.24 s
node --test --test-isolation=none0.52 s0.51 s

The threads, vmThreads and --test-isolation=none rows come from the second batch. In that batch, the Vitest default, Jest + @swc/jest and node --test rows measured 1.52/1.40 s, 0.95/0.80 s and 1.14/1.23 s.

What the numbers say:

  • Isolation is most of the cost. Vitest's default forks pool starts a fresh child process for each test file, and its own summary told me so: "39 workers spawned, ~81ms startup each". node --test also runs each file in its own process. Jest isolates each file in a separate vm context but reuses its 7 worker processes. Turn isolation off and Vitest (0.68 s) and node:test (0.51 s) both beat Jest.
  • vmThreads is the middle ground for Vitest. It kept per-file isolation and cut the run from 1.51 s to 0.91 s. The catch is that it runs each file in a vm sandbox, like Jest does. Vitest's docs warn that this leaks memory on large suites (workers restart when they pass vmMemoryLimit) and that objects from Node APIs such as fetch's response.json() and structuredClone() have a different prototype. That's the same realm problem I hit with Jest below, and this suite only passed under vmThreads because I had already fixed those tests.
  • ts-jest with type checking costs about 2.3 seconds here. It was 3.13 s warm against 0.81 s for @swc/jest. With isolatedModules: true it skips the type checker and drops to 1.14 s. Running tsc --noEmit on the whole project separately took about 1.5 s (median of 5), so a separate type-check step is cheaper than type-checking inside Jest.
  • Caches barely mattered at this size. Cold and warm runs differ by 0.1 to 0.2 s for Vitest and Jest with SWC. Vitest 5 made fsModuleCache stable, but on a project this small it made no measurable difference. node --test has no transform cache at all, because type stripping is cheap.

Results at 4x the size#

To see how the gap scales, I copied the test tree three more times: 156 files and 732 tests, same source. Median of 7 runs:

Runner and configColdWarm
Vitest, defaults5.15 s5.03 s
Vitest, --pool=vmThreads2.29 s1.99 s
Vitest, --no-isolate1.32 s1.08 s
Jest + @swc/jest1.75 s1.52 s
node --test4.32 s4.26 s
node --test --test-isolation=none1.08 s1.10 s

The per-file process cost grows with the file count. Isolated Vitest went from 1.5 s to 5.0 s, while Jest went from 0.8 s to 1.5 s. Without isolation, Vitest and node:test stay around one second. On a busy machine, process startup is exactly the thing that gets slower, so your numbers on a quiet laptop may be closer together, but the direction should hold.

One file, the inner loop#

Running only test/orders/service.create.test.ts (4 tests), warm, median of 7:

RunnerTime
node --test0.18 s
Jest + @swc/jest0.39 s
Vitest0.40 s
Jest + ts-jest1.40 s

Node has nothing to boot except itself. Vitest and Jest each spend about 0.2 s more starting their runner.

Watch mode#

I started each runner in watch mode, waited for the first run, then edited a source file and timed the rerun (median of 5). money.ts is imported by 3 test files. events.ts is imported by the shared test fixtures, so 18 files depend on it.

Watch commandEdit money.tsEdit events.ts
vitest --watch0.23 s, 3 files0.97 s, 18 files
jest --watch (needs git)0.25 s, 3 files0.52 s, 18 files
jest --watchAll0.66 s, all 39 files0.61 s, all 39 files
node --test --watch0.31 s, 3 files0.94 s, 18 files

All three rerun only the affected test files. Vitest follows the module graph, and node:test did the same in my test. Jest's --watch uses git to find changed files, and outside a git repo it refuses to start ("--watch is not supported without git/hg, please use --watchAll"). Vitest's interactive watch UI is the nicest of the three (filter by name, rerun failed tests, the optional browser UI), but on raw rerun speed they're close.

Coverage#

Full suite with coverage on, median of 5 runs after one warm-up:

RunnerTimeLines reported
Vitest, @vitest/coverage-v81.99 s97.38%
Jest + @swc/jest, default (Babel/Istanbul)0.99 s97.81%
Jest + @swc/jest, --coverageProvider=v81.66 s98.44%
node --test --experimental-test-coverage1.36 s100% lines, 95.79% branches

Coverage added about 0.5 s to Vitest and less to Jest with Istanbul. The percentages aren't comparable between tools, because each counts lines, statements and branches differently. Node said 100% of lines while the others found a few uncovered lines in the same code. Pick one tool for your coverage threshold and stay with it. Node's coverage is still behind the --experimental-test-coverage flag on Node 24.

Setup for each runner#

Vitest#

ts
// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    include: ["test/**/*.test.ts"],
    coverage: { provider: "v8", include: ["src/**"] },
    // isolate: false,   // 0.68 s instead of 1.51 s, if your tests don't leak state
    // pool: "vmThreads", // keeps isolation, 0.91 s here
  },
});

That's all it needed. ESM, TypeScript, .ts extensions in imports, top-level await and import.meta all worked without extra config. The exception is decorators: a class with a legacy decorator and a constructor parameter property failed with "SyntaxError: Invalid or unexpected token" under my bare config, even with experimentalDecorators in tsconfig.json. Adding unplugin-swc 2.0.0 to the plugins, with experimentalDecorators and emitDecoratorMetadata in tsconfig, fixed it. If you're on NestJS 12, the Vitest config that nest new generates already handles this. I covered that in my NestJS 12 migration guide.

Jest with @swc/jest#

js
// jest.config.cjs
/** @type {import('jest').Config} */
module.exports = {
  testEnvironment: "node",
  testMatch: ["<rootDir>/test/**/*.test.ts"],
  transform: { "^.+\\.ts$": ["@swc/jest"] },
};

This works if SWC compiles your ESM TypeScript to CommonJS, which is what happens when you don't list .ts in extensionsToTreatAsEsm. It broke in three places:

  1. Top-level await and import.meta. My MCP server file had an if (import.meta.url === ...) block with a top-level await to start the server when run directly. As CommonJS, Jest failed to parse it: "await is only valid in async functions and the top level bodies of modules". I moved the entry point to its own main.ts, which is cleaner anyway, and used the same code for all three runners.
  2. Realm mismatches with node:assert. Jest runs each file in a vm context with its own Object.prototype. Objects created by Node's globals, like Response.json() from fetch or structuredClone(), come from the outer realm. assert.deepStrictEqual checks prototypes, so it failed with "Compared values have no visual difference". Jest's own expect().toEqual() and toStrictEqual() passed on the same objects. Vitest's default pool and node:test didn't have the problem. I changed those tests to JSON.parse(await r.text()) for all runners.
  3. jest.mock hoisting. With @swc/jest 0.2.39, a jest.mock() call wasn't hoisted when jest was imported from @jest/globals. The real module ran, and my "must not send email" guard threw. Using the global jest object fixed it, and so did ts-jest, which hoisted the same file correctly.

For native ESM, add extensionsToTreatAsEsm: [".ts"], tell SWC to keep ES modules, and start Node with --experimental-vm-modules. On Node 24.22, Jest 30.5 still printed "VM Modules is an experimental feature" for every worker. In this mode, module mocks use jest.unstable_mockModule() followed by a dynamic import().

js
// jest.esm.config.cjs, run with NODE_OPTIONS=--experimental-vm-modules
module.exports = {
  testEnvironment: "node",
  testMatch: ["<rootDir>/test/**/*.test.ts"],
  extensionsToTreatAsEsm: [".ts"],
  transform: { "^.+\\.ts$": ["@swc/jest", { module: { type: "es6" } }] },
};

Jest with ts-jest#

ts-jest uses the TypeScript compiler, so it type-checks your tests, which explains the 3.1 s run. It also brings tsconfig friction. My project tsconfig uses "module": "nodenext" and verbatimModuleSyntax in a "type": "module" package, and ts-jest's CommonJS mode failed every file with TS1295: "ECMAScript imports and exports cannot be written in a CommonJS file under 'verbatimModuleSyntax'". I needed a separate tsconfig.jest.json with "module": "commonjs", verbatimModuleSyntax: false, and a paths entry for the #t import (Node-style imports aren't resolved with node10 resolution). You also can't move to TypeScript 7 with ts-jest yet, because of the <7 peer range.

node:test#

json
{
  "scripts": {
    "test": "node --test \"test/**/*.test.ts\"",
    "test:watch": "node --test --watch \"test/**/*.test.ts\"",
    "test:cov": "node --test --experimental-test-coverage --test-coverage-include=\"src/**\" \"test/**/*.test.ts\""
  }
}

No dependencies and no config. Node 24 strips types by default, and the test runner finds .ts files from the glob. The price is that your TypeScript has to be erasable: Node removes types but doesn't compile anything. I checked each case on Node 24.22.1:

Syntaxnode file.ts--experimental-transform-types
enum Status { ... }ERR_UNSUPPORTED_TYPESCRIPT_SYNTAXworks
constructor(private readonly repo: Repo)ERR_UNSUPPORTED_TYPESCRIPT_SYNTAXworks
@Injectable() decoratorERR_UNSUPPORTED_TYPESCRIPT_SYNTAXSyntaxError

So a NestJS codebase full of decorators and parameter properties can't run under plain node --test. My orders service is written NestJS-style, with constructor injection, but it assigns dependencies to private fields by hand instead of using parameter properties. That makes it erasable, and "erasableSyntaxOnly": true in tsconfig keeps it that way. You also need .ts extensions in relative imports (allowImportingTsExtensions plus noEmit for tsc) and import type for type-only imports (verbatimModuleSyntax).

Mocking APIs compared#

The same two jobs in each runner: replace an ES module that sends email, and fake a 60-second timer. All three files below passed.

ts
// Vitest: vi.mock is hoisted above the imports
import { vi, it, expect } from "vitest";
import { sendReceipt } from "./receipts.ts";
import { sendEmail } from "./mailer.ts";
vi.mock("./mailer.ts", () => ({ sendEmail: vi.fn(async () => ({ id: "msg_1" })) }));

it("mocks an ES module", async () => {
  expect(await sendReceipt("a@b.co", 7)).toBe("msg_1");
  expect(sendEmail).toHaveBeenCalledWith("a@b.co", "Receipt for order #7");
});
// timers: vi.useFakeTimers(); vi.advanceTimersByTime(60_000);
ts
// Jest (CommonJS output): use the global jest object so jest.mock gets hoisted
import { sendReceipt } from "./receipts.ts";
import { sendEmail } from "./mailer.ts";
jest.mock("./mailer.ts", () => ({ sendEmail: jest.fn(async () => ({ id: "msg_1" })) }));
// timers: jest.useFakeTimers(); jest.advanceTimersByTime(60_000);
ts
// node:test: needs --experimental-test-module-mocks, then a dynamic import
import { mock, it } from "node:test";
import assert from "node:assert/strict";
const sendEmail = mock.fn(async (_to: string, _subject: string) => ({ id: "msg_1" }));
mock.module("./mailer.ts", { exports: { sendEmail } });
const { sendReceipt } = await import("./receipts.ts");

it("mocks an ES module", async () => {
  assert.equal(await sendReceipt("a@b.co", 7), "msg_1");
  assert.deepEqual(sendEmail.mock.calls[0].arguments, ["a@b.co", "Receipt for order #7"]);
});
// timers: mock.timers.enable({ apis: ["setTimeout"] }); mock.timers.tick(60_000);

Vitest has the smoothest module mocking for ESM. Jest's is fine in CommonJS but needs unstable_mockModule and dynamic imports in ESM. Node's mock.module() works, but it's experimental: without the flag, mock.module is not a function. Node 24 also prints a deprecation warning if you use the older namedExports option instead of exports. Node's mock.fn(), mock.method() and mock.timers need no flag and covered everything else I needed. For most service code, constructor injection with hand-written fakes, like this suite uses, avoids module mocking altogether.

Which test runner should you use?#

Pick Vitest for a new TypeScript or ESM project, a Vite or React frontend, or a NestJS 12 app. It needed the least configuration, handled ESM and top-level await without flags, and has the best mocking and watch experience. Its default per-file process isolation is the slowest option at scale, so try pool: "vmThreads" or isolate: false once your suite is stable. Those settings made it the fastest or close to it in my runs.

Keep Jest if you have a large, working Jest suite, especially CommonJS. With @swc/jest it was the fastest default configuration here, and moving to Vitest is mostly an API rename (jest.fn to vi.fn) that buys you little speed unless you also change isolation. Swap ts-jest for @swc/jest and run tsc --noEmit separately: that alone took this suite from 3.13 s to 0.81 s.

Pick node:test for libraries, CLIs and backend code written in erasable TypeScript, where zero dependencies matters. It had the fastest single-file runs (0.18 s) and the fastest full run without isolation (0.51 s). You give up a rich expect, snapshot ergonomics and stable module mocking, and decorator-heavy code won't run. If you're comparing runtimes too, I measured Bun against Node separately, and I covered what changes in TypeScript 7 for build tooling.

Questions