AI & ML

Vitest vs Jest 2026: The Migration Guide with Real Benchmarks

· 5 min read
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

Vitest vs Jest 2026 Comparison

DimensionJest 30 (SWC)Vitest 3.x
Cold Start (50k tests, CI)14 min 22 s4 min 51 s (-66%)
Watch Mode (single file change)3,400 ms380 ms (-89%)
Native ESM SupportExperimental flags requiredBuilt-in, no configuration
Migration Effort (50k tests)N/A (incumbent)~2 weeks for 3 engineers

Jest has powered JavaScript testing for nearly a decade, but teams running large Vitest vs Jest 2026 comparisons keep landing on the same conclusion: the performance gap is real, the ESM story is night and day, and the JavaScript testing framework comparison increasingly favors Vitest for anything greenfield.

Table of Contents

Why the Jest-to-Vitest Conversation Hit a Tipping Point

Two forces collided this year. First, Jest 30 shipped with incremental improvements but kept its CJS-first architecture and experimental ESM flags largely intact. The Jest docs still maintain a dedicated ECMAScript Modules page separate from the main "Getting Started" guide, reinforcing that ESM remains a second-class citizen requiring explicit configuration. Second, Vitest 3.x reached a level of maturity and ecosystem adoption that made it hard to ignore. Nuxt, SvelteKit, Astro, and Angular's latest tooling all ship with or recommend Vitest out of the box.

This is not a hot take about which framework is "better." It is a data-backed migration guide built from a real production migration, covering benchmark numbers, a step-by-step playbook, and the ten gotchas that official documentation glosses over.

The Testing Ecosystem in 2026: What Changed

Jest 30: What's New and What's Still Missing

Jest 30 brought welcome improvements: performance work on jest-haste-map, a refreshed snapshot format, and continued iteration on ESM support. But ESM remains gated behind experimental flags and separate API surfaces. Mocking an ESM module still requires jest.unstable_mockModule paired with dynamic await import() calls rather than the familiar synchronous jest.mock(). The transform pipeline, built around Babel by default (or @swc/jest for speed), still processes every file before execution. And moduleNameMapper, the escape hatch teams rely on for path aliasing, grows increasingly fragile as more packages ship pure ESM.

Vitest 3.x: Native ESM, Vite-Powered, Growing Ecosystem

Vitest 3.x leans on Vite's transform pipeline, which means TypeScript, JSX, and ESM work without configuring a separate transform chain. Browser mode has stabilized, workspace support handles monorepos natively, and inline snapshots got a polish pass. The critical architectural difference: Vitest does not simulate ESM through compatibility shims. It runs your code through Vite's module pipeline in Node (or in a real browser via browser mode), so import.meta, top-level await, and .mjs files just work. No flags. No workarounds.

Ecosystem Signals That Matter

The State of JS 2024 survey showed Vitest overtaking Jest in developer satisfaction scores, and npm weekly download trends show Vitest's growth curve steepening while Jest's flattens. When the frameworks your app is built on recommend a testing tool, that signal carries weight.

Head-to-Head Benchmark: 50,000 Tests in Production

Test Environment and Methodology

We tested against our production monorepo: 12 packages, roughly 50,000 tests spanning React component tests (Testing Library + jsdom), Node service integration tests, and pure unit tests. Hardware was standardized: GitHub Actions large runners (8-core, 16 GB RAM) for CI numbers, and a 2023 MacBook Pro (M3 Pro, 36 GB RAM) for local development numbers. Each measurement is the median of five consecutive runs. We disabled --forceExit in Jest to ensure clean shutdowns. Both configurations used jsdom as the test environment for UI packages and Node for service packages.

Jest ran version 30.x with @swc/jest as the transformer. Vitest ran version 3.x with the default Vite configuration and no additional optimizer tweaks.

Benchmark Results

Metric Jest 30 (SWC) Vitest 3.x Delta
Cold start (full suite, CI) 14 min 22 s 4 min 51 s -66%
Watch mode (single file change) 3,400 ms 380 ms -89%
CI pipeline (8-shard parallel) 6 min 10 s 2 min 48 s -55%
Memory peak (full suite) 5.8 GB 3.1 GB -47%
TypeScript transform overhead 48 s 9 s -81%

We expected the cold start improvement to be big. A 66% reduction still caught us off guard. The watch mode difference was even more dramatic. On a Friday afternoon I changed a single utility function, saved the file, and Vitest re-ran the three affected test files in 380 ms. The equivalent Jest watch cycle took 3.4 seconds because Jest's --onlyChanged relies on git diff heuristics rather than a module dependency graph.

On a Friday afternoon I changed a single utility function, saved the file, and Vitest re-ran the three affected test files in 380 ms.

Where Vitest Wins Big

Cold start and file discovery account for the largest chunk of the performance gap. Jest's jest-haste-map crawls the entire project upfront to build its module map. Vite's on-demand transform means Vitest only processes files as they get imported during test execution. For watch mode, Vitest tracks the import graph (similar to Vite's HMR graph in dev), so changing a utility file triggers only the tests that transitively import it.

Native ESM support also eliminates an entire category of configuration headaches. We had 14 moduleNameMapper entries in Jest solely to handle ESM-only packages. In Vitest, we deleted all of them.

Where Jest Still Holds Up

Jest's snapshot serializers remain more mature for complex component trees, especially custom serializers that strip volatile props. Jest's --shard flag (jest --shard=1/3) is battle-hardened for splitting massive suites across CI runners. Vitest supports sharding, but we found Jest's implementation slightly more predictable for suites above 80,000 tests. Jest's fake timer implementation also handled edge cases in our cron scheduling library more reliably; we had to adjust two timer-dependent tests after migrating to vi.useFakeTimers().

Side-by-Side Configuration

// jest.config.ts (monorepo root)
export default {
  projects: [
    '<rootDir>/packages/api',
    '<rootDir>/packages/web',
    '<rootDir>/packages/shared',
  ],
  transform: {
    '^.+\\.(ts|tsx)$': ['@swc/jest'],
  },
  moduleNameMapper: {
    '^@shared/(.*)$': '<rootDir>/packages/shared/src/$1',
  },
  testEnvironment: 'node',
  coverageProvider: 'v8',
};
// vitest.workspace.ts (monorepo root)
export default [
  'packages/api',
  'packages/web',
  'packages/shared',
];

// vitest.config.ts (shared base)
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';

export default defineConfig({
  resolve: {
    alias: {
      '@shared': resolve(__dirname, 'packages/shared/src'),
    },
  },
  test: {
    environment: 'node',
    coverage: { provider: 'v8' },
  },
});

Notice the absence of a transform key in Vitest. Vite handles TypeScript and JSX natively through esbuild.

The Migration Playbook: Step by Step

Step 1: Audit Your Jest Config Complexity

Before installing anything, inventory your Jest configuration. Open your jest.config.ts and annotate every non-default field:

// jest.config.ts — annotated for migration
export default {
  // MIGRATION: maps to test.environment in vitest.config.ts
  testEnvironment: 'jsdom',

  // MIGRATION: maps to test.setupFiles in vitest.config.ts
  setupFilesAfterEnv: ['./test/setup.ts'],

  // MIGRATION: replace with resolve.alias in vite config
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\\.css$': 'identity-obj-proxy',
  },

  // MIGRATION: not needed — Vite handles TS/JSX natively
  transform: {
    '^.+\\.tsx?$': ['@swc/jest'],
  },

  // MIGRATION: maps to test.coverage.thresholds
  coverageThreshold: {
    global: { branches: 80, lines: 90 },
  },
};

Grep your test files for Jest-specific APIs: jest.fn(), jest.spyOn(), jest.mock(), jest.useFakeTimers(), and jest.requireActual(). Count them. In our codebase, we found 1,847 jest.mock() calls and 3,200+ jest.fn() references. That number determines whether a codemod saves you days or weeks.

Step 2: Install Vitest and Run Codemods

npm install -D vitest @vitest/coverage-v8 @vitest/ui

# Note: install the codemod tool first if not already available:
#   npm install -D jest-to-vitest

npx jest-to-vitest --write

The jest-to-vitest community codemod handles the mechanical renames: jest.fn() becomes vi.fn(), jest.spyOn() becomes vi.spyOn(), jest.mock() becomes vi.mock(). Here is a representative before and after:

// BEFORE (Jest)
import { render, screen } from '@testing-library/react';
import { UserCard } from './UserCard';
import * as api from '../api/users';

jest.mock('../api/users');

describe('UserCard', () => {
  it('displays the user name', async () => {
    jest.spyOn(api, 'fetchUser').mockResolvedValue({ name: 'Alice' });
    render(<UserCard id="1" />);
    expect(await screen.findByText('Alice')).toBeInTheDocument();
  });
});
// AFTER (Vitest, post-codemod)
// Note: if globals mode is not enabled, add: import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { UserCard } from './UserCard';
import * as api from '../api/users';

vi.mock('../api/users');

describe('UserCard', () => {
  it('displays the user name', async () => {
    vi.spyOn(api, 'fetchUser').mockResolvedValue({ name: 'Alice' });
    render(<UserCard id="1" />);
    expect(await screen.findByText('Alice')).toBeInTheDocument();
  });
});

The codemod handles these renames reliably. What it misses: factory function variable references inside vi.mock(), custom moduleNameMapper replacements, jest-dom import paths, and done() callback refactoring to async/await. Those require manual intervention.

Step 3: Handle the Mock Hoisting Difference (Biggest Gotcha)

This is the single most common migration failure. Both Jest and Vitest hoist mock() calls to the top of the file, but Vitest's hoisting has different scoping rules for variables referenced inside factory functions.

// BROKEN after migration
const mockFetch = vi.fn(); // This is undefined when vi.mock runs

vi.mock('../api/users', () => ({
  fetchUser: mockFetch, // ❌ mockFetch is undefined here
}));

The fix is vi.hoisted(), which explicitly creates values available in the hoisted scope:

// FIXED with vi.hoisted()
const { mockFetch } = vi.hoisted(() => ({
  mockFetch: vi.fn(),
}));

vi.mock('../api/users', () => ({
  fetchUser: mockFetch, // ✅ mockFetch is defined
}));

I found that roughly 40% of our vi.mock() calls with factory functions needed this treatment. When I ran our packages/auth tests after the codemod, 23 of 58 mock factories produced undefined reference errors until I refactored them with vi.hoisted().

I found that roughly 40% of our vi.mock() calls with factory functions needed this treatment.

Step 4: Migrate Configuration File

Key mappings from Jest config to Vitest config:

  • testEnvironmenttest.environment ('jsdom', 'happy-dom', 'node')
  • setupFilesAfterEnvtest.setupFiles
  • moduleNameMapperresolve.alias in the Vite config
  • coverageThresholdtest.coverage.thresholds
  • transform → usually removed entirely; Vite handles it
  • globals: true in Vitest test config enables describe/it/expect without imports (mimicking Jest's globals), though explicit imports from vitest are recommended for type safety

If you enable globals: true, add "types": ["vitest/globals"] to your tsconfig.json to avoid TypeScript errors.

Step 5: Update CI Pipeline

# BEFORE: Jest CI job
- name: Test
  run: npx jest --ci --coverage --maxWorkers=4

# AFTER: Vitest CI job
- name: Test
  run: npx vitest run --coverage --reporter=verbose

Vitest automatically uses worker threads matching available cores. For coverage, v8 is the default provider and is significantly faster than Istanbul. Use Istanbul (coverage.provider: 'istanbul') only if you need branch coverage accuracy for complex conditional expressions where V8's instrumentation sometimes misreports. This approach also fails when you have native Node addons in your test path that are incompatible with V8 coverage instrumentation, in which case Istanbul is the safer choice.

The 10 Migration Gotchas Nobody Warns You About

1. vi.mock() Hoisting Scoping. Covered above. Use vi.hoisted(). This accounts for the majority of red test suites post-migration.

2. moduleNameMapper Regex Syntax Differences. Jest uses <rootDir> tokens and specific regex escaping. Vitest uses Vite's resolve.alias, which accepts either string paths or regex. Copy-pasting Jest regex patterns into resolve.alias silently breaks.

3. Fake Timers API Defaults. vi.useFakeTimers() defaults differ from jest.useFakeTimers(). Vitest uses @sinonjs/fake-timers under the hood and fakes Date by default; Jest's modern fake timers (also built on @sinonjs/fake-timers) do too, but advanceTimersByTime behavior with setImmediate can diverge. Test timer-heavy code manually.

4. Snapshot File Location. Jest stores snapshots in __snapshots__/ adjacent to test files. Vitest does the same by default, but the file extension and format differ slightly. Run vitest run --update to regenerate baselines after migration.

5. done() Callback Patterns. Neither framework has formally deprecated done(), but Vitest handles it less gracefully when mixed with async functions. Refactor to async/await to avoid silent timeout failures.

6. Global expect Type Conflicts. If your project imports Chai directly (Vitest uses Chai-based expect internally), TypeScript can surface duplicate type declarations. Exclude Chai types from your tsconfig or use Vitest's typed expect explicitly.

7. jest-dom Matchers. Replace @testing-library/jest-dom with @testing-library/jest-dom/vitest in your setup file. The import path change is one line but blocks the entire DOM assertion suite if you miss it. Note: this dedicated Vitest entry point landed in @testing-library/jest-dom v6.6.0, so make sure you're on at least that version.

8. Dynamic import() in Mocked Modules. Jest's jest.unstable_mockModule plus dynamic import() pattern does not map 1:1 to vi.mock(). Vitest handles ESM mocking natively without the unstable API, but factory functions run at a different lifecycle point.

9. --forceExit Does Not Exist in Vitest. If your Jest suite required --forceExit to terminate, you have open handles (database connections, undrained streams). Vitest surfaces these as hanging test warnings. Fix the root cause rather than masking it.

10. Monorepo Workspace Config Is Not 1:1 with Jest Projects. Jest's projects array in config does not directly translate to vitest.workspace.ts. Vitest workspaces define glob patterns or directory paths, and each workspace can override the base config. Expect 30 minutes of manual tuning for a 10+ package monorepo.

Migration Checklist (Copy-Paste Ready)

- [ ] Audit `jest.config.*` for custom transforms, moduleNameMapper, setupFiles
- [ ] Install `vitest`, `@vitest/coverage-v8`, `@vitest/ui`
- [ ] Run `jest-to-vitest` codemod across all test files
- [ ] Replace `jest.mock()` factory variable references with `vi.hoisted()`
- [ ] Migrate `jest.config.*` to `vitest.config.*` (or embed in `vite.config.*`)
- [ ] Switch `@testing-library/jest-dom` to `@testing-library/jest-dom/vitest`
- [ ] Update `setupFiles` paths and verify setup file content works under Vitest
- [ ] Add `"types": ["vitest/globals"]` to tsconfig (if using globals mode)
- [ ] Ensure Vite plugins (React, Svelte, Vue) are loaded in test config
- [ ] Replace `done()` callbacks with async/await patterns
- [ ] Update CI workflow: `npx jest --ci` → `npx vitest run`
- [ ] Run full suite, review snapshot diffs, regenerate baselines
- [ ] Compare coverage output, configure `test.coverage.thresholds`
- [ ] Remove Jest dependencies (`jest`, `@swc/jest`, `ts-jest`, etc.) from package.json
- [ ] Update `CONTRIBUTING.md` and developer onboarding docs

When You Should NOT Migrate (Yet)

Not every team should move right now. If you have invested heavily in Jest's --shard flag for suites exceeding 100,000 tests and your CI orchestration depends on that exact sharding behavior, Vitest's sharding works but has fewer miles on it at that scale. If your codebase relies extensively on jest.mock() factory patterns with complex variable closures and your team lacks the bandwidth for manual refactoring of hundreds of mock factories, the migration cost is real. Legacy enterprise projects locked to CommonJS-only environments with no Vite in the toolchain exist, and forcing Vite into that stack creates more problems than it solves. And if your CI times are already acceptable and developers are not complaining about watch mode speed, migration for its own sake carries a cost that may not pay for itself.

Should You Migrate in 2026?

For new projects, Vitest is the default choice. Configuration is simpler, ESM works without ceremony, and the performance advantage compounds as the test suite grows.

Got an existing Jest project with fewer than 5,000 tests and a straightforward config? I've seen that migration take a single developer roughly a day, including codemod runs, manual fixups, and CI updates. The watch mode improvement alone justifies the time.

For large Jest monorepos with 10,000+ tests, plan a phased migration, one package at a time. Our 50,000-test migration took three engineers two weeks of focused work, and the CI pipeline went from 14 minutes to under 5. That freed up roughly 45 minutes of developer wait time per pull request across a team of 30 engineers.

Our 50,000-test migration took three engineers two weeks of focused work, and the CI pipeline went from 14 minutes to under 5.

Jest is not dead. It remains a capable, well-documented testing framework with a massive ecosystem. But Vitest is where the momentum in JavaScript testing lives, and the gap widens with each release. Every month you wait, more Jest-specific patterns pile up in your codebase, making the eventual migration harder. The benchmarks say move. The developer experience says move. The ecosystem says move. The only question is when, and for most teams, the answer is now.