JavaScript Vitest: Fast Unit Testing
Jest dominated JavaScript testing for years, but it was built for a CommonJS world. As ESM became the standard and Vite emerged as the fastest build tool, running Jest alongside Vite meant...
Key Insights
- Vitest runs 10-20x faster than Jest on large codebases by leveraging Vite’s native ESM support and smart caching, making TDD workflows actually enjoyable
- The Jest-compatible API means you can migrate existing test suites with minimal changes while gaining instant HMR-powered test reruns
- In-source testing lets you co-locate tests with implementation code, eliminating the mental overhead of switching between files during development
Why Vitest Exists
Jest dominated JavaScript testing for years, but it was built for a CommonJS world. As ESM became the standard and Vite emerged as the fastest build tool, running Jest alongside Vite meant maintaining two separate transformation pipelines. Your app used Vite’s lightning-fast ESM handling while your tests crawled through Jest’s CommonJS transformation.
Vitest fixes this by using Vite as its test runner. Same config, same plugins, same transformation pipeline. If your code works in Vite, it works in Vitest—no additional setup required.
Choose Vitest when you’re already using Vite, starting a new project, or when Jest’s startup time has become a bottleneck. Stick with Jest if you’re deep in a legacy CommonJS codebase with extensive Jest-specific infrastructure.
Quick Setup and Configuration
Install Vitest as a dev dependency:
npm install -D vitest
Add a test script to your package.json:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"coverage": "vitest run --coverage"
}
}
If you’re already using Vite, Vitest reads your vite.config.ts automatically. For test-specific options, add a test block:
// vite.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{js,ts}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'src/**/*.d.ts']
},
setupFiles: ['./src/test/setup.ts']
}
})
For standalone projects without Vite, create vitest.config.ts:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node'
}
})
The globals: true option makes describe, it, and expect available without imports—matching Jest’s behavior. If you prefer explicit imports for better IDE support, leave it false and import from vitest.
Writing Your First Tests
Vitest follows the same conventions as Jest. Test files live alongside source files with .test.ts or .spec.ts suffixes. Here’s a practical example testing a cart utility:
// cart.ts
export interface CartItem {
id: string
name: string
price: number
quantity: number
}
export function calculateTotal(items: CartItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
}
export function applyDiscount(total: number, discountPercent: number): number {
if (discountPercent < 0 || discountPercent > 100) {
throw new Error('Discount must be between 0 and 100')
}
return total * (1 - discountPercent / 100)
}
export function formatPrice(amount: number): string {
return `$${amount.toFixed(2)}`
}
// cart.test.ts
import { describe, it, expect } from 'vitest'
import { calculateTotal, applyDiscount, formatPrice, CartItem } from './cart'
describe('Cart utilities', () => {
const sampleItems: CartItem[] = [
{ id: '1', name: 'Widget', price: 10.00, quantity: 2 },
{ id: '2', name: 'Gadget', price: 25.50, quantity: 1 }
]
describe('calculateTotal', () => {
it('sums price * quantity for all items', () => {
expect(calculateTotal(sampleItems)).toBe(45.50)
})
it('returns 0 for empty cart', () => {
expect(calculateTotal([])).toBe(0)
})
})
describe('applyDiscount', () => {
it('reduces total by percentage', () => {
expect(applyDiscount(100, 20)).toBe(80)
})
it('throws for invalid discount values', () => {
expect(() => applyDiscount(100, -5)).toThrow('Discount must be between 0 and 100')
expect(() => applyDiscount(100, 150)).toThrow()
})
})
describe('formatPrice', () => {
it('formats with dollar sign and two decimals', () => {
expect(formatPrice(45.5)).toBe('$45.50')
expect(formatPrice(100)).toBe('$100.00')
})
})
})
Run npm test and Vitest enters watch mode, rerunning relevant tests as you edit files. Use npm run test:run for a single execution in CI.
Assertions and Matchers
Vitest includes all Jest matchers plus additional ones for better TypeScript support. Here’s a reference for common scenarios:
// Equality
expect(value).toBe(primitive) // strict equality (===)
expect(value).toEqual(object) // deep equality
expect(value).toStrictEqual(object) // deep equality + same types
// Truthiness
expect(value).toBeTruthy()
expect(value).toBeFalsy()
expect(value).toBeNull()
expect(value).toBeDefined()
// Numbers
expect(num).toBeGreaterThan(3)
expect(num).toBeLessThanOrEqual(10)
expect(0.1 + 0.2).toBeCloseTo(0.3) // floating point comparison
// Strings and arrays
expect(str).toMatch(/pattern/)
expect(array).toContain(item)
expect(array).toHaveLength(3)
// Objects
expect(obj).toHaveProperty('key')
expect(obj).toMatchObject({ partial: 'match' })
Async testing uses resolves and rejects matchers:
// api.test.ts
import { describe, it, expect } from 'vitest'
import { fetchUser, fetchUsers } from './api'
describe('API client', () => {
it('fetches a user by ID', async () => {
await expect(fetchUser('123')).resolves.toMatchObject({
id: '123',
email: expect.stringContaining('@')
})
})
it('rejects for non-existent user', async () => {
await expect(fetchUser('invalid')).rejects.toThrow('User not found')
})
it('returns array of users', async () => {
const users = await fetchUsers()
expect(users).toHaveLength(expect.any(Number))
expect(users[0]).toEqual(
expect.objectContaining({
id: expect.any(String),
name: expect.any(String)
})
)
})
})
Mocking and Spies
Vitest provides vi as its mocking utility, mirroring Jest’s jest object. Use vi.fn() for function mocks and vi.spyOn() to wrap existing methods:
// notification.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NotificationService } from './notification'
describe('NotificationService', () => {
let service: NotificationService
let mockSend: ReturnType<typeof vi.fn>
beforeEach(() => {
mockSend = vi.fn().mockResolvedValue({ delivered: true })
service = new NotificationService(mockSend)
})
it('calls send with formatted message', async () => {
await service.notify('user@example.com', 'Hello')
expect(mockSend).toHaveBeenCalledTimes(1)
expect(mockSend).toHaveBeenCalledWith({
to: 'user@example.com',
body: 'Hello',
timestamp: expect.any(Date)
})
})
it('retries on failure', async () => {
mockSend
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({ delivered: true })
await service.notify('user@example.com', 'Hello')
expect(mockSend).toHaveBeenCalledTimes(2)
})
})
For module mocking, use vi.mock() at the top of your test file:
// user-service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { UserService } from './user-service'
import { db } from './database'
vi.mock('./database', () => ({
db: {
users: {
findById: vi.fn(),
create: vi.fn(),
update: vi.fn()
}
}
}))
describe('UserService', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('creates user with hashed password', async () => {
vi.mocked(db.users.create).mockResolvedValue({ id: '1', email: 'test@example.com' })
const service = new UserService()
const user = await service.createUser('test@example.com', 'password123')
expect(db.users.create).toHaveBeenCalledWith(
expect.objectContaining({
email: 'test@example.com',
passwordHash: expect.not.stringContaining('password123')
})
)
})
})
Leveraging Speed Features
Vitest’s watch mode uses Vite’s HMR to rerun only affected tests instantly. But the real productivity boost comes from in-source testing—embedding tests directly in your source files:
// math.ts
export function fibonacci(n: number): number {
if (n <= 1) return n
return fibonacci(n - 1) + fibonacci(n - 2)
}
export function isPrime(n: number): boolean {
if (n < 2) return false
for (let i = 2; i <= Math.sqrt(n); i++) {
if (n % i === 0) return false
}
return true
}
// In-source tests - stripped from production builds
if (import.meta.vitest) {
const { describe, it, expect } = import.meta.vitest
describe('fibonacci', () => {
it('returns correct sequence values', () => {
expect(fibonacci(0)).toBe(0)
expect(fibonacci(1)).toBe(1)
expect(fibonacci(10)).toBe(55)
})
})
describe('isPrime', () => {
it('identifies prime numbers', () => {
expect(isPrime(2)).toBe(true)
expect(isPrime(17)).toBe(true)
expect(isPrime(4)).toBe(false)
})
})
}
Enable this in your config:
// vite.config.ts
export default defineConfig({
test: {
includeSource: ['src/**/*.ts']
},
define: {
'import.meta.vitest': 'undefined' // strips tests from production
}
})
In-source testing eliminates context switching for utility functions. You see the implementation and tests together, making TDD natural.
CI Integration and Coverage
Vitest runs headless in CI with the run command. Here’s a complete GitHub Actions workflow:
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
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:run -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./coverage/coverage-final.json
fail_ci_if_error: true
Install the coverage provider:
npm install -D @vitest/coverage-v8
Coverage reports generate in the coverage directory. The V8 provider is faster; use Istanbul (@vitest/coverage-istanbul) if you need specific Istanbul features.
Wrapping Up
Vitest isn’t just a faster Jest—it’s testing designed for modern JavaScript. The shared Vite pipeline eliminates configuration drift between dev and test environments. In-source testing keeps implementation and tests together. Watch mode with HMR makes the feedback loop nearly instant.
Start by replacing Jest in a single project. The migration is straightforward: swap jest imports with vitest, rename jest.fn() to vi.fn(), and delete your Babel configuration. Your tests will run faster, and you’ll wonder why you waited so long to switch.