Vitest vs node:test: An Honest 2026 Comparison
Most comparisons of these two are out of date. node:test is not the minimal runner it was at launch: it has stable mocking, stable snapshots, coverage, watch mode, and test tags. The real gap is narrower and more specific than "features vs zero dependencies".
The usual framing is that Vitest is the full-featured framework and node:test is the bare-bones built-in you settle for when you want zero dependencies. That was accurate in 2023. It is not accurate now, and choosing on it will lead you to add a dependency you may not need.
As of Node 20 the built-in runner is marked Stable, not experimental. It has full mocking of functions, methods, properties, getters, setters, modules, and timers. Snapshot testing stabilised in v22.3.0. It ships spec, tap, dot, junit, and lcov reporters, configurable concurrency, watch mode, test filtering, randomised ordering with a replayable seed, and a rerun-failures flag. Node 26.2.0 added test tags.
Vitest is still meaningfully ahead, but on a shorter and much more specific list than "features". This page compares what each one actually does today, then gives you a decision rule based on what you are testing rather than how many features are on a marketing page.
What each one actually supports in 2026
| Capability | node:test | Vitest |
|---|---|---|
| Stability | Stable since Node 20 | Stable, current major v4 |
| Dependencies | None, ships with Node | Vitest plus Vite |
| Mocking | Functions, methods, properties, getters/setters, modules, timers | Jest-compatible mocking utilities |
| Snapshots | Stable since v22.3.0 | Jest-compatible snapshots |
| Coverage | V8-based, still flagged experimental | v8 or Istanbul providers |
| Watch mode | Yes (experimental flag) | Yes, HMR-style rerun |
| TypeScript | Native type stripping | Out of the box via Vite |
| JSX and component tests | No | Vue, React, Svelte and others |
| Browser mode | No | Yes |
| CI reporters | spec, tap, dot, junit, lcov built in | Multiple, plus sharding for distributed CI |
| Benchmarking | No | Yes, via Tinybench |
| Type testing | No | Yes, via expect-type |
| Test tags | Yes, since Node 26.2.0 | Via filtering |
What node:test can do that most comparisons say it cannot
If your mental model of the built-in runner is "test, subtests, and node:assert", it is roughly three years stale. The current surface is substantial.
--test-reporter=junitand=lcovare built in, so CI test reporting and coverage upload need no extra packages.--test-concurrency=Ncontrols parallel test files; concurrency is not an afterthought.--test-randomizewith--test-random-seedmakes order-dependence reproducible instead of mysterious.--test-rerun-failures <state-file>reruns only what failed, which is a meaningful CI optimisation.--experimental-test-coverageproduces V8 coverage without instrumenting your build.
import { test, describe, it, mock, snapshot } from 'node:test';
import assert from 'node:assert';
// module mocking, not just function mocking
mock.module('./payments.js', {
namedExports: { charge: mock.fn(() => ({ ok: true })) },
});
// timer mocking
mock.timers.enable({ apis: ['setTimeout', 'Date'] });
describe('checkout', () => {
it('charges once', async (t) => {
const spy = t.mock.method(cart, 'total');
await checkout(cart);
assert.strictEqual(spy.mock.callCount(), 1);
});
});What Vitest still does that node:test does not
The remaining gap is real, and it is almost entirely about the browser and the build pipeline rather than about test-running itself.
- Component testing. Vue, React, Svelte and other component tests need a transform pipeline and a DOM. Vitest inherits both from Vite; node:test has neither.
- Browser mode. Running the suite in a real browser rather than a simulated DOM has no built-in equivalent.
- JSX and framework transforms. Vitest reuses your existing Vite config, resolvers, and plugins, so tests see the same module graph as your app. Reproducing that under node:test means building it yourself.
- Benchmarking and type testing. Tinybench-backed benchmarks and expect-type assertions have no built-in counterpart.
- Sharding. Vitest supports splitting a suite across CI machines natively, which matters once a suite is long enough to need it.
- Jest compatibility. The Jest-compatible
expectand mocking API makes migrating an existing Jest suite mostly mechanical.
The decision rule: what are you testing?
The honest split is not features against dependencies. It is whether your tests need a browser-shaped environment and a build transform.
| What you are testing | Pick | Why |
|---|---|---|
| A published npm library | node:test | Zero dependencies in your test path, and nothing to keep on a release cadence |
| A backend service or CLI | node:test | No DOM, no transforms; the built-in surface covers it |
| A React, Vue, or Svelte app | Vitest | Component tests need the Vite transform pipeline and a DOM |
| Anything already on Vite | Vitest | Test config is your existing app config; no second module graph to maintain |
| A Jest suite you want to move | Vitest | Jest-compatible expect and mocks make it mostly mechanical |
| A suite that needs benchmarks or type tests | Vitest | No built-in equivalent exists |
| A monorepo with both | Both | Runner choice is per package; there is no rule against mixing |
Startup cost and CI time
The practical argument for the built-in runner in CI is that there is nothing to install before you can run tests. On a cold CI job, npm ci for a test framework and its transform chain is real wall-clock time on every run, and it is time spent before a single assertion executes.
- node:test adds nothing to install and nothing to resolve;
node --testruns against the Node already on the runner. - Vitest pulls Vitest plus Vite plus their transitive dependencies, and warms a transform pipeline on first run.
- Against that, Vitest sharding can cut total wall-clock on a large suite far more than install time costs you.
Running each one in CI
# node:test - nothing to install
- run: node --test --experimental-test-coverage \
--test-reporter=junit --test-reporter-destination=junit.xml \
--test-reporter=lcov --test-reporter-destination=lcov.info
# Vitest
- run: npm ci
- run: npx vitest run --coverage --reporter=junit --outputFile=junit.xml
# Vitest, sharded across 4 machines
- run: npx vitest run --shard=${{ matrix.shard }}/4Can you migrate between them?
Partly, and the direction matters. Moving from Vitest to node:test is mechanical for plain unit tests: the describe/it shape is the same, and the main work is rewriting expect(...) assertions to node:assert and remapping mocking calls. Moving component tests is not a rewrite, it is a rebuild, because the transform pipeline and DOM have no built-in equivalent.
- Assertions:
expect(a).toBe(b)becomesassert.strictEqual(a, b). Mostly find-and-replace, with care around deep equality. - Mocks:
vi.fn()becomesmock.fn();vi.mock()becomesmock.module(), whose API is not identical. - Snapshots: both have them, but the file formats differ, so expect to regenerate rather than port.
- Component tests: no path. These stay on Vitest.
The switching cost is mostly in the parts nobody lists
- Assertions and mocks usually port mechanically when the target implements a compatible API; custom transformers and framework plugins do not.
- Snapshot formats differ between runners, so plan to regenerate and review rather than port.
- Run both suites in parallel in CI for a period and diff the results. A migration that changes which tests fail is not a migration, it is a regression you have not found yet.
- Coverage numbers move on a runner change even when the tests do not, because instrumentation differs. Re-baseline any coverage gate deliberately.
The verdict
If you are writing a library, a backend service, or a CLI, start with node:test. In 2026 it covers mocking, snapshots, coverage, watch, concurrency, and CI reporters, and it costs you nothing to install and nothing to maintain. The old reason to skip it no longer holds.
If you are testing components, need a browser or DOM, are already on Vite, or are migrating an existing Jest suite, use Vitest. Its advantage is genuine but specific: it owns the build pipeline and the browser, which is exactly what the built-in runner does not attempt.
The one constraint worth checking before committing to node:test is coverage, which remains behind --experimental-test-coverage. If experimental flags are not allowed in your CI, that decides it on its own.
Frequently asked questions
Is the Node built-in test runner production ready?
node:test has been marked Stable since Node 20, not experimental. Snapshot testing stabilised in v22.3.0 and global setup and teardown landed in v24. The one part still behind an experimental flag is code coverage, via --experimental-test-coverage.Does node:test support mocking?
mock.module(), plus timers including setTimeout, setInterval, and Date through mock.timers.Can node:test run TypeScript?
--no-strip-types extends the default test-file patterns to include .ts, .mts, and .cts. You do not need a separate transform step for straightforward TypeScript, though anything relying on Vite plugins or path aliases still needs configuration.Is Vitest faster than node:test?
Can I use node:test for React component tests?
Does node:test work with CI test reporting and coverage tools?
junit and lcov reporters, so JUnit XML for test reporting and lcov for coverage upload both work with no additional packages. That removes what used to be the strongest CI argument against it.How do I migrate from Vitest to node:test?
describe/it structure carries over, expect(...) assertions become node:assert calls, and vi.fn()/vi.mock() become mock.fn()/mock.module(). Snapshot formats differ so plan to regenerate them. Component tests do not migrate and should stay on Vitest.