Why Vitest Has Replaced Jest for Most Projects
In 2026, Vitest has become the default testing framework for new JavaScript/TypeScript projects. It runs in the same Vite pipeline as your application, meaning your test environment matches your development environment exactly. No more mysterious "works in dev but fails in test" bugs caused by different transpilers or module resolution.
Vitest is also fast. A 500-test suite that takes 45 seconds in Jest often runs in 8 seconds with Vitest, thanks to native ES module support, parallel execution, and avoiding CommonJS interop overhead.
Initial Setup
npm install -D vitest @vitest/coverage-v8 @testing-library/react jsdom
// vite.config.ts — add test block
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.d.ts', 'src/test/**'],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
},
},
alias: {
'@': new URL('./src', import.meta.url).pathname,
},
},
})
// src/test/setup.ts
import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
afterEach(cleanup)
vi.stubEnv('API_BASE_URL', 'http://localhost:3000')
Core Testing Primitives
describe, it, expect
// src/utils/formatters.test.ts
import { describe, it, expect } from 'vitest'
import { formatCurrency, truncate } from './formatters'
describe('formatCurrency', () => {
it('formats USD with dollar sign and two decimal places', () => {
expect(formatCurrency(1234.5, 'USD')).toBe('$1,234.50')
})
it('handles zero correctly', () => {
expect(formatCurrency(0, 'USD')).toBe('$0.00')
})
it('handles negative values', () => {
expect(formatCurrency(-50, 'USD')).toBe('-$50.00')
})
})
describe('truncate', () => {
it('returns original string when shorter than limit', () => {
expect(truncate('hello', 10)).toBe('hello')
})
it('truncates and adds ellipsis when too long', () => {
expect(truncate('hello world', 5)).toBe('hello...')
})
})
Testing Async Code
import { describe, it, expect } from 'vitest'
import { fetchUser } from './userService'
describe('userService', () => {
it('fetches user by id', async () => {
const user = await fetchUser('user-123')
expect(user).toMatchObject({
id: 'user-123',
email: expect.stringContaining('@'),
})
})
it('throws on 404', async () => {
await expect(fetchUser('non-existent')).rejects.toThrow('User not found')
})
})
Mock Functions: vi.fn()
import { describe, it, expect, vi } from 'vitest'
describe('vi.fn() basics', () => {
it('tracks calls and arguments', () => {
const mockFn = vi.fn()
mockFn('first call')
mockFn('second call', { with: 'object' })
expect(mockFn).toHaveBeenCalledTimes(2)
expect(mockFn).toHaveBeenCalledWith('first call')
expect(mockFn).toHaveBeenLastCalledWith('second call', { with: 'object' })
})
it('returns controlled values with mockReturnValueOnce', () => {
const mockFn = vi
.fn()
.mockReturnValueOnce('first')
.mockReturnValueOnce('second')
.mockReturnValue('default')
expect(mockFn()).toBe('first')
expect(mockFn()).toBe('second')
expect(mockFn()).toBe('default')
})
it('mocks async implementations', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce({ data: 'success' })
const result = await mockFetch('/api/data')
expect(result).toEqual({ data: 'success' })
})
it('simulates rejections', async () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('Network error'))
await expect(mockFetch('/api/data')).rejects.toThrow('Network error')
})
})
Module Mocking: vi.mock()
// vi.mock() is hoisted to the top of the file
vi.mock('../lib/emailProvider', () => ({
sendEmail: vi.fn().mockResolvedValue({ messageId: 'mock-id-123' }),
validateEmail: vi.fn().mockReturnValue(true),
}))
import { sendEmail, validateEmail } from '../lib/emailProvider'
import { sendWelcomeEmail } from './emailService'
import { describe, it, expect, vi, beforeEach } from 'vitest'
describe('emailService', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('sends welcome email with correct template', async () => {
await sendWelcomeEmail({ email: 'user@example.com', name: 'Alice' })
expect(sendEmail).toHaveBeenCalledOnce()
expect(sendEmail).toHaveBeenCalledWith({
to: 'user@example.com',
subject: 'Welcome, Alice!',
html: expect.stringContaining('Welcome to our platform'),
})
})
})
Partial Module Mocking
vi.mock('../utils/date', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/date')>()
return {
...actual,
// Override only getCurrentDate for deterministic tests
getCurrentDate: vi.fn().mockReturnValue(new Date('2026-01-15T10:00:00Z')),
}
})
Snapshot Testing
import { describe, it, expect } from 'vitest'
import { renderUserCard } from './UserCard'
describe('UserCard', () => {
it('renders correctly for active user', () => {
const output = renderUserCard({
name: 'Alice',
role: 'admin',
status: 'active',
})
// First run creates snapshot; subsequent runs compare against it
expect(output).toMatchSnapshot()
})
it('renders inline snapshot for small outputs', () => {
const badge = renderBadge('admin')
expect(badge).toMatchInlineSnapshot(`
"<span class="badge badge-admin">admin</span>"
`)
})
})
Update snapshots after intentional changes: vitest --update-snapshots
Concurrent Tests for Speed
import { describe, it, expect } from 'vitest'
// Run all tests in this block concurrently
describe.concurrent('independent API tests', () => {
it('fetches users', async () => {
const users = await fetchUsers()
expect(users).toHaveLength(10)
})
it('fetches products', async () => {
const products = await fetchProducts()
expect(products).toHaveLength(20)
})
it('fetches orders', async () => {
const orders = await fetchOrders()
expect(orders.items).toBeInstanceOf(Array)
})
})
Only use concurrent for tests that do not share state.
Testing React Components
// src/components/SearchBar.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { SearchBar } from './SearchBar'
describe('SearchBar', () => {
it('calls onSearch with debounced input', async () => {
const user = userEvent.setup()
const onSearch = vi.fn()
render(<SearchBar onSearch={onSearch} debounceMs={300} />)
const input = screen.getByRole('textbox', { name: /search/i })
await user.type(input, 'vitest guide')
// Not called yet — debounced
expect(onSearch).not.toHaveBeenCalled()
// After debounce delay
await waitFor(() => {
expect(onSearch).toHaveBeenCalledWith('vitest guide')
}, { timeout: 500 })
})
})
Coverage Reports and CI
# Run with coverage
vitest run --coverage
# Output shows line/branch/function coverage per file
# HTML report in coverage/index.html shows exact uncovered lines
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run test:coverage
- uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
Practical Tips from Production Codebases
Test behaviors, not implementations. A test that breaks when you rename a private function is a liability. Test what your function does (outputs, side effects), not how it does it.
Keep test files co-located with source files. UserCard.test.tsx next to UserCard.tsx is easier to find than a separate __tests__ directory.
Reset mocks in beforeEach, not afterEach. If a test fails, afterEach cleanup might not run. beforeEach ensures a clean slate regardless.
Mock at the boundary, not the internals. Mock your HTTP client, not the function that calls it. This tests your actual request-building logic.