Skip to main content

bun:test vs node:test vs Vitest JavaScript Testing 2026

·PkgPulse Team

bun:test vs node:test vs Vitest: The 2026 State of Zero-Config JavaScript Testing

TL;DR

The testing landscape has fractured into two camps in 2026: runtime-native testing (built into Bun and Node.js, zero dependencies, just run it) vs dedicated test frameworks (Vitest, Jest — richer features, broader ecosystem, some config required). bun:test is the fastest option by a wide margin — the same JavaScript runtime that runs your code also runs the tests, with Jest-compatible APIs. node:test is the Node.js answer — stable since Node.js 18, no npm install needed, but significantly fewer features. Vitest remains the recommended choice for most projects: HMR-based watch mode, UI mode, Storybook integration, and the deepest ecosystem — it just requires Vite as a build tool. The right answer depends on whether you prioritize zero dependencies or rich testing capabilities.

Key Takeaways

  • bun:test is 5-20x faster than Jest/Vitest in benchmarks — Bun's JS engine speed + native test runner; compatible with most Jest APIs so migration is often zero-code-change
  • node:test is the zero-install option for Node.js projects — ships with Node.js 18+, now stable; uses node:assert for assertions, lacks many modern features
  • Vitest is the most feature-rich — watch mode, UI mode, browser mode, TypeScript-first, best ecosystem; requires Vite (5-30 second initial setup)
  • Performance reality: for most projects, test suite time is dominated by I/O (database queries, API calls, file system) not test runner overhead — bun:test's speed matters most for large suites of fast unit tests
  • Jest compatibility: bun:test is ~95% compatible; node:test requires rewriting to the test() + assert API; Vitest is ~99% compatible
  • Recommendation: use Vitest for new projects; consider bun:test if you're already on Bun; use node:test for simple scripts and CLI utilities that shouldn't have npm dependencies

The Three Testing Approaches

Runtime-Native (bun:test, node:test)

The philosophy: your runtime should include a test runner. Zero installation, no config files for simple cases, just write and run:

# Bun
bun test                  # Runs all *.test.ts files
bun test --watch          # Watch mode

# Node.js
node --test               # Runs all *.test.js files
node --test src/**/*.test.js  # Specific files

Dedicated Framework (Vitest, Jest)

The philosophy: testing is complex enough to deserve a specialized tool with a rich ecosystem:

# Install once
npm install -D vitest
# Configure (optional, Vite project = auto-configured)
# Run
npx vitest            # Watch mode
npx vitest run        # Single run
npx vitest --ui       # Browser-based UI

bun:test: The Speed Champion

bun:test is built into Bun and uses Jest's API — describe, test, expect, beforeEach, afterEach, mock.

Setup: Literally None

# Write a test file
cat > calculator.test.ts << 'EOF'
import { test, expect, describe } from 'bun:test'
import { add, subtract } from './calculator'

describe('Calculator', () => {
  test('adds numbers', () => {
    expect(add(2, 3)).toBe(5)
  })

  test('subtracts numbers', () => {
    expect(subtract(10, 4)).toBe(6)
  })
})
EOF

# Run it — no config, no install
bun test

Jest-Compatible API

bun:test is designed as a Jest drop-in replacement for most tests:

// This runs unchanged on Jest, Vitest, AND bun:test
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
// or just: global describe/test/expect are available without import

describe('UserService', () => {
  const mockEmailService = mock(() => Promise.resolve())

  beforeEach(() => {
    mockEmailService.mockClear()
  })

  test('sends welcome email on registration', async () => {
    const userService = new UserService({ email: mockEmailService })
    await userService.register({ email: 'user@example.com', name: 'Alice' })

    expect(mockEmailService).toHaveBeenCalledOnce()
    expect(mockEmailService).toHaveBeenCalledWith({
      to: 'user@example.com',
      template: 'welcome',
    })
  })

  test('throws on duplicate email', async () => {
    await expect(
      userService.register({ email: 'existing@example.com', name: 'Bob' })
    ).rejects.toThrow('Email already registered')
  })
})

Performance: The Main Selling Point

In benchmarks across various test suites:

Suite SizeJestVitestbun:test
100 tests (unit)3.2s1.8s0.4s
500 tests (unit)12.1s6.3s1.2s
1000 tests (unit)31s15s3.1s
1000 tests (with DB)45s28s22s

The DB-heavy scenario shows why "5-20x faster" is nuanced — when tests are I/O-bound rather than CPU-bound, the runtime speedup matters less.

bun:test Gotchas

Not everything is Jest-compatible:

// ✅ Works in bun:test
jest.fn()                          // Use mock() instead
jest.mock('./module')               // Use mock.module() instead
jest.spyOn(object, 'method')       // Available

// ⚠️ Different API
import { mock } from 'bun:test'
mock.module('./analytics', () => ({
  track: mock(() => {}),
}))

// ❌ Not available in bun:test
jest.useFakeTimers()               // Use Bun's --fake-timers flag
jest.runAllTimers()                // CLI-only, not per-test

// ✅ Snapshot testing works
expect(component).toMatchSnapshot()

node:test: Built Into Node.js

node:test became stable in Node.js 18 and is now the official Node.js test runner. No npm install, no configuration:

import { test, describe, before, after, mock } from 'node:test'
import assert from 'node:assert/strict'

describe('UserService', () => {
  let userService: UserService

  before(() => {
    userService = new UserService({ db: testDb })
  })

  after(async () => {
    await testDb.cleanup()
  })

  test('creates a user successfully', async () => {
    const user = await userService.create({
      name: 'Alice',
      email: 'alice@example.com',
    })

    assert.equal(user.name, 'Alice')
    assert.equal(user.email, 'alice@example.com')
    assert.ok(user.id, 'User should have an ID')
  })

  test('rejects invalid email', async () => {
    await assert.rejects(
      () => userService.create({ name: 'Bob', email: 'not-an-email' }),
      { message: /invalid email/i }
    )
  })
})

Running node:test

# Single file
node --test user.test.ts

# All test files recursively
node --test '**/*.test.{js,ts}'

# Watch mode (Node.js 22+)
node --test --watch

# Coverage (built-in since Node.js 22)
node --test --experimental-test-coverage

node:test Limitations

node:test is deliberately minimal:

// ❌ No expect().toBe() — use assert
// ❌ No watch mode with HMR
// ❌ No UI mode
// ❌ No TypeScript support without ts-node/tsx
// ❌ No snapshot testing
// ❌ No module mocking (use --import hooks manually)
// ❌ No concurrent test execution across files by default
// ❌ No coverage UI — just JSON/TAP output

// ✅ What it does well:
// → Zero dependencies
// → TAP output (works with any TAP reporter)
// → Subtest support
// → Built-in diagnostics

For CLI utilities, scripts, and simple modules that you don't want to add testing infrastructure to, node:test is ideal. For complex applications, its limitations become friction.


Vitest: The Feature-Rich Default

Vitest (7M+ weekly downloads) requires Vite but provides the most complete testing experience:

import { describe, test, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

// TypeScript support, module mocking, happy-dom/JSDOM, browser mode, UI mode
vi.mock('./analytics', () => ({
  track: vi.fn(),
}))

describe('SearchInput', () => {
  test('calls onChange debounced', async () => {
    vi.useFakeTimers()
    const onChange = vi.fn()

    render(<SearchInput onChange={onChange} debounceMs={300} />)

    const input = screen.getByRole('textbox')
    await userEvent.type(input, 'hello')

    // No calls yet — debounced
    expect(onChange).not.toHaveBeenCalled()

    // Advance timers
    vi.advanceTimersByTime(300)
    expect(onChange).toHaveBeenCalledOnce()
    expect(onChange).toHaveBeenCalledWith('hello')

    vi.useRealTimers()
  })
})

Vitest's Unique Features

UI Mode — browser-based test runner UI:

npx vitest --ui
# Opens browser UI with:
# - Test results in real-time
# - Source code side-by-side
# - Coverage report
# - Snapshot inspector

Workspace support — run different environments in parallel:

// vitest.config.ts
export default defineConfig({
  test: {
    workspace: [
      { test: { name: 'unit', environment: 'jsdom' } },
      { test: { name: 'browser', browser: { enabled: true, name: 'chromium' } } },
      { test: { name: 'node', environment: 'node' } },
    ],
  },
})

Migration Paths

From Jest to bun:test (near-zero for most)

# Try it first
bun test  # May just work if your tests don't use Jest-specific features

# Common fixes:
# jest.fn() → mock()
# jest.mock() → mock.module()
# jest.useFakeTimers() → run with --fake-timers flag

From Jest to Vitest (~30 min)

npm install -D vitest
# Replace jest.config.js → vitest.config.ts
# Replace import { jest } with import { vi }
# vi.fn(), vi.mock(), vi.spyOn() are all identical

From Jest/Vitest to node:test (significant rewrite)

node:test uses assert instead of expect — the entire assertion style is different. Only worth it if eliminating npm dependencies is a hard requirement.


Coverage and CI Integration

Code coverage is a key differentiator between the three options.

bun:test Coverage

# Built-in coverage (uses V8 coverage)
bun test --coverage

# Output:
# ------------------|---------|----------|---------|---------|
# File              | % Stmts | % Branch | % Funcs | % Lines |
# ------------------|---------|----------|---------|---------|
# src/calculator.ts |   95.2% |    87.5% |   100%  |   95.2% |
# src/utils.ts      |   88.0% |    75.0% |    90%  |   88.0% |

# Coverage thresholds (bun 1.1+)
bun test --coverage --coverage-threshold 80

bun:test coverage uses V8's native coverage — fast and accurate. HTML reports aren't built-in but output can be consumed by tools like c8 for HTML generation.

node:test Coverage

# Experimental coverage (Node.js 22+)
node --test --experimental-test-coverage

# With LCOV output for external tools
node --test --experimental-test-coverage --test-reporter=lcov > coverage.lcov

node:test coverage is still experimental — functional but not production-ready for detailed reports.

Vitest Coverage

# Install the V8 provider (fastest)
npm install -D @vitest/coverage-v8

# Run with coverage
npx vitest run --coverage

# Configure thresholds
// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html', 'lcov'],
      thresholds: {
        global: {
          statements: 80,
          branches: 75,
          functions: 80,
          lines: 80,
        },
      },
      exclude: ['**/node_modules/**', '**/test/**'],
    },
  },
})

Vitest produces the most comprehensive coverage reports — HTML with line-by-line highlighting, LCOV for CI/CD integration, JSON for custom tooling.

CI/CD Integration

All three work in CI with minimal setup:

# GitHub Actions — bun:test
- name: Run tests
  run: bun test --coverage

# GitHub Actions — node:test
- name: Run tests
  run: node --test

# GitHub Actions — Vitest
- name: Run tests
  run: npx vitest run --reporter=github-actions --coverage
# The github-actions reporter produces inline PR annotations

Vitest's --reporter=github-actions is a notable feature — it posts test results as inline annotations on PRs, making failures immediately visible in code review.


Snapshot Testing

Snapshot testing support varies significantly:

// Vitest — full snapshot support
test('renders correctly', () => {
  const { container } = render(<Button>Click me</Button>)
  expect(container).toMatchSnapshot()
  expect(container).toMatchInlineSnapshot(`
    <div>
      <button class="btn btn-primary">Click me</button>
    </div>
  `)
})

// bun:test — snapshot support added in Bun 1.0
test('renders correctly', () => {
  expect(renderToString(<Button>Click me</Button>)).toMatchSnapshot()
  // Inline snapshots not yet supported
})

// node:test — no snapshot support
// Must use external libraries or implement manually

For snapshot-heavy test suites (React component rendering, API response shapes), Vitest or bun:test are required.


Decision Guide

SituationRecommendation
New Node.js project with ViteVitest
New Bun projectbun:test
CLI utility (no npm deps)node:test
Migrating from JestVitest (easiest) or bun:test
Need browser testingVitest browser mode
Need UI mode / coverage UIVitest
Max performance (pure unit tests)bun:test
Enterprise, must stay on Node.jsVitest or node:test
Monorepo with mixed frameworksVitest (workspace support)

Methodology

  • Performance benchmarks from community benchmarks (github.com/nicolo-ribaudo/bun-tests-benchmark), March 2026
  • Download data from npmjs.com API weekly averages
  • Versions: Bun 1.2.x, Node.js 22.x (LTS), Vitest 2.x
  • Sources: Bun documentation (bun.sh/docs/cli/test), Node.js documentation (nodejs.org/api/test.html), Vitest documentation (vitest.dev)

Compare Vitest, bun:test, and node:test on PkgPulse — download trends, ecosystem health, and version activity.

Related: node:test vs Vitest vs Jest Native Test Runner 2026 · Vitest Browser Mode vs Playwright Component Testing 2026 · Playwright vs Cypress vs Puppeteer E2E Testing 2026

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.