Vitest is a unit test framework built by the Vite team. It reuses your existing Vite config — so TypeScript, JSX, path aliases, and environment variables all work in tests without extra setup. It implements nearly the entire Jest API, so migrating existing test suites is usually a matter of swapping the import and running vitest.
The speed difference is noticeable: Vitest starts in under 200ms while Jest spins up in 2-3 seconds. For large test suites, that compounds.
This guide covers Vitest from scratch — not just the basics, but the parts that trip people up: mocking, async, component testing, and configuration for real projects.
Why Vitest Instead of Jest
The core argument is configuration cost. A typical Jest setup for a TypeScript + Vite project needs:
babel-jestorts-jestto handle TypeScript- A Babel config or
jest.config.tswithtransform - Separate handling for ESM packages that don't ship CommonJS
- Path alias replication (your
vite.config.tsaliases don't work in Jest)
With Vitest, none of that is needed. Your existing vite.config.ts is already the Vitest config. TypeScript works. JSX works. Path aliases work.
When to stick with Jest:
- You're not using Vite (plain Node.js, Express, NestJS without Vite)
- Your team knows Jest deeply and migration cost isn't justified
- You need specific Jest ecosystem tools without Vitest equivalents
For anything Vite-based — React, Vue, Svelte, or even a Node.js library built with Vite — Vitest is the obvious choice.
Installation
# Vite-based project
npm install -D vitest
# Adds test script to package.json:
# "test": "vitest"
# "test:run": "vitest run"
# "coverage": "vitest run --coverage"For non-Vite projects, Vitest still works but you'll want a config file:
npm install -D vitest viteRunning Tests
npx vitest # watch mode (default in dev)
npx vitest run # single run (for CI)
npx vitest run --reporter=verbose
npx vitest --coverage
npx vitest src/utils.test.ts # specific file
npx vitest -t "handles errors" # filter by test nameBasic Test Structure
Vitest uses the same API as Jest:
// src/utils/slug.test.ts
import { describe, it, expect } from 'vitest'
import { slugify, truncate, formatDate } from './string-utils'
describe('slugify', () => {
it('converts spaces to hyphens', () => {
expect(slugify('Hello World')).toBe('hello-world')
})
it('removes special characters', () => {
expect(slugify('TypeScript: The Good Parts!')).toBe('typescript-the-good-parts')
})
it('handles leading and trailing spaces', () => {
expect(slugify(' spaced ')).toBe('spaced')
})
it('collapses multiple hyphens', () => {
expect(slugify('hello---world')).toBe('hello-world')
})
})
describe('truncate', () => {
it('returns full string when under limit', () => {
expect(truncate('short', 10)).toBe('short')
})
it('truncates and appends ellipsis', () => {
expect(truncate('hello world', 8)).toBe('hello...')
})
})You can also use test instead of it — they're aliases:
test('formatDate returns ISO format', () => {
const date = new Date('2026-01-15')
expect(formatDate(date)).toBe('2026-01-15')
})Configuration
Vitest config lives in vitest.config.ts or inside vite.config.ts. For projects that already have Vite:
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
// Run tests in a simulated browser environment
environment: 'jsdom', // or 'happy-dom' (faster) or 'node'
// Global test APIs — no need to import describe/it/expect
globals: true,
// Run this before each test file
setupFiles: ['./src/test/setup.ts'],
// Coverage configuration
coverage: {
provider: 'v8', // or 'istanbul'
reporter: ['text', 'html', 'lcov'],
exclude: ['node_modules/', 'src/test/']
},
// Include pattern
include: ['**/*.{test,spec}.{ts,tsx}'],
}
})For a pure Vitest config (separate file):
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
globals: true,
}
})If you set globals: true, add the types to tsconfig.json:
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}The expect API
Vitest's expect is compatible with Jest matchers:
// Primitives
expect(value).toBe(42) // strict equality (===)
expect(value).toEqual({ a: 1 }) // deep equality
expect(value).not.toBe(null)
// Truthiness
expect(value).toBeTruthy()
expect(value).toBeFalsy()
expect(value).toBeNull()
expect(value).toBeUndefined()
expect(value).toBeDefined()
// Numbers
expect(value).toBeGreaterThan(5)
expect(value).toBeLessThanOrEqual(10)
expect(0.1 + 0.2).toBeCloseTo(0.3, 5) // floating point comparison
// Strings
expect(str).toContain('substring')
expect(str).toMatch(/regex/)
expect(str).toHaveLength(10)
// Arrays
expect(arr).toContain('item')
expect(arr).toHaveLength(3)
expect(arr).toEqual(expect.arrayContaining(['a', 'b']))
// Objects
expect(obj).toHaveProperty('key')
expect(obj).toHaveProperty('nested.key', 'value')
expect(obj).toMatchObject({ name: 'test' }) // partial match
// Errors
expect(() => parseInput('')).toThrow()
expect(() => parseInput('')).toThrow('Input cannot be empty')
expect(() => parseInput('')).toThrow(ValidationError)
// Async errors
await expect(fetchUser(-1)).rejects.toThrow('User not found')Mocking with vi
The vi object replaces Jest's global jest:
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
// Mock a function
const mockFn = vi.fn()
mockFn('hello')
expect(mockFn).toHaveBeenCalledOnce()
expect(mockFn).toHaveBeenCalledWith('hello')
// Mock return values
mockFn.mockReturnValue('result')
mockFn.mockReturnValueOnce('first').mockReturnValueOnce('second')
// Mock async functions
const mockAsync = vi.fn().mockResolvedValue({ id: 1, name: 'Test' })
const result = await mockAsync()
// Spy on an existing function
import { formatDate } from './utils'
const spy = vi.spyOn({ formatDate }, 'formatDate')
// Reset between tests
beforeEach(() => {
vi.clearAllMocks() // clears call history
})
afterEach(() => {
vi.restoreAllMocks() // restores original implementations
})Mocking Modules
// Mock an entire module
vi.mock('./database', () => ({
db: {
user: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
}
}
}))
// Use the mock in tests
import { db } from './database'
describe('UserService', () => {
it('returns user by id', async () => {
vi.mocked(db.user.findUnique).mockResolvedValue({
id: '1',
email: 'test@example.com',
name: 'Test User'
})
const user = await getUserById('1')
expect(db.user.findUnique).toHaveBeenCalledWith({
where: { id: '1' }
})
expect(user?.email).toBe('test@example.com')
})
})Partial Mocks
Sometimes you only want to mock part of a module:
// Keep the real implementation but mock specific exports
vi.mock('./utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('./utils')>()
return {
...actual,
sendEmail: vi.fn(), // mock only this
// formatDate stays real
}
})Mocking Dates and Timers
describe('scheduled tasks', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-15T10:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('expires tokens after 24 hours', async () => {
const token = createToken()
expect(isTokenValid(token)).toBe(true)
vi.advanceTimersByTime(24 * 60 * 60 * 1000 + 1)
expect(isTokenValid(token)).toBe(false)
})
})Testing Async Code
describe('API calls', () => {
// Async/await — cleanest approach
it('fetches user data', async () => {
const user = await fetchUser('123')
expect(user.id).toBe('123')
})
// Test promises directly
it('rejects for invalid id', () => {
return expect(fetchUser('')).rejects.toThrow('Invalid user ID')
})
// Polling with waitFor (for state that changes over time)
it('eventually processes queue', async () => {
startBackgroundProcessor()
await vi.waitFor(
() => expect(getProcessedCount()).toBeGreaterThan(0),
{ timeout: 2000, interval: 100 }
)
})
})React Component Testing
Install testing utilities:
npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-domSetup file:
// src/test/setup.ts
import '@testing-library/jest-dom'Component tests:
// src/components/SearchBar.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import SearchBar from './SearchBar'
describe('SearchBar', () => {
it('renders the search input', () => {
render(<SearchBar onSearch={vi.fn()} />)
expect(screen.getByRole('textbox', { name: /search/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /search/i })).toBeInTheDocument()
})
it('calls onSearch with input value on submit', async () => {
const user = userEvent.setup()
const onSearch = vi.fn()
render(<SearchBar onSearch={onSearch} />)
const input = screen.getByRole('textbox')
await user.type(input, 'vitest guide')
await user.click(screen.getByRole('button', { name: /search/i }))
expect(onSearch).toHaveBeenCalledWith('vitest guide')
})
it('debounces rapid typing', async () => {
const user = userEvent.setup()
const onSearch = vi.fn()
vi.useFakeTimers()
render(<SearchBar onSearch={onSearch} debounce={300} />)
await user.type(screen.getByRole('textbox'), 'abc')
expect(onSearch).not.toHaveBeenCalled()
vi.advanceTimersByTime(300)
expect(onSearch).toHaveBeenCalledOnce()
expect(onSearch).toHaveBeenCalledWith('abc')
vi.useRealTimers()
})
it('shows no results message when search returns empty', async () => {
const user = userEvent.setup()
const onSearch = vi.fn().mockResolvedValue([])
render(<SearchBar onSearch={onSearch} />)
await user.type(screen.getByRole('textbox'), 'xyzabc')
await user.click(screen.getByRole('button', { name: /search/i }))
await waitFor(() => {
expect(screen.getByText(/no results/i)).toBeInTheDocument()
})
})
})Testing Hooks
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('starts at the initial value', () => {
const { result } = renderHook(() => useCounter(5))
expect(result.current.count).toBe(5)
})
it('increments', () => {
const { result } = renderHook(() => useCounter(0))
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
})Coverage
npm install -D @vitest/coverage-v8
npx vitest run --coverageCoverage config in vitest.config.ts:
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
// Minimum thresholds — fails CI if not met
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80
},
// What to include/exclude
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.test.{ts,tsx}',
'src/**/*.stories.{ts,tsx}',
'src/test/**',
'src/types/**',
]
}
}The HTML report is generated in ./coverage/index.html and shows line-by-line coverage. Open it after running tests to identify untested branches.
Snapshot Testing
it('renders post card correctly', () => {
const { container } = render(
<PostCard
title="Test Post"
excerpt="Short description"
date="2026-01-15"
/>
)
expect(container.firstChild).toMatchSnapshot()
})Update snapshots when the component changes intentionally:
npx vitest run --update-snapshotsPrefer snapshot testing for stable output (serialized data, HTML structure) rather than complex components that change frequently.
Vitest UI
npm install -D @vitest/ui
npx vitest --uiOpens a browser-based dashboard at http://localhost:51204 that shows:
- Live test results as they run
- Filterable test tree
- Console output per test
- Module graph for each test file
Useful during development when you're working on a specific feature and want to see results without watching the terminal.
In-Source Testing
Vitest supports writing tests directly in your source files (disabled by default):
// src/utils/math.ts
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max)
}
// Tests live alongside the code — stripped from production builds
if (import.meta.vitest) {
const { describe, it, expect } = import.meta.vitest
describe('clamp', () => {
it('clamps below min', () => expect(clamp(0, 1, 10)).toBe(1))
it('clamps above max', () => expect(clamp(15, 1, 10)).toBe(10))
it('returns value within range', () => expect(clamp(5, 1, 10)).toBe(5))
})
}Enable with:
test: {
includeSource: ['src/**/*.ts']
}Vitest vs Jest
| Vitest | Jest | |
|---|---|---|
| Speed | Fast (esbuild transform) | Slower (Babel/ts-jest) |
| TypeScript | Native with Vite | Needs config |
| ESM | Native | Needs transform workarounds |
| Config | Extends vite.config.ts | Separate jest.config |
| API | Jest-compatible | Original |
| Mocking | vi.* | jest.* |
| Globals | Optional | Default |
| Browser testing | @vitest/browser | jsdom only |
| Ecosystem | Growing | Mature |
| IDE support | Good | Excellent |
| Non-Vite projects | Works but adds Vite dep | Better fit |
Migration from Jest is mostly mechanical:
- Replace
jest.*withvi.* - Remove Jest config and Babel transform setup
- Update imports:
import { vi } from 'vitest' - Ensure
globals: truein Vitest config if you had Jest globals
Most test code runs without changes.
CI Setup
# .github/workflows/test.yml
- name: Run tests
run: npx vitest run
- name: Run tests with coverage
run: npx vitest run --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage/lcov.infoWorkspace Testing (Monorepos)
// vitest.workspace.ts
export default [
'packages/*/vitest.config.ts',
{
test: {
name: 'shared',
root: './packages/shared',
}
}
]Run with:
npx vitest --workspaceFor testing React specifically in a Next.js project, the setup adds a few steps — see Testing in Next.js with Vitest and Playwright for the App Router-specific configuration including server component testing and route handlers.
If you're on Bun, bun test implements a similar Jest-compatible API natively — see Bun Complete Guide for the comparison. For vanilla Node.js projects where you want a faster Jest, Vitest with environment: 'node' works well as a drop-in replacement regardless of whether you're using Vite.