Vitest - Complete guide to testing with Vite
What is Vitest?
Vitest is a modern testing framework built specifically for the Vite ecosystem. It uses the same configuration as Vite, which means tests run blazingly fast - often 10x faster than Jest. Vitest offers full compatibility with the Jest API, meaning you can migrate existing tests with virtually no changes.
The framework is developed by the creators of Vite (Evan You and team) and has quickly become the testing standard in Vue, React and Svelte projects that use Vite. Native support for ESM, TypeScript and JSX means configuration is minimal - in most cases you just install the package and start writing tests.
Why Vitest instead of Jest?
Key advantages of Vitest
- Blazing speed - Shares the transformation pipeline with Vite
- Zero configuration - Works out-of-the-box with TypeScript, JSX, ESM
- Smart watch mode - Only reruns affected tests
- Native ESM - No need to transpile to CommonJS
- Jest compatibility - Migrate existing tests without changes
- Inline testing - Tests can live in the same file as the code
- Browser mode - Tests can run in a real browser
Vitest vs Jest - detailed comparison
| Feature | Vitest | Jest |
|---|---|---|
| Startup time | ~10x faster | Slower cold start |
| Hot Module Replacement | Yes (with Vite) | No |
| ESM | Native | Requires configuration |
| TypeScript | Out-of-box | Requires ts-jest or babel |
| Watch mode | Smart (dependency graph) | Basic (file change) |
| Configuration | Uses vite.config | Separate jest.config |
| Browser testing | Built-in | Requires Puppeteer/Playwright |
| In-source testing | Yes | No |
| Snapshot testing | Yes | Yes |
| Coverage | c8 or istanbul | istanbul |
When to choose Vitest?
- Project uses Vite - Obvious choice, shares configuration
- New project - Faster setup and better developer experience
- Migrating from Jest - The API is practically identical
- TypeScript/ESM - Zero additional configuration
- You need speed - Watch mode is incomparably faster
When to stay with Jest?
- Project without Vite - Vitest can work standalone, but loses its advantage
- Large existing test base - Migration can be risky
- Specific Jest plugins - Not all have equivalents
- Create React App - Jest is the default and well integrated
Installation and configuration
Basic installation
# npm
npm install -D vitest
# pnpm
pnpm add -D vitest
# yarn
yarn add -D vitest
# bun
bun add -D vitestPackage.json configuration
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest --watch"
},
"devDependencies": {
"vitest": "^2.0.0"
}
}vitest.config.ts configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
// Test environment
environment: 'jsdom', // or 'node', 'happy-dom', 'edge-runtime'
// Global imports (describe, it, expect without importing)
globals: true,
// Setup files (run before tests)
setupFiles: ['./src/test/setup.ts'],
// Test file patterns
include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
exclude: ['node_modules', 'dist', '.idea', '.git', '.cache'],
// Coverage
coverage: {
provider: 'v8', // or 'istanbul'
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
],
},
// Timeouts
testTimeout: 10000,
hookTimeout: 10000,
// Parallelism
pool: 'threads', // or 'forks', 'vmThreads'
poolOptions: {
threads: {
singleThread: false,
},
},
// Snapshot
snapshotFormat: {
escapeString: true,
printBasicPrototype: true,
},
},
})React configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
css: true,
},
})// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
// Automatic cleanup after each test
afterEach(() => {
cleanup()
})Vue configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
},
})TypeScript configuration
// tsconfig.json
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}Writing tests
Basic syntax
// math.test.ts
import { describe, it, expect, test } from 'vitest'
import { sum, multiply, divide } from './math'
// describe groups related tests
describe('Math utilities', () => {
// Basic test
it('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3)
})
// test and it are equivalent
test('multiplies 2 * 3 to equal 6', () => {
expect(multiply(2, 3)).toBe(6)
})
// Nested describe for organization
describe('sum function', () => {
it('handles positive numbers', () => {
expect(sum(1, 2)).toBe(3)
expect(sum(100, 200)).toBe(300)
})
it('handles negative numbers', () => {
expect(sum(-1, 1)).toBe(0)
expect(sum(-5, -3)).toBe(-8)
})
it('handles zero', () => {
expect(sum(0, 0)).toBe(0)
expect(sum(5, 0)).toBe(5)
})
})
describe('divide function', () => {
it('divides numbers correctly', () => {
expect(divide(10, 2)).toBe(5)
})
it('throws on division by zero', () => {
expect(() => divide(10, 0)).toThrow('Cannot divide by zero')
})
})
})Assertions (expect)
import { describe, it, expect } from 'vitest'
describe('Assertions', () => {
// Equality
it('equality matchers', () => {
expect(2 + 2).toBe(4) // Strict equality (===)
expect({ a: 1 }).toEqual({ a: 1 }) // Deep equality
expect([1, 2]).toStrictEqual([1, 2]) // Strict deep equality
})
// Truthiness
it('truthiness matchers', () => {
expect(true).toBeTruthy()
expect(false).toBeFalsy()
expect(null).toBeNull()
expect(undefined).toBeUndefined()
expect('defined').toBeDefined()
})
// Numbers
it('number matchers', () => {
expect(10).toBeGreaterThan(5)
expect(5).toBeGreaterThanOrEqual(5)
expect(3).toBeLessThan(5)
expect(5).toBeLessThanOrEqual(5)
expect(0.1 + 0.2).toBeCloseTo(0.3) // Floating point
})
// Strings
it('string matchers', () => {
expect('Hello World').toContain('World')
expect('Hello World').toMatch(/world/i)
expect('Hello').toHaveLength(5)
})
// Arrays and iterables
it('array matchers', () => {
const arr = [1, 2, 3]
expect(arr).toContain(2)
expect(arr).toHaveLength(3)
expect(arr).toEqual(expect.arrayContaining([1, 3]))
})
// Objects
it('object matchers', () => {
const user = { name: 'John', age: 30, email: 'john@example.com' }
expect(user).toHaveProperty('name')
expect(user).toHaveProperty('name', 'John')
expect(user).toMatchObject({ name: 'John', age: 30 })
expect(user).toEqual(expect.objectContaining({ name: 'John' }))
})
// Exceptions
it('exception matchers', () => {
const throwError = () => { throw new Error('Oops!') }
expect(throwError).toThrow()
expect(throwError).toThrow('Oops!')
expect(throwError).toThrow(Error)
expect(throwError).toThrow(/oops/i)
})
// Negation
it('negation', () => {
expect(5).not.toBe(10)
expect([1, 2]).not.toContain(3)
})
})Async tests
import { describe, it, expect } from 'vitest'
import { fetchUser, fetchPosts, saveUser } from './api'
describe('Async tests', () => {
// Async/await - most readable
it('fetches user with async/await', async () => {
const user = await fetchUser(1)
expect(user).toHaveProperty('name')
expect(user.id).toBe(1)
})
// Promise return
it('fetches user with promise', () => {
return fetchUser(1).then(user => {
expect(user).toHaveProperty('name')
})
})
// Resolves/rejects matchers
it('uses resolves matcher', async () => {
await expect(fetchUser(1)).resolves.toHaveProperty('name')
await expect(fetchUser(-1)).rejects.toThrow('Not found')
})
// Multiple parallel operations
it('fetches multiple resources in parallel', async () => {
const [user, posts] = await Promise.all([
fetchUser(1),
fetchPosts(1)
])
expect(user.id).toBe(1)
expect(posts).toBeInstanceOf(Array)
})
// Timeout for long operations
it('handles long operation', async () => {
const result = await saveUser({ name: 'Test' })
expect(result.success).toBe(true)
}, 10000) // 10 second timeout
})Setup and teardown
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'
import { createDatabase, closeDatabase, clearDatabase } from './db'
describe('Database tests', () => {
// Once before all tests in the describe block
beforeAll(async () => {
await createDatabase()
console.log('Database created')
})
// Once after all tests in the describe block
afterAll(async () => {
await closeDatabase()
console.log('Database closed')
})
// Before each test
beforeEach(async () => {
await clearDatabase()
console.log('Database cleared')
})
// After each test
afterEach(() => {
console.log('Test completed')
})
it('test 1', () => {
// Database is clean
})
it('test 2', () => {
// Database is clean
})
})Skip, only and todo
import { describe, it, expect } from 'vitest'
describe('Test modifiers', () => {
// Skip a test
it.skip('skipped test', () => {
// This test will not be run
})
// Run ONLY this test
it.only('focused test', () => {
// Only this test will be run
})
// Test to implement later
it.todo('implement this test later')
// Conditional skipping
it.skipIf(process.env.CI)('skip on CI', () => {
// Skipped on CI
})
it.runIf(process.env.CI)('run only on CI', () => {
// Run only on CI
})
// Skip an entire describe block
describe.skip('skipped suite', () => {
it('test 1', () => {})
it('test 2', () => {})
})
// Concurrent test execution
describe.concurrent('parallel tests', () => {
it('test 1', async () => {})
it('test 2', async () => {})
})
})Mocking
Mocking functions
import { describe, it, expect, vi } from 'vitest'
import { processUser, sendEmail } from './user'
describe('Mocking functions', () => {
// Basic mock
it('creates a mock function', () => {
const mockFn = vi.fn()
mockFn('arg1', 'arg2')
expect(mockFn).toHaveBeenCalled()
expect(mockFn).toHaveBeenCalledTimes(1)
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2')
})
// Mock with implementation
it('mocks with implementation', () => {
const mockFn = vi.fn((x: number) => x * 2)
expect(mockFn(5)).toBe(10)
expect(mockFn).toHaveReturnedWith(10)
})
// Mock returning a value
it('mocks return value', () => {
const mockFn = vi.fn()
.mockReturnValue('default')
.mockReturnValueOnce('first')
.mockReturnValueOnce('second')
expect(mockFn()).toBe('first')
expect(mockFn()).toBe('second')
expect(mockFn()).toBe('default')
})
// Mock async function
it('mocks async function', async () => {
const mockFn = vi.fn()
.mockResolvedValue({ id: 1, name: 'John' })
.mockResolvedValueOnce({ id: 2, name: 'Jane' })
expect(await mockFn()).toEqual({ id: 2, name: 'Jane' })
expect(await mockFn()).toEqual({ id: 1, name: 'John' })
})
// Mock rejection
it('mocks rejected promise', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Failed'))
await expect(mockFn()).rejects.toThrow('Failed')
})
// Clearing mocks
it('clears mock state', () => {
const mockFn = vi.fn().mockReturnValue(42)
mockFn()
mockFn()
expect(mockFn).toHaveBeenCalledTimes(2)
mockFn.mockClear() // Clears calls, not implementation
expect(mockFn).toHaveBeenCalledTimes(0)
expect(mockFn()).toBe(42)
mockFn.mockReset() // Clears everything
expect(mockFn()).toBeUndefined()
})
})Mocking modules
// user-service.ts
import axios from 'axios'
export async function getUser(id: number) {
const response = await axios.get(`/api/users/${id}`)
return response.data
}
// user-service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import axios from 'axios'
import { getUser } from './user-service'
// Mock the entire module
vi.mock('axios')
const mockedAxios = vi.mocked(axios)
describe('User Service', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetches user from API', async () => {
const mockUser = { id: 1, name: 'John' }
mockedAxios.get.mockResolvedValue({ data: mockUser })
const user = await getUser(1)
expect(mockedAxios.get).toHaveBeenCalledWith('/api/users/1')
expect(user).toEqual(mockUser)
})
it('handles API error', async () => {
mockedAxios.get.mockRejectedValue(new Error('Network error'))
await expect(getUser(1)).rejects.toThrow('Network error')
})
})Partial mocking
// utils.ts
export function formatDate(date: Date): string {
return date.toISOString()
}
export function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
export function generateId(): string {
return Math.random().toString(36).substring(7)
}
// utils.test.ts
import { describe, it, expect, vi } from 'vitest'
// Mock only selected functions
vi.mock('./utils', async () => {
const actual = await vi.importActual('./utils')
return {
...actual,
generateId: vi.fn(() => 'mocked-id'),
}
})
import { formatDate, validateEmail, generateId } from './utils'
describe('Partial mocking', () => {
it('uses real formatDate', () => {
const date = new Date('2024-01-01')
expect(formatDate(date)).toBe('2024-01-01T00:00:00.000Z')
})
it('uses real validateEmail', () => {
expect(validateEmail('test@example.com')).toBe(true)
expect(validateEmail('invalid')).toBe(false)
})
it('uses mocked generateId', () => {
expect(generateId()).toBe('mocked-id')
})
})Spying on methods
import { describe, it, expect, vi } from 'vitest'
describe('Spying', () => {
it('spies on object method', () => {
const user = {
name: 'John',
greet() {
return `Hello, ${this.name}!`
}
}
const spy = vi.spyOn(user, 'greet')
const result = user.greet()
expect(spy).toHaveBeenCalled()
expect(result).toBe('Hello, John!')
spy.mockRestore() // Restore the original implementation
})
it('spies on console.log', () => {
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
console.log('test message')
expect(spy).toHaveBeenCalledWith('test message')
spy.mockRestore()
})
it('spies and changes implementation', () => {
const obj = {
getValue: () => 'original'
}
vi.spyOn(obj, 'getValue').mockReturnValue('mocked')
expect(obj.getValue()).toBe('mocked')
})
})Mocking timers
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: NodeJS.Timeout
return (...args) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => fn(...args), delay)
}
}
describe('Timer mocking', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('advances timers', async () => {
const callback = vi.fn()
setTimeout(callback, 1000)
expect(callback).not.toHaveBeenCalled()
vi.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalled()
})
it('runs all timers', () => {
const callback1 = vi.fn()
const callback2 = vi.fn()
setTimeout(callback1, 1000)
setTimeout(callback2, 2000)
vi.runAllTimers()
expect(callback1).toHaveBeenCalled()
expect(callback2).toHaveBeenCalled()
})
it('tests debounce', () => {
const callback = vi.fn()
const debouncedFn = debounce(callback, 500)
debouncedFn('a')
debouncedFn('b')
debouncedFn('c')
expect(callback).not.toHaveBeenCalled()
vi.advanceTimersByTime(500)
expect(callback).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('c')
})
it('mocks Date', () => {
vi.setSystemTime(new Date('2024-06-15'))
expect(new Date().toISOString()).toContain('2024-06-15')
})
})Mocking global objects
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
describe('Global mocking', () => {
it('mocks fetch', async () => {
const mockResponse = { data: 'test' }
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse)
}))
const response = await fetch('/api/data')
const data = await response.json()
expect(data).toEqual(mockResponse)
vi.unstubAllGlobals()
})
it('mocks localStorage', () => {
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
}
vi.stubGlobal('localStorage', localStorageMock)
localStorage.setItem('key', 'value')
expect(localStorageMock.setItem).toHaveBeenCalledWith('key', 'value')
vi.unstubAllGlobals()
})
it('mocks window.location', () => {
const locationMock = {
href: 'http://localhost:3000',
assign: vi.fn(),
reload: vi.fn(),
}
vi.stubGlobal('location', locationMock)
location.assign('http://example.com')
expect(locationMock.assign).toHaveBeenCalledWith('http://example.com')
vi.unstubAllGlobals()
})
})Testing React components
Setup for React Testing Library
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})Testing components
// Button.tsx
interface ButtonProps {
children: React.ReactNode
onClick?: () => void
disabled?: boolean
variant?: 'primary' | 'secondary'
}
export function Button({
children,
onClick,
disabled = false,
variant = 'primary'
}: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant}`}
data-testid="button"
>
{children}
</button>
)
}
// Button.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from './Button'
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('renders with correct class', () => {
render(<Button variant="secondary">Test</Button>)
expect(screen.getByTestId('button')).toHaveClass('btn-secondary')
})
it('handles click events', async () => {
const handleClick = vi.fn()
const user = userEvent.setup()
render(<Button onClick={handleClick}>Click me</Button>)
await user.click(screen.getByText('Click me'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>)
expect(screen.getByText('Click me')).toBeDisabled()
})
it('does not call onClick when disabled', async () => {
const handleClick = vi.fn()
const user = userEvent.setup()
render(<Button onClick={handleClick} disabled>Click me</Button>)
await user.click(screen.getByText('Click me'))
expect(handleClick).not.toHaveBeenCalled()
})
})Testing forms
// LoginForm.tsx
import { useState } from 'react'
interface LoginFormProps {
onSubmit: (email: string, password: string) => void
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!email || !password) {
setError('All fields are required')
return
}
if (!email.includes('@')) {
setError('Invalid email format')
return
}
setError('')
onSubmit(email, password)
}
return (
<form onSubmit={handleSubmit} data-testid="login-form">
{error && <div role="alert">{error}</div>}
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">Login</button>
</form>
)
}
// LoginForm.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'
describe('LoginForm', () => {
it('renders form fields', () => {
render(<LoginForm onSubmit={() => {}} />)
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument()
})
it('shows error when fields are empty', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={() => {}} />)
await user.click(screen.getByRole('button', { name: /login/i }))
expect(screen.getByRole('alert')).toHaveTextContent('All fields are required')
})
it('shows error for invalid email', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={() => {}} />)
await user.type(screen.getByLabelText(/email/i), 'invalid')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /login/i }))
expect(screen.getByRole('alert')).toHaveTextContent('Invalid email format')
})
it('calls onSubmit with valid data', async () => {
const handleSubmit = vi.fn()
const user = userEvent.setup()
render(<LoginForm onSubmit={handleSubmit} />)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /login/i }))
expect(handleSubmit).toHaveBeenCalledWith('test@example.com', 'password123')
})
})Testing with context and providers
// ThemeContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react'
interface ThemeContextType {
theme: 'light' | 'dark'
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType | null>(null)
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light')
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within ThemeProvider')
}
return context
}
// ThemedButton.tsx
import { useTheme } from './ThemeContext'
export function ThemedButton() {
const { theme, toggleTheme } = useTheme()
return (
<button onClick={toggleTheme}>
Current theme: {theme}
</button>
)
}
// ThemedButton.test.tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ThemeProvider } from './ThemeContext'
import { ThemedButton } from './ThemedButton'
// Helper for rendering with a provider
function renderWithTheme(ui: React.ReactElement) {
return render(
<ThemeProvider>{ui}</ThemeProvider>
)
}
describe('ThemedButton', () => {
it('shows current theme', () => {
renderWithTheme(<ThemedButton />)
expect(screen.getByText(/current theme: light/i)).toBeInTheDocument()
})
it('toggles theme on click', async () => {
const user = userEvent.setup()
renderWithTheme(<ThemedButton />)
await user.click(screen.getByRole('button'))
expect(screen.getByText(/current theme: dark/i)).toBeInTheDocument()
})
})Testing hooks
// useCounter.ts
import { useState, useCallback } from 'react'
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const increment = useCallback(() => setCount(c => c + 1), [])
const decrement = useCallback(() => setCount(c => c - 1), [])
const reset = useCallback(() => setCount(initialValue), [initialValue])
return { count, increment, decrement, reset }
}
// useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
it('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
it('increments counter', () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
it('decrements counter', () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.decrement()
})
expect(result.current.count).toBe(4)
})
it('resets counter', () => {
const { result } = renderHook(() => useCounter(10))
act(() => {
result.current.increment()
result.current.increment()
result.current.reset()
})
expect(result.current.count).toBe(10)
})
})Snapshot testing
// Card.tsx
interface CardProps {
title: string
description: string
imageUrl?: string
}
export function Card({ title, description, imageUrl }: CardProps) {
return (
<div className="card">
{imageUrl && <img src={imageUrl} alt={title} />}
<h2>{title}</h2>
<p>{description}</p>
</div>
)
}
// Card.test.tsx
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { Card } from './Card'
describe('Card', () => {
it('matches snapshot', () => {
const { container } = render(
<Card
title="Test Card"
description="This is a test description"
/>
)
expect(container).toMatchSnapshot()
})
it('matches snapshot with image', () => {
const { container } = render(
<Card
title="Test Card"
description="Description"
imageUrl="https://example.com/image.jpg"
/>
)
expect(container).toMatchSnapshot()
})
// Inline snapshot - saved in the test file itself
it('matches inline snapshot', () => {
const { container } = render(
<Card title="Title" description="Desc" />
)
expect(container.innerHTML).toMatchInlineSnapshot(`
"<div class=\\"card\\"><h2>Title</h2><p>Desc</p></div>"
`)
})
})Coverage
Coverage configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
coverage: {
provider: 'v8', // or 'istanbul'
reporter: ['text', 'json', 'html', 'lcov'],
reportsDirectory: './coverage',
// Files to include
include: ['src/**/*.{ts,tsx}'],
// Files to exclude
exclude: [
'node_modules',
'src/test',
'**/*.d.ts',
'**/*.test.{ts,tsx}',
'**/index.ts',
],
// Coverage thresholds
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
// Do not allow dropping below thresholds
thresholdAutoUpdate: false,
// All files, even untested ones
all: true,
},
},
})Running coverage
# Generate coverage report
pnpm test:coverage
# With UI
pnpm vitest --coverage --uiVitest UI
# Installation
npm install -D @vitest/ui
# Run
pnpm vitest --uiVitest UI offers:
- Interactive test preview in the browser
- Filtering and searching tests
- Coverage preview
- Re-run individual tests
- Dependency graph view
In-source testing
Vitest allows writing tests in the same file as the code:
// math.ts
export function sum(a: number, b: number): number {
return a + b
}
export function multiply(a: number, b: number): number {
return a * b
}
// Tests in the same file - removed during build
if (import.meta.vitest) {
const { describe, it, expect } = import.meta.vitest
describe('sum', () => {
it('adds numbers', () => {
expect(sum(1, 2)).toBe(3)
})
})
describe('multiply', () => {
it('multiplies numbers', () => {
expect(multiply(2, 3)).toBe(6)
})
})
}Configuration:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
define: {
'import.meta.vitest': 'undefined',
},
test: {
includeSource: ['src/**/*.{js,ts}'],
},
})Browser mode
# Installation
npm install -D @vitest/browser webdriverio// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
browser: {
enabled: true,
provider: 'webdriverio',
name: 'chrome',
},
},
})CI/CD integration
GitHub Actions
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install
- name: Run tests
run: pnpm test:run
- name: Run coverage
run: pnpm test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
fail_ci_if_error: trueGitLab CI
# .gitlab-ci.yml
test:
image: node:20
stage: test
before_script:
- npm install -g pnpm
- pnpm install
script:
- pnpm test:run
- pnpm test:coverage
coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xmlMigrating from Jest
Automatic migration
npx vitest-migrateManual changes
// Jest
jest.fn()
jest.spyOn(obj, 'method')
jest.mock('./module')
jest.useFakeTimers()
// Vitest
vi.fn()
vi.spyOn(obj, 'method')
vi.mock('./module')
vi.useFakeTimers()// Jest - globals in setupFilesAfterEnv
// jest.config.js
module.exports = {
setupFilesAfterEnv: ['./src/setupTests.ts']
}
// Vitest
// vitest.config.ts
export default defineConfig({
test: {
globals: true,
setupFiles: ['./src/test/setup.ts']
}
})Best practices
1. Test organization
src/
├── components/
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.test.tsx # Tests next to code
│ │ └── index.ts
├── hooks/
│ ├── useCounter.ts
│ └── useCounter.test.ts
├── utils/
│ ├── format.ts
│ └── format.test.ts
└── test/
├── setup.ts # Global setup
├── mocks/ # Shared mocks
│ └── handlers.ts
└── utils/ # Test utilities
└── renderWithProviders.tsx2. Test naming
// Descriptive names
describe('Button component', () => {
describe('when clicked', () => {
it('should call onClick handler', () => {})
it('should not call onClick when disabled', () => {})
})
describe('when loading', () => {
it('should show spinner', () => {})
it('should disable the button', () => {})
})
})3. Avoid implementation details
// ❌ Testing implementation
it('sets state to true', () => {
const { result } = renderHook(() => useToggle())
expect(result.current[0]).toBe(false)
})
// ✅ Testing behavior
it('toggles from off to on', () => {
render(<Toggle />)
const button = screen.getByRole('switch')
expect(button).toHaveAttribute('aria-checked', 'false')
fireEvent.click(button)
expect(button).toHaveAttribute('aria-checked', 'true')
})4. Use appropriate queries
// Query priority in React Testing Library:
// 1. getByRole - most accessible
// 2. getByLabelText - for forms
// 3. getByPlaceholderText - for inputs
// 4. getByText - for text
// 5. getByTestId - last resort
// ✅ Good
screen.getByRole('button', { name: /submit/i })
screen.getByLabelText(/email/i)
// ❌ Avoid
screen.getByTestId('submit-button')
container.querySelector('.btn-submit')5. Isolate tests
describe('UserService', () => {
let db: TestDatabase
beforeEach(async () => {
db = await createTestDatabase()
})
afterEach(async () => {
await db.cleanup()
})
it('creates user', async () => {
// Test uses a clean database
})
})FAQ - Frequently asked questions
How to run a single test?
# By file name
pnpm vitest Button.test.tsx
# By pattern
pnpm vitest -t "should render"
# In watch mode, press 't' and type a patternHow to debug tests?
// 1. Use console.log
it('debug test', () => {
const result = someFunction()
console.log('Result:', result)
})
// 2. Use screen.debug()
it('debug DOM', () => {
render(<Component />)
screen.debug() // Displays the entire DOM
screen.debug(screen.getByRole('button')) // Specific element
})
// 3. VS Code debugger
// Add "test": "vitest --inspect-brk --single-thread" to scriptsHow to test components with external dependencies?
// Mock an external module
vi.mock('next/router', () => ({
useRouter: () => ({
push: vi.fn(),
pathname: '/',
query: {},
}),
}))
// Mock fetch
vi.stubGlobal('fetch', vi.fn())Vitest vs Playwright - when to use which?
| Vitest | Playwright |
|---|---|
| Unit/integration tests | E2E tests |
| Fast, isolated | Slower, realistic |
| jsdom/happy-dom | Real browsers |
| Testing logic | Testing user flows |
| Individual components | Entire application |
Summary
Vitest is a modern testing framework that offers:
- Blazing speed - Thanks to integration with Vite
- Zero configuration - TypeScript, ESM, JSX out-of-box
- Jest compatibility - Easy migration
- Great DX - Watch mode, UI, debugging
- Flexibility - From unit tests to browser testing
If you use Vite, Vitest is the obvious choice. Even in projects without Vite, it is worth considering Vitest for its speed and modern approach to testing.