Vitest, unit testing built on Vite
Vitest is a test runner that uses the same configuration as Vite. If your project builds through Vite, tests inherit path aliases, plugins, and transforms without duplicating settings. MIT licensed, currently stable at 4.1.10, with version 5 in beta.
Why this won over Jest
The answer is not "because it is faster", even though that is how it tends to be marketed. The reason is more mundane and concerns configuration.
For years a typical frontend project carried two parallel configurations: one for the build tool and one for the test runner. Both had to know the same things about path aliases, TypeScript handling, and what to do with an imported stylesheet or image. They drifted apart regularly, and the symptom was a test unable to import a module the application imported without trouble.
Vitest removes that problem by reading the build tool's configuration. A single source of truth means an alias added for the application works in tests immediately, and a plugin handling an unusual file format needs no second implementation.
Speed is a side effect here, but a real one. Tests run from native modules rather than passing through a transpilation layer for every file, and in watch mode only files depending on what you changed are recomputed. Across a large suite the difference is noticeable.
Interface compatibility with Jest was a strategic decision and it is what drove adoption, including in Next.js projects where migration used to hurt most. The functions describing tests, the assertions, and the mocks carry the same names, so migrating an existing suite is mostly a configuration change rather than a rewrite.
There is a boundary to that compatibility worth asking about before migrating, though. Tests relying on mechanisms specific to the older tool, its own file transformation approach or plugins written against its internals, will not carry over automatically. The same applies to suites leaning on implicit module substitution, since here substitution is always explicit. In practice moving a typical unit test suite takes an afternoon, while a suite with elaborate bespoke test infrastructure takes days.
Browser mode, the biggest change in version 4
This is the most important thing to know, because material predating that release describes it as experimental.
Browser mode stopped being an experiment in version 4 and is marked stable. It runs the same tests in a real browser instead of an environment simulating a document, with no change to test code.
The difference matters practically. A simulated document environment is a browser implemented in JavaScript and does not reproduce everything: computed styles, element dimensions, visibility observers, and pointer event behaviour work differently there or not at all. A test checking whether an element is visible can pass there and fail for a user.
Stabilisation changed how it is configured, though, and that is the trap when upgrading. The browser provider is now installed as a separate package depending on whether you use Playwright, WebDriverIO, or preview mode. Configuration written before version 4 stops working and needs adjusting.
Screenshot comparison arrived too, along with execution trace recording familiar from Playwright. The latter is particularly useful for tests failing only on a build server, since instead of guessing you open the trace and see the page state at every step.
The cost is worth remembering, though. Launching a real browser takes longer than a simulated environment, so a sensible arrangement keeps most tests lightweight and reserves browser mode for cases where actual rendering matters.
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 considerably faster than through a separate transpilation layer. 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 | v8 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": "^4.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', 'vmForks'
maxWorkers: '50%',
isolate: true,
// 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 raise thresholds automatically after a better run
autoUpdate: false,
},
},
},
})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 (the provider is a separate package: playwright, webdriverio or preview)
npm install -D vitest @vitest/browser-playwright// vitest.config.ts
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [
{ browser: 'chromium' },
],
},
},
})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
Where the migration starts
There is no official tool that rewrites the tests, and the migration guide describes the work as manual. You start by swapping dependencies and configuration, then fix whatever fails afterwards.
npm uninstall jest ts-jest babel-jest jest-environment-jsdom
npm install -D vitest jsdomManual 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
// Bad - testing implementation
it('sets state to true', () => {
const { result } = renderHook(() => useToggle())
expect(result.current[0]).toBe(false)
})
// Good - testing behaviour
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)
// Bad
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 --no-file-parallelism" 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 |
What version 5 brings
Version 5 is in beta, so details may still shift, but a few things are worth knowing when planning, because they concern environment requirements.
Version floors were raised: Node.js 22 or newer and Vite 6.4 or newer will be required. For projects on older versions that means upgrading the test runner drags the rest along, and that is usually its own task.
Default mock behaviour changes too. Clearing mocks between tests will be on by default, so call state resets before every test without asking. That is a change for the better, since it eliminates a common class of inter test dependencies, but it may expose tests that passed by accident on state left by a predecessor.
Previously deprecated ways of marking sequentially executed tests will be removed. If your suite uses them, review it early, since the change is mechanical but touches many files at once.
Practical advice: do not migrate to a beta release on a production project, but check now whether you meet the environment version requirements. If not, upgrading Node.js is what to schedule before version 5 ships stable.
Module mocking, where the trouble concentrates
Substituting modules in tests is a feature everyone uses and almost everyone applies too broadly. A few things are worth knowing before diving in.
The first is call hoisting. The declaration replacing a module is moved to the top of the file, before imports, regardless of where you wrote it. The consequence is that it cannot reference a variable defined higher in the file, because at execution time that variable does not exist yet. The error mentions access before initialisation and is the most common mistake in early mock heavy tests.
The second is scope. A replaced module stays replaced for the whole test file rather than one case. If you need different behaviour across tests, replace it once and change what the mock returns inside individual tests.
The third, and the most substantive, is whether to mock at all. A test replacing everything around the code under test checks that functions get called in a fixed order rather than that the code does what it should. Such a test passes after a refactor that breaks behaviour and fails after one that breaks nothing. Mock the system boundaries, meaning network, time, randomness, and the filesystem, and test your own logic for real.
The fourth is time. Controlling the clock lets you test time dependent behaviour without waiting, and it is almost always better than counting down in a test. Just remember to restore the real clock afterwards, or later tests in the same file inherit a frozen clock and start behaving strangely.
The fifth concerns code coverage and is more about interpretation than configuration. A percentage says how many lines executed during the tests, not how many behaviours were checked. A test that calls a function without verifying the result raises coverage exactly as much as a sound one, so a threshold set in configuration guards against untouched code rather than against untested code. Treat the number as a signal about where to look rather than as a goal, because a team chasing a high percentage usually reaches it the cheapest way available.
Vitest against the alternatives
| Feature | Vitest | Jest | Playwright |
|---|---|---|---|
| Test kinds | unit and component | unit and component | end to end and component |
| Configuration | inherited from Vite | separate | separate |
| Native module support | native | via a compatibility layer | native |
| Real browser | optional, browser mode | no | always |
| Speed per test | high | medium | low |
| Ecosystem and material | growing | largest | large |
The division of labour between these tools is fairly settled today. Logic, helper functions, and component behaviour go to the lightweight runner. Flows spanning the whole application, where a real browser and a real server matter, are tested separately. The overlap around component testing is real, and the choice depends on whether you need faithful rendering or speed.
Documentation lives on the Vitest site, and the source in the project repository.