Technology · Developer Tooling
Vitest in 2026: The Testing Setup That Replaced Jest for Our Team
Vitest runs faster than Jest, handles ESM and TypeScript natively, and shares Jest's API. The case for switching in a Vite project, and how to do it.
Anurag Verma
8 min read
Sponsored
Jest built the culture of component testing in the JavaScript ecosystem. It’s been the default for years. But it was built for a CommonJS world, and modern JavaScript is ESM by default. The mismatches (needing babel to transform files, broken ESM imports, TypeScript source maps that don’t match, Jest configs that grow into multi-page files) have accumulated into real friction.
Vitest was built alongside Vite and shares its configuration. If you’re using Vite (which means Next.js with the Vitest adapter, Astro, SvelteKit, Remix, or a Vite-based framework), your tests run against the same pipeline that builds your app. No separate babel config. No separate TypeScript configuration. No separate module resolution rules.
The result is a test runner that feels like it was actually designed for 2026.
Why Vitest Over Jest
ESM without ceremony. Jest needs --experimental-vm-modules and specific transforms to handle ESM. Vitest handles ESM natively. If your app code is ESM, your tests are too.
TypeScript without extra setup. Jest requires ts-jest or babel to transform TypeScript. Vitest uses the same TypeScript transforms as Vite. Add it to your project and it works.
Shared configuration. Jest has jest.config.js alongside your vite.config.ts. Vitest configuration lives inside vite.config.ts. One fewer config file and no divergence between how tests and app code resolve modules.
Speed. Vitest runs tests in parallel across worker threads and only re-runs tests affected by your code changes. On large test suites, the warmup and re-run time is lower than Jest in most comparisons.
Same API. describe, it, expect, vi.fn(), vi.mock(), beforeEach, afterEach. If you know Jest, you know Vitest.
Setup
npm install -D vitest @vitest/ui
Add to vite.config.ts:
import { defineConfig } from 'vite';
import { defineConfig as defineTestConfig } from 'vitest/config';
export default defineConfig({
// ... your existing vite config
test: {
environment: 'jsdom', // 'node' | 'jsdom' | 'happy-dom' | 'edge-runtime'
globals: true, // makes describe/it/expect globally available without imports
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
},
},
});
Add to package.json:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}
}
vitest starts in watch mode. vitest run runs once and exits (for CI).
Writing Tests
The API is Jest-compatible:
// src/utils/format.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { formatCurrency, formatDate } from './format';
describe('formatCurrency', () => {
it('formats USD amounts', () => {
expect(formatCurrency(1234.56, 'USD')).toBe('$1,234.56');
});
it('handles zero', () => {
expect(formatCurrency(0, 'USD')).toBe('$0.00');
});
it('handles negative amounts', () => {
expect(formatCurrency(-100, 'USD')).toBe('-$100.00');
});
});
If you have globals: true in your config, you can skip the imports:
// No import needed when globals: true
describe('formatCurrency', () => {
it('formats USD amounts', () => {
expect(formatCurrency(1234.56, 'USD')).toBe('$1,234.56');
});
});
Mocking
Vitest uses vi where Jest uses jest:
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { sendEmail } from '../services/email';
import { notifyUser } from './notifications';
vi.mock('../services/email');
describe('notifyUser', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('sends an email on signup', async () => {
vi.mocked(sendEmail).mockResolvedValue({ sent: true });
await notifyUser({ event: 'signup', userId: '123' });
expect(sendEmail).toHaveBeenCalledWith({
to: expect.any(String),
subject: 'Welcome!',
});
});
});
Module mocking works with vi.mock() at the top level, same as Jest’s jest.mock().
Spying on implementations:
import { vi, it, expect } from 'vitest';
it('calls the callback after delay', async () => {
vi.useFakeTimers();
const callback = vi.fn();
scheduleCallback(callback, 1000);
expect(callback).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1000);
expect(callback).toHaveBeenCalledOnce();
vi.useRealTimers();
});
Component Testing With React Testing Library
For React components:
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event
Setup file:
// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});
Test:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, it, expect, describe } from 'vitest';
import { SearchInput } from './SearchInput';
describe('SearchInput', () => {
it('calls onSearch when the user types', async () => {
const user = userEvent.setup();
const onSearch = vi.fn();
render(<SearchInput onSearch={onSearch} />);
const input = screen.getByRole('textbox');
await user.type(input, 'hello');
expect(onSearch).toHaveBeenCalledWith('hello');
});
});
This is identical to how you’d write the test with Jest + RTL. The same test file works with either runner, which makes migration straightforward. For the end-to-end layer above unit tests like these, see our Playwright E2E testing guide.
Migrating From Jest
For most projects, migration is mechanical:
- Install Vitest and remove Jest packages
- Replace
jest.config.jswith thetestblock invite.config.ts - Replace
jestwithviin test files - Update CI script from
jesttovitest run
# Remove Jest
npm uninstall jest jest-environment-jsdom @types/jest ts-jest babel-jest
# Install Vitest
npm install -D vitest @vitest/coverage-v8 jsdom
If you used @types/jest for global types, add this to tsconfig.json instead:
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}
Search and replace across your test files:
jest.fn()→vi.fn()jest.mock(→vi.mock(jest.spyOn(→vi.spyOn(jest.clearAllMocks()→vi.clearAllMocks()jest.useFakeTimers()→vi.useFakeTimers()jest.resetModules()→vi.resetModules()
If you have jest.config.js options like moduleNameMapper or transform, they translate to Vite’s resolve.alias and plugin configuration.
The Vitest UI
Vitest ships a browser-based UI:
npx vitest --ui
This opens a dashboard showing all tests, pass/fail state, duration, and coverage. Useful during active development to see the full test tree without scrolling through terminal output. Optional; it doesn’t affect the test runner itself.
In-Source Tests
A unique Vitest feature: tests embedded directly in your source files.
// src/utils/math.ts
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
// Only runs during testing, stripped from production builds
if (import.meta.vitest) {
const { it, expect } = import.meta.vitest;
it('clamps to min', () => {
expect(clamp(-5, 0, 10)).toBe(0);
});
it('clamps to max', () => {
expect(clamp(15, 0, 10)).toBe(10);
});
it('returns value within range', () => {
expect(clamp(5, 0, 10)).toBe(5);
});
}
Useful for utility functions where you want the test immediately adjacent to the code. Not suitable for components or integration tests; those stay in separate *.test.ts files.
Snapshot Testing
Snapshot testing works the same way it does in Jest: render a value once, save it, and fail the test if a future run produces something different.
import { it, expect } from 'vitest';
import { formatInvoice } from './invoice';
it('formats an invoice consistently', () => {
const invoice = formatInvoice({ id: 'INV-1', total: 4200 });
expect(invoice).toMatchSnapshot();
});
For quick, one-off assertions where a separate snapshot file is overkill, toMatchInlineSnapshot() writes the expected value directly into the test file the first time it runs, and updates it in place with vitest --update.
Filtering Tests in Watch Mode
Vitest’s watch mode has interactive filtering built in, which matters once a suite grows past a couple hundred tests. Press p while vitest is running to filter by filename pattern, or t to filter by test name pattern, without editing any config or adding a .only. Press a to rerun the full suite again. This is one of the smaller but more noticeable day-to-day differences from Jest, where filtering usually means passing --testPathPattern on the command line and restarting.
Testing Monorepos With Workspaces
For a monorepo with multiple packages, each with its own test setup and environment, Vitest supports a vitest.workspace.ts file that points at each project:
// vitest.workspace.ts
import { defineWorkspace } from 'vitest/config';
export default defineWorkspace([
'packages/*',
{
test: {
name: 'api',
environment: 'node',
},
},
]);
Running vitest from the repo root then runs every package’s tests with its own environment and config, reported together, instead of needing a separate Jest project per package with its own projects array in a shared config.
When Jest Is Still Fine
If your project doesn’t use Vite, the case for migrating is weaker. Next.js 14+ with its SWC compiler handles Jest well without Babel, and the module resolution issues that plagued older setups are mostly solved. Moving a stable, well-configured Jest setup to Vitest purely for speed is probably not worth the migration risk.
If you’re on Next.js with the App Router and Vite is not part of your stack, stay with Jest. If you’re on any Vite-based framework, the migration is worth the 30-60 minutes it takes. Either way, passing tests only prove your code does what you told it to; if you want to know whether your tests would actually catch a real bug, mutation testing is the tool for that question.
Frequently asked questions
- Is Vitest a drop-in replacement for Jest?
- Mostly. The test API (`describe`, `it`, `expect`, `beforeEach`, `afterEach`) is the same, and most test files work with only a find-and-replace of `jest.` to `vi.` for mocks, spies, and timers. Config translates from `jest.config.js` into a `test` block inside `vite.config.ts`.
- Why is Vitest faster than Jest?
- Vitest runs tests in parallel across worker threads and only re-runs tests affected by recent code changes, and it reuses Vite's existing transform pipeline instead of running a separate babel or ts-jest transform step. On large suites this adds up to noticeably lower warmup and re-run times.
- Do I need Vite to use Vitest?
- Not strictly, but the benefits are strongest in a Vite-based project (Astro, SvelteKit, Remix, or Next.js with the Vitest adapter) because tests then share the app's build config. If a project doesn't use Vite and already has a stable Jest setup, especially on Next.js 14+ with SWC, migrating purely for speed may not be worth the risk.
- What are in-source tests in Vitest?
- In-source tests live directly inside the source file they test, guarded by `if (import.meta.vitest)`, and are stripped out of production builds automatically. They're useful for small utility functions where keeping the test immediately next to the code helps, but components and integration tests should stay in separate `*.test.ts` files.
- Can Vitest test monorepos with multiple packages?
- Yes, through a `vitest.workspace.ts` file that lists each package or defines a per-project test config (different environments, different setup files). Running `vitest` from the repo root then runs every package's tests together with its own settings, instead of needing a separate Jest config per package.
Sponsored
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored