Migrating from Jest to Vitest 4: A Complete 2026 Guide
Current Situation Analysis
Modern TypeScript and ESM-first projects have outgrown the traditional test runner architecture. For years, Jest dominated the ecosystem, but its design predates the widespread adoption of native ES modules, SWC/esbuild compilation, and Vite's plugin ecosystem. Running Jest on a modern TypeScript codebase requires injecting transformation layers like ts-jest or babel-jest. These layers introduce configuration drift, obscure stack traces, and significantly inflate cold-start times.
The problem is frequently overlooked because test suites are treated as static infrastructure. Teams assume that if tests pass, the pipeline is healthy. In reality, configuration complexity compounds silently. Every new transformer, resolver, or environment override adds a layer of indirection that slows down developer feedback loops and increases CI compute costs. Benchmarks consistently show Jest requiring 3β8x longer execution times than modern alternatives on identical codebases, but the hidden cost is actually configuration maintenance. A typical Jest + TypeScript setup pulls over 100 transitive dependencies, whereas Vitest 4 resolves the same toolchain with roughly half that footprint by leveraging Vite's native compilation pipeline.
This architectural mismatch becomes critical when teams attempt to adopt modern testing paradigms like in-browser DOM testing, parallel CI execution, or monorepo workspace isolation. Jest's plugin system struggles to keep pace with these requirements without heavy third-party glue code. Vitest 4 addresses this by unifying the transformation, mocking, and execution layers under a single, ESM-native runtime.
WOW Moment: Key Findings
The shift from Jest to Vitest 4 isn't just a version upgrade; it's a fundamental change in how test pipelines are architected. The following comparison highlights the operational differences between a traditional Jest setup (with ts-jest) and a native Vitest 4 configuration.
| Approach | Config Complexity | TypeScript Handling | Mock API & Hoisting | Browser Testing | Package Footprint |
|---|---|---|---|---|---|
| Jest + ts-jest | High (transformers, resolvers, environments) | Requires explicit transformer plugin | Synchronous jest.mock(), limited ESM support |
JSDOM simulation only (requires jest-environment-jsdom) |
~100+ transitive deps |
| Vitest 4 | Low (single vitest.config.ts, Vite-native) |
Native via esbuild/SWC, zero extra setup | Async vi.mock(), full ESM/CJS interop |
Stable native Chromium/Firefox via Playwright | ~50 transitive deps |
Why this matters: Vitest 4 eliminates the transformation bottleneck by reusing Vite's compilation graph. This means TypeScript, JSX, and CSS modules are processed identically in both dev and test environments. The stable Browser Mode removes the need for JSDOM polyfills, allowing tests to run against actual rendering engines. For teams managing large test suites, this translates to faster cold starts, accurate coverage metrics via the v8 provider, and a unified configuration surface that reduces CI maintenance overhead.
Core Solution
Migrating to Vitest 4 requires a systematic approach to dependency cleanup, configuration alignment, and API translation. The following implementation demonstrates a production-ready migration path using a realistic service-layer testing scenario.
Step 1: Dependency Cleanup & Installation
Remove legacy Jest packages to prevent resolver conflicts. Install Vitest 4 and the optional UI dashboard.
npm uninstall jest @types/jest ts-jest babel-jest @jest/globals jest-environment-jsdom
npm install --save-dev vitest@4 @vitest/ui
Verify the installation:
npx vitest --version
# Expected: vitest/4.1.7 darwin-arm64 node-v22.22.0
Step 2: Configuration Architecture
Create vitest.config.ts. The critical decision here is enabling globals: true, which maps describe, test, and expect to the global scope. This allows existing test files to execute without immediate refactoring.
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
resolve: {
alias: {
'@services': path.resolve(__dirname, './src/services'),
'@types': path.resolve(__dirname, './src/types'),
},
},
test: {
globals: true,
environment: 'node',
include: ['src/**/*.spec.ts'],
reporters: ['verbose'],
coverage: {
provider: 'v8',
include: ['src/**'],
exclude: ['src/**/*.spec.ts', 'src/types/**'],
},
},
})
Rationale:
globals: truereduces migration friction. You can incrementally adopt explicit imports later.coverage.provider: 'v8'leverages Node's native V8 coverage API, which is faster and more accurate than Babel-based instrumentation.resolve.aliasreplaces Jest'smoduleNameMapper, aligning test resolution with Vite's build graph.
Step 3: API Translation Patterns
Vitest's mocking API mirrors Jest's but introduces async-first design for better ESM compatibility. Below is a complete translation of common patterns using a PaymentGateway and AuditLogger scenario.
Mocking Functions
// Legacy Jest
const gateway = jest.fn().mockResolvedValue({ transactionId: 'tx_99' })
// Vitest 4
import { vi } from 'vitest'
const gateway = vi.fn().mockResolvedValue({ transactionId: 'tx_99' })
Module Mocking with Partial Overrides
// Legacy Jest
jest.mock('@services/audit', () => ({
...jest.requireActual('@services/audit'),
logEvent: jest.fn(),
}))
// Vitest 4 (Note: async callback required)
vi.mock('@services/audit', async () => {
const actual = await vi.importActual('@services/audit')
return {
...actual,
logEvent: vi.fn(),
}
})
Spying on Methods
import { AuditLogger } from '@services/audit'
const spy = vi.spyOn(AuditLogger.prototype, 'record').mockImplementation(() => {})
// ... run test logic ...
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ type: 'payment' }))
spy.mockRestore()
Clearing State
beforeEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks()
})
Step 4: Leveraging Vitest 4 Enhancements
Vitest 4 introduces matchers that reduce assertion boilerplate and improve readability.
const handler = vi.fn()
handler('checkout')
// Exact call verification
expect(handler).toHaveBeenCalledExactlyOnceWith('checkout')
// Predicate-based validation
expect(42).toSatisfy((val: number) => val > 0 && val < 100)
// Enum/State validation
const status = 'processing'
expect(status).toBeOneOf(['pending', 'processing', 'completed'])
Step 5: Workspace Isolation & Line Filtering
For monorepos or mixed test types, inline workspaces eliminate the need for separate config files.
export default defineConfig({
test: {
workspace: [
{
test: { name: 'unit', environment: 'node', include: ['src/**/*.unit.spec.ts'] },
},
{
test: { name: 'integration', environment: 'node', include: ['src/**/*.int.spec.ts'], globalSetup: ['./test/db-setup.ts'] },
},
],
},
})
Run a single test by line number for rapid iteration:
npx vitest run "src/services/payment.spec.ts:42"
Step 6: Browser Mode (Stable in v4)
For DOM-dependent logic, enable native browser execution via Playwright.
npm install --save-dev @vitest/browser-playwright playwright
export default defineConfig({
test: {
browser: {
enabled: true,
provider: 'playwright',
instances: [{ browser: 'chromium' }],
},
},
})
Pitfall Guide
1. Synchronous vi.importActual() Usage
Explanation: Unlike jest.requireActual(), vi.importActual() returns a Promise. Spreading it synchronously injects a Promise object into your mock instead of the resolved module exports.
Fix: Always wrap the mock factory in an async function and await the import.
2. Missing globals: true Causing ReferenceError
Explanation: Without globals: true, describe, test, and expect are not available in the global scope. Tests will fail immediately with ReferenceError: describe is not defined.
Fix: Enable globals: true in vitest.config.ts or add import { describe, test, expect } from 'vitest' to every test file.
3. moduleNameMapper vs resolve.alias Mismatch
Explanation: Jest uses regex-based path mapping. Vitest relies on Vite's resolve.alias, which uses exact string matching or path resolution. Regex patterns will silently fail.
Fix: Convert regex mappers to path.resolve(__dirname, './src/...') aliases. Test path resolution explicitly before migrating large suites.
4. Mock Hoisting Timing Conflicts
Explanation: vi.mock() calls are hoisted to the top of the file by Vitest's compiler plugin. If you reference variables defined outside the mock factory, they will be undefined during hoisting.
Fix: Keep mock factories self-contained. If you need dynamic values, use vi.doMock() or restructure the test to avoid hoisting dependencies.
5. Legacy Config File Conflicts
Explanation: Leaving jest.config.ts or jest.config.js in the project root can cause IDE extensions or CI scripts to accidentally invoke the wrong runner.
Fix: Delete all Jest configuration files immediately after verifying the Vitest pipeline. Update package.json scripts to point exclusively to vitest.
6. ESM/CJS Interop Resolution Failures
Explanation: Vitest runs in ESM mode by default. Packages that only export CommonJS or lack proper exports fields may trigger ERR_REQUIRE_ESM or ERR_MODULE_NOT_FOUND.
Fix: Add deps.inline or deps.external in the Vitest config to control module resolution. Use ssr.noExternal for packages that require server-side transformation.
7. Browser Mode Provider Mismatch in CI
Explanation: Browser Mode requires Playwright binaries. CI environments without pre-installed browsers will fail with Executable doesn't exist at /path/to/chromium.
Fix: Add npx playwright install --with-deps chromium to your CI pipeline before running tests. Cache the ~/.cache/ms-playwright directory to avoid repeated downloads.
Production Bundle
Action Checklist
- Remove all Jest dependencies and clear
node_modules - Install
vitest@4and verify version output - Create
vitest.config.tswithglobals: trueandv8coverage - Replace
moduleNameMapperwithresolve.aliasusing absolute paths - Convert
jest.requireActualto asyncvi.importActualpatterns - Update
package.jsonscripts to usevitest runandvitest - Add
npx playwright installto CI if enabling Browser Mode - Delete legacy
jest.config.*files and verify CI pipeline
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Vite-based frontend or TS library | Vitest 4 (Node + Browser Mode) | Native ESM, zero-config TS, stable DOM testing | Low setup, high CI speed |
| Legacy Express/Next.js server | Jest or Vitest with deps.inline |
Jest has mature CJS/SSR plugins; Vitest requires explicit module routing | Medium config overhead |
| Monorepo with mixed test types | Vitest Inline Workspaces | Single config file, isolated environments, shared coverage | Reduced maintenance, faster parallel runs |
| Strict CJS-only codebase | Jest or Vitest with ssr.noExternal |
Vitest defaults to ESM; CJS interop requires explicit configuration | Higher initial tuning cost |
Configuration Template
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
resolve: {
alias: {
'@core': path.resolve(__dirname, './src/core'),
'@utils': path.resolve(__dirname, './src/utils'),
},
},
test: {
globals: true,
environment: 'node',
include: ['src/**/*.spec.ts'],
reporters: process.env.CI ? ['dot'] : ['verbose'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['src/**'],
exclude: ['src/**/*.spec.ts', 'src/types/**', 'src/mocks/**'],
},
server: {
deps: {
inline: ['@my-org/ui-components'],
},
},
},
})
Quick Start Guide
- Clean & Install: Run
npm uninstall jest @types/jest ts-jestfollowed bynpm i -D vitest@4. - Bootstrap Config: Create
vitest.config.tswithglobals: true,environment: 'node', andcoverage.provider: 'v8'. - Update Scripts: Replace
"test": "jest"inpackage.jsonwith"test": "vitest run"and"test:watch": "vitest". - Run & Verify: Execute
npm test. Fix anyvi.importActualasync errors or alias mismatches. - Enable CI: Add
npx playwright install chromium(if using browser mode) and runnpm testin your pipeline. Use--shardfor parallel execution on large suites.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
