Vitest: Complete Testing Guide for Developers
Testing is a cornerstone of modern software development, and choosing the right testing framework can dramatically improve your productivity and code quality. Vitest is a blazing-fast, modern testing framework built by the Vite team, designed to integrate seamlessly with Vite-powered projects while remaining flexible enough for any JavaScript or TypeScript codebase. In this guide, we'll explore what Vitest is, why it matters, how to set it up, and how to write effective tests following best practices.
What Is Vitest?
Vitest is a unit testing framework created by Anthony Fu and the Vite ecosystem team. It is built on top of Vite's transform pipeline, which means it leverages the same blazing-fast bundler and HMR (Hot Module Replacement) infrastructure that powers modern frontend development. Vitest is designed to be a drop-in replacement for Jest in many cases, offering a familiar API while delivering superior performance and native ESM support.
Key characteristics of Vitest include:
- Native ESM support: Vitest understands ES modules out of the box without complex configuration.
- Vite-powered: It reuses your existing Vite config, so transforms, plugins, and aliases work automatically.
- Jest-compatible API: If you know Jest, you already know Vitest. The
expect,describe,it, andmockAPIs are nearly identical. - Smart watch mode: Vitest only re-runs tests affected by file changes, making iterative development fast.
- Built-in coverage: Powered by v8 or istanbul, coverage reports require no extra setup.
- TypeScript support: Write tests in TypeScript with zero configuration.
Why Vitest Matters
Before Vitest, JavaScript developers typically relied on Jest for testing. While Jest is mature and capable, it was built before ESM became standard and can be slow in large projects, especially those using Vite. Vitest solves several pain points that developers face with traditional testing frameworks.
Performance: Vitest's use of Vite's transform pipeline means tests start almost instantly. In watch mode, only affected tests re-run, which can reduce feedback loops from seconds to milliseconds. For large monorepos, this difference is transformative.
Configuration simplicity: If you already use Vite, Vitest reads your vite.config.ts file. Aliases, environment variables, and plugins are shared automatically. There's no need to duplicate configuration between your build tool and your test runner.
Modern JavaScript: Vitest fully supports ESM, top-level await, and modern browser APIs. You don't need to fight with Babel transforms or CommonJS interop issues.
Developer experience: The watch mode is intelligent and interactive. You can filter tests, update snapshots, and see results in a clean terminal UI. Source map support makes stack traces accurate and easy to follow.
Installing Vitest
Getting started with Vitest is straightforward. You can add it to any project, whether or not you already use Vite. The simplest way is to install it as a development dependency.
npm install -D vitest
If you're starting a new Vite project, you can scaffold one with the Vitest template:
npm create vite@latest my-app -- --template vue-ts
cd my-app
npm install -D vitest @vue/test-utils jsdom
For projects that need DOM testing, you'll also want a DOM environment. Vitest supports jsdom and happy-dom. Happy-dom is generally faster, while jsdom is more complete.
npm install -D happy-dom
Configuring Vitest
Vitest can be configured in several ways. The most common approach is to add a test property to your existing vite.config.ts file. This keeps all configuration in one place.
// vite.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'happy-dom',
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
},
},
})
If you prefer a separate configuration file, you can create a vitest.config.ts file. This is useful when your test configuration differs significantly from your build configuration.
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
environment: 'node',
include: ['tests/**/*.test.ts'],
exclude: ['node_modules', 'dist'],
},
})
Important configuration options to know:
environment: Sets the test environment (node,jsdom,happy-dom, or custom).globals: When true, allows usingdescribe,it,expectwithout importing them.include: Glob patterns for test files.coverage: Configuration for code coverage reporting.setupFiles: Files to run before each test file.mocks: Global mocks for modules.
Adding Scripts to package.json
Add convenient npm scripts to run your tests. This is a small but important step for team workflows.
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}
}
The test script starts Vitest in watch mode, ideal for local development. The test:run script runs tests once and exits, which is what you want in CI pipelines. The test:ui script launches a browser-based UI for viewing and managing tests, and test:coverage generates a coverage report.
Writing Your First Test
Let's start with a simple example. Suppose you have a utility function that calculates the sum of an array of numbers.
// src/utils/math.ts
export function sum(numbers: number[]): number {
return numbers.reduce((acc, n) => acc + n, 0)
}
export function average(numbers: number[]): number {
if (numbers.length === 0) throw new Error('Cannot average empty array')
return sum(numbers) / numbers.length
}
Now let's write tests for these functions. Vitest test files typically use the .test.ts or .spec.ts extension.
// src/utils/math.test.ts
import { describe, it, expect } from 'vitest'
import { sum, average } from './math'
describe('sum', () => {
it('adds positive numbers correctly', () => {
expect(sum([1, 2, 3])).toBe(6)
})
it('returns 0 for an empty array', () => {
expect(sum([])).toBe(0)
})
it('handles negative numbers', () => {
expect(sum([-1, -2, 3])).toBe(0)
})
})
describe('average', () => {
it('calculates the average correctly', () => {
expect(average([2, 4, 6])).toBe(4)
})
it('throws on empty array', () => {
expect(() => average([])).toThrow('Cannot average empty array')
})
})
Run your tests with npm test and you should see green checkmarks in your terminal. The describe block groups related tests, it defines individual test cases, and expect makes assertions about values.
Understanding Matchers
Vitest's expect API provides a rich set of matchers for making assertions. Here are the most commonly used ones.
// Equality matchers
expect(value).toBe(other) // Strict equality (===)
expect(value).toEqual(other) // Deep equality
expect(value).toStrictEqual(other) // Deep equality, strict types
// Truthiness matchers
expect(value).toBeTruthy()
expect(value).toBeFalsy()
expect(value).toBeNull()
expect(value).toBeUndefined()
expect(value).toBeDefined()
expect(value).toBeNaN()
// Number matchers
expect(value).toBeGreaterThan(5)
expect(value).toBeGreaterThanOrEqual(5)
expect(value).toBeLessThan(10)
expect(value).toBeCloseTo(0.3, 5) // Floating point comparison
// String matchers
expect(str).toContain('substring')
expect(str).toMatch(/regex/)
expect(str).toHaveLength(5)
// Array matchers
expect(arr).toContain(item)
expect(arr).toHaveLength(3)
expect(arr).toContainEqual({ key: 'value' })
// Object matchers
expect(obj).toHaveProperty('key')
expect(obj).toHaveProperty('key', 'value')
expect(obj).toMatchObject({ key: 'value' })
// Negation
expect(value).not.toBe(other)
Testing Asynchronous Code
Modern JavaScript is full of asynchronous operations. Vitest provides several ways to test async code. The simplest approach is to make your test function async and await the operation.
// src/utils/api.ts
export async function fetchUser(id: number): Promise<{ id: number; name: string }> {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) {
throw new Error(`Failed to fetch user ${id}`)
}
return response.json()
}
// src/utils/api.test.ts
import { describe, it, expect } from 'vitest'
import { fetchUser } from './api'
describe('fetchUser', () => {
it('returns user data for valid id', async () => {
const user = await fetchUser(1)
expect(user).toEqual({ id: 1, name: 'Alice' })
})
it('throws for invalid id', async () => {
await expect(fetchUser(999)).rejects.toThrow('Failed to fetch user 999')
})
})
You can also test that a promise resolves or rejects using resolves and rejects matchers, which make async assertions cleaner.
Mocking Functions and Modules
Mocking is essential for isolating the code under test from external dependencies. Vitest provides powerful mocking utilities that work with both CommonJS and ESM modules.
Mocking a function: Use vi.fn() to create a mock function that you can assert against.
// src/utils/calculator.ts
export function calculate(operation: (a: number, b: number) => number, a: number, b: number): number {
return operation(a, b)
}
// src/utils/calculator.test.ts
import { describe, it, expect, vi } from 'vitest'
import { calculate } from './calculator'
describe('calculate', () => {
it('calls the operation function with correct arguments', () => {
const mockOperation = vi.fn((a, b) => a + b)
const result = calculate(mockOperation, 3, 4)
expect(result).toBe(7)
expect(mockOperation).toHaveBeenCalledWith(3, 4)
expect(mockOperation).toHaveBeenCalledTimes(1)
})
})
Mocking a module: Use vi.mock() to replace an entire module with a mock implementation. This is especially useful for mocking API calls, database access, or external services.
// src/services/userService.ts
import { fetchUser } from '../utils/api'
export async function getUserName(id: number): Promise<string> {
const user = await fetchUser(id)
return user.name
}
// src/services/userService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock the api module
vi.mock('../utils/api', () => ({
fetchUser: vi.fn(),
}))
import { fetchUser } from '../utils/api'
import { getUserName } from './userService'
describe('getUserName', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns the user name', async () => {
vi.mocked(fetchUser).mockResolvedValue({ id: 1, name: 'Alice' })
const name = await getUserName(1)
expect(name).toBe('Alice')
expect(fetchUser).toHaveBeenCalledWith(1)
})
it('propagates errors from fetchUser', async () => {
vi.mocked(fetchUser).mockRejectedValue(new Error('Network error'))
await expect(getUserName(1)).rejects.toThrow('Network error')
})
})
Note that vi.mock() calls are hoisted to the top of the file automatically by Vitest's transformer, so they work regardless of where you place them in the file. This is a common source of confusion, so it's worth understanding.
Mocking Timers
When testing code that uses setTimeout, setInterval, or Date, you often don't want to wait for real time to pass. Vitest provides fake timers for this purpose.
// src/utils/debounce.ts
export function debounce<T extends (...args: any[]) => void>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout>
return (...args: Parameters<T>) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
}
// src/utils/debounce.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { debounce } from './debounce'
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('calls the function after the delay', () => {
const fn = vi.fn()
const debounced = debounce(fn, 300)
debounced()
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(299)
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(fn).toHaveBeenCalledTimes(1)
})
it('resets the timer on subsequent calls', () => {
const fn = vi.fn()
const debounced = debounce(fn, 300)
debounced()
vi.advanceTimersByTime(200)
debounced()
vi.advanceTimersByTime(200)
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(100)
expect(fn).toHaveBeenCalledTimes(1)
})
})
Snapshot Testing
Snapshot testing is useful for verifying that the output of a function or component hasn't changed unexpectedly. Vitest stores a snapshot file alongside your test, and future runs compare against it.
// src/utils/format.ts
export function formatUser(user: { name: string; age: number; email: string }): string {
return `Name: ${user.name}, Age: ${user.age}, Email: ${user.email}`
}
// src/utils/format.test.ts
import { describe, it, expect } from 'vitest'
import { formatUser } from './format'
describe('formatUser', () => {
it('matches snapshot', () => {
const result = formatUser({ name: 'Alice', age: 30, email: 'alice@example.com' })
expect(result).toMatchInlineSnapshot(`"Name: Alice, Age: 30, Email: alice@example.com"`)
})
})
When you use toMatchSnapshot() without an inline argument, Vitest creates a __snapshots__ directory with .snap files. To update snapshots after intentional changes, run vitest -u. Use snapshot testing judiciously—it's great for serializable output but can lead to false confidence if overused.
Testing Vue Components
Vitest pairs naturally with @vue/test-utils for testing Vue components. Here's a complete example of testing a Vue 3 component.
<!-- src/components/Counter.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const increment = () => count.value++
const decrement = () => count.value--
</script>
<template>
<div>
<p data-testid="count">{{ count }}</p>
<button data-testid="increment" @click="increment">+</button>
<button data-testid="decrement" @click="decrement">-</button>
</div>
</template>
// src/components/Counter.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('Counter', () => {
it('renders initial count of 0', () => {
const wrapper = mount(Counter)
expect(wrapper.find('[data-testid="count"]').text()).toBe('0')
})
it('increments count when button clicked', async () => {
const wrapper = mount(Counter)
await wrapper.find('[data-testid="increment"]').trigger('click')
expect(wrapper.find('[data-testid="count"]').text()).toBe('1')
})
it('decrements count when button clicked', async () => {
const wrapper = mount(Counter)
await wrapper.find('[data-testid="decrement"]').trigger('click')
expect(wrapper.find('[data-testid="count"]').text()).toBe('-1')
})
})
Testing React Components
For React projects, Vitest works beautifully with React Testing Library. Here's an example.
// src/components/Todo.tsx
import { useState } from 'react'
interface Todo {
id: number
text: string
done: boolean
}
export function Todo() {
const [todos, setTodos] = useState<Todo[]>([])
const [input, setInput] = useState('')
const addTodo = () => {
if (input.trim()) {
setTodos([...todos, { id: Date.now(), text: input, done: false }])
setInput('')
}
}
return (
<div>
<input
data-testid="todo-input"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<button data-testid="add-button" onClick={addTodo}>
Add
</button>
<ul data-testid="todo-list">
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</div>
)
}
// src/components/Todo.test.tsx
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { Todo } from './Todo'
describe('Todo', () => {
it('adds a new todo', () => {
render(<Todo />)
const input = screen.getByTestId('todo-input') as HTMLInputElement
const button = screen.getByTestId('add-button')
fireEvent.change(input, { target: { value: 'Buy groceries' } })
fireEvent.click(button)
expect(screen.getByText('Buy groceries')).toBeDefined()
expect(input.value).toBe('')
})
})
Setup and Teardown Hooks
Vitest provides lifecycle hooks that run before or after tests. These are useful for setting up common state, initializing databases, or cleaning up resources.
import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from 'vitest'
describe('database operations', () => {
beforeAll(async () => {
// Runs once before all tests in this describe block
await setupDatabase()
})
beforeEach(async () => {
// Runs before each test
await clearTable('users')
})
afterEach(async () => {
// Runs after each test
await cleanupFiles()
})
afterAll(async () => {
// Runs once after all tests
await closeDatabase()
})
it('inserts a user', async () => {
// Test logic here
})
it('deletes a user', async () => {
// Test logic here
})
})
Code Coverage
Measuring code coverage helps you identify untested code paths. Vitest supports both v8 and istanbul coverage providers. V8 is faster and recommended for most projects.
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.test.{ts,tsx}', 'src/types/**'],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
},
},
})
Run coverage with npm run test:coverage. The text reporter prints a summary table in the terminal, while html generates a detailed browsable report in the coverage/ directory. Setting thresholds ensures your CI pipeline fails if coverage drops below acceptable levels.
Running Tests in CI
In continuous integration, you want tests to run once and exit with a non-zero code on failure. Use the run command for this.
# GitHub Actions example
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run test:coverage
Vitest automatically detects CI environments and disables watch mode. You can also explicitly set CI=true in your environment variables to ensure consistent behavior.
Best Practices
Writing good tests is as important as choosing the right framework. Here are proven best practices to follow when working with Vitest.
- Test behavior, not implementation: Focus on what your code does, not how it does it. Tests that assert internal implementation details break easily during refactoring and provide less value.
- Keep tests isolated: Each test should be independent. Avoid shared mutable state between tests. Use
beforeEachto reset state andvi.clearAllMocks()to reset mock call history. - Name tests descriptively: Use clear, behavior-focused descriptions.
it('returns empty array when no items match filter')is far better thanit('works'). - Follow the AAA pattern: Structure tests with Arrange, Act, Assert sections. Set up the preconditions, perform the action, then verify the result.
- Avoid testing framework code: Don't write tests that verify that Vitest or your assertion library works. Test your application code.
- Mock at the boundaries: Mock external dependencies like APIs, databases, and file systems. Don't mock the code you're actually trying to test.
- Use
data-testidattributes: In component tests, preferdata-testidover CSS classes or text content for selecting elements. This makes tests resilient to styling and copy changes. - Don't overuse snapshots: Snapshot tests are convenient but can become a maintenance burden. Use them for truly complex outputs, not for simple values that are better asserted explicitly.
- Set realistic coverage thresholds: Aim for meaningful coverage of critical paths rather than 100% coverage of everything. Some code, like type definitions or simple getters, may not need tests.
- Run tests frequently: Use watch mode during development to get instant feedback. Run the full suite before committing and in CI.
- Colocate tests with source: Keep test files next to the code they test (e.g.,
math.tsandmath.test.tsin the same directory). This makes tests easy to find and maintain. - Use TypeScript for type safety: Vitest has first-class TypeScript support. Leverage it to catch type errors in your tests and mock implementations.
Advanced Features
In-source testing: Vitest allows you to write tests directly inside your source files. This is useful for small utility functions where a separate test file feels excessive.
// src/utils/math.ts
export function sum(a: number, b: number): number {
return a + b
}
// Run with: vitest --run --testNamePattern="in-source"
if (import.meta.vitest) {
const { describe, it, expect } = import.meta.vitest
describe('sum (in-source)', () => {
it('adds two numbers', () => {
expect(sum(2, 3)).toBe(5)
})
})
}
Enable this feature in your config:
// vitest.config.ts
export default defineConfig({
test: {
includeSource: ['src/**/*.{ts,js}'],
},
})
Custom matchers: You can extend Vitest's expect with custom matchers using the vitest module. This is useful for domain-specific assertions.
// src/test/matchers.ts
import { expect } from 'vitest'
expect.extend({
toBeWithinRange(received: number, floor: number, ceiling: number) {
const pass = received >= floor && received <= ceiling
if (pass) {
return {
message: () => `expected ${received} not to be within range ${floor} - ${ceiling}`,
pass: true,
}
}
return {
message: () => `expected ${received} to be within range ${floor} - ${ceiling}`,
pass: false,
}
},
})
declare module 'vitest' {
interface Assertion<T = any> {
toBeWithinRange(floor: number, ceiling: number): void
}
}
Import this file in your setup file to make the custom matcher available everywhere:
// vitest.config.ts
export default defineConfig({
test: {
setupFiles: ['./src/test/matchers.ts'],
},
})
UI mode: Vitest offers a visual UI for browsing and running tests. Install the UI package and launch it with a flag.
npm install -D @vitest/ui
npx vitest --ui
The UI opens in your browser and shows a tree of your test files, real-time results, code coverage, and detailed error messages with source maps. It's an excellent tool for debugging failing tests.
Common Pitfalls and How to Avoid Them
Even with a great framework, there are common mistakes developers make. Being aware of these will save you hours of debugging.
- Forgetting to await async operations: If you forget to
awaitan async call in a test, the test may pass even when the code is broken because the assertion runs before the promise resolves. Always await async operations or useresolves/rejectsmatchers. - Mock hoisting confusion:
vi.mock()is hoisted to the top of the file. If you try to use variables defined later in the file inside your mock factory, it will fail. Usevi.hoisted()to define variables that should be available in mock factories. - Not cleaning up mocks: Mock call counts persist between tests unless you clear them. Use
vi.clearAllMocks()inbeforeEachto avoid cross-test contamination. - Testing the wrong environment: If your code uses DOM APIs but your test environment is set to
node, tests will fail with confusing errors. Make sure your environment matches your code's needs. - Over-mocking: Mocking too much can make tests meaningless. If you mock every dependency, you're testing your mocks, not your code. Mock only what's necessary.
Conclusion
Vitest is a powerful, modern testing framework that combines the familiarity of Jest with the speed and developer experience of Vite. Its native ESM support, intelligent watch mode, and seamless integration with the Vite ecosystem make it an excellent choice for any JavaScript or TypeScript project. By understanding its core APIs—matchers, mocking, timers, snapshots, and lifecycle hooks—you can write tests that are fast, reliable, and maintainable. Pair that with the best practices outlined in this guide, and you'll have a robust testing strategy that catches bugs early, documents your code's behavior, and gives you confidence to refactor and ship. Whether you're starting a new project or migrating from Jest, Vitest is well worth your time to learn and adopt.