Vitest - Modern Testing for Vite Projects
Vitest is a blazing-fast unit testing framework built on top of Vite. It provides a Jest-compatible API while leveraging Vite's native ES modules support and hot module replacement for instant test feedback. If you're using Vite for your build (which includes Vue, React, Svelte, and Astro projects), Vitest is the natural choice for testing.
Why Vitest?
Native ESM support. Unlike Jest, which requires transformers and configuration gymnastics for ES modules, Vitest handles ESM natively. Your tests use the same module resolution as your application.
Vite integration. Vitest reuses your existing vite.config.ts—same aliases, plugins, and transforms. No duplicate configuration.
Jest-compatible API. If you know Jest, you know Vitest. The migration path is straightforward, and most Jest tests run with minimal changes.
Watch mode that actually works. Vitest only re-runs tests affected by your changes, with sub-second feedback during development.
TypeScript out of the box. No ts-jest, no Babel configuration. Just write TypeScript tests.
Installation and Setup
Install Vitest as a dev dependency:
pnpm add -D vitest
Add test scripts to your package.json:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
Configuration
Create vitest.config.ts in your project root. For most projects, you can extend your existing Vite config:
// vitest.config.ts
import { defineConfig, mergeConfig } from 'vitest/config';
import viteConfig from './vite.config';
export default mergeConfig(
viteConfig,
defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
},
},
})
);
Or create a standalone config if you don't need Vite's plugins:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});
Enabling Global APIs
By default, you must import test functions explicitly:
import { describe, it, expect } from 'vitest';
With globals: true, these are available without imports (like Jest). You'll also need to tell TypeScript about the globals:
// tsconfig.json
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}
Test Environments
Vitest supports multiple environments for different testing needs:
| Environment | Use Case | Install |
|---|---|---|
node |
Backend code, utilities, no DOM | Built-in |
jsdom |
Browser-like DOM (most frontend tests) | pnpm add -D jsdom |
happy-dom |
Faster jsdom alternative | pnpm add -D happy-dom |
edge-runtime |
Edge/serverless functions | pnpm add -D @edge-runtime/vm |
Set the environment globally in config or per-file:
// At the top of a specific test file
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
Writing Tests
Basic Structure
Vitest uses the familiar describe/it/expect pattern:
// src/utils/string.test.ts
import { describe, it, expect } from 'vitest';
import { capitalize, slugify } from './string';
describe('string utilities', () => {
describe('capitalize', () => {
it('capitalizes the first letter', () => {
expect(capitalize('hello')).toBe('Hello');
});
it('handles empty strings', () => {
expect(capitalize('')).toBe('');
});
it('preserves already capitalized strings', () => {
expect(capitalize('Hello')).toBe('Hello');
});
});
describe('slugify', () => {
it('converts spaces to hyphens', () => {
expect(slugify('hello world')).toBe('hello-world');
});
it('lowercases and removes special characters', () => {
expect(slugify('Hello, World!')).toBe('hello-world');
});
});
});
Common Matchers
// Equality
expect(value).toBe(5); // Strict equality (===)
expect(obj).toEqual({ a: 1 }); // Deep equality
expect(obj).toStrictEqual({ a: 1 }); // Deep equality + same type
// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeLessThanOrEqual(10);
expect(0.1 + 0.2).toBeCloseTo(0.3); // Floating point comparison
// Strings
expect(str).toMatch(/pattern/);
expect(str).toContain('substring');
// Arrays
expect(arr).toContain(item);
expect(arr).toHaveLength(3);
expect(arr).toEqual(expect.arrayContaining([1, 2]));
// Objects
expect(obj).toHaveProperty('key');
expect(obj).toHaveProperty('nested.key', 'value');
expect(obj).toMatchObject({ partial: 'match' });
// Exceptions
expect(() => badFunction()).toThrow();
expect(() => badFunction()).toThrow('specific message');
expect(() => badFunction()).toThrow(CustomError);
// Promises
await expect(promise).resolves.toBe('value');
await expect(promise).rejects.toThrow('error');
Async Testing
Vitest handles async code naturally:
// Async/await (preferred)
it('fetches user data', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('John');
});
// Returning promises
it('fetches user data', () => {
return fetchUser(1).then(user => {
expect(user.name).toBe('John');
});
});
// Testing rejections
it('throws for invalid user', async () => {
await expect(fetchUser(-1)).rejects.toThrow('User not found');
});
Setup and Teardown
import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
describe('database tests', () => {
beforeAll(async () => {
// Runs once before all tests in this describe block
await db.connect();
});
afterAll(async () => {
// Runs once after all tests
await db.disconnect();
});
beforeEach(async () => {
// Runs before each test
await db.seed();
});
afterEach(async () => {
// Runs after each test
await db.clean();
});
it('creates a user', async () => {
// Test with fresh database state
});
});
Mocking
Function Mocks with vi.fn()
Create mock functions to track calls and control return values:
import { describe, it, expect, vi } from 'vitest';
describe('button click handler', () => {
it('calls onClick when clicked', () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Click me</Button>);
fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalled();
expect(onClick).toHaveBeenCalledTimes(1);
expect(onClick).toHaveBeenCalledWith(expect.any(Object)); // Event object
});
});
Mock functions can return specific values:
const mockFetch = vi.fn();
// Return a value
mockFetch.mockReturnValue('mocked');
// Return different values on successive calls
mockFetch
.mockReturnValueOnce('first')
.mockReturnValueOnce('second')
.mockReturnValue('default');
// Return a resolved promise
mockFetch.mockResolvedValue({ data: 'success' });
// Return a rejected promise
mockFetch.mockRejectedValue(new Error('Network error'));
// Custom implementation
mockFetch.mockImplementation((url) => {
if (url.includes('/users')) {
return Promise.resolve({ users: [] });
}
return Promise.reject(new Error('Not found'));
});
Module Mocking with vi.mock()
Mock entire modules to isolate code under test:
// src/services/api.ts
export async function fetchUsers() {
const response = await fetch('/api/users');
return response.json();
}
// src/services/api.test.ts
import { describe, it, expect, vi } from 'vitest';
import { fetchUsers } from './api';
// Mock at the top of the file
vi.mock('./api', () => ({
fetchUsers: vi.fn(),
}));
describe('api service', () => {
it('returns mocked users', async () => {
// Type assertion needed for mocked function
const mockFetchUsers = fetchUsers as ReturnType<typeof vi.fn>;
mockFetchUsers.mockResolvedValue([{ id: 1, name: 'John' }]);
const users = await fetchUsers();
expect(users).toHaveLength(1);
expect(users[0].name).toBe('John');
});
});
For more control, use the factory function:
// Mock external dependency
vi.mock('axios', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}));
// In test
import axios from 'axios';
it('makes API call', async () => {
vi.mocked(axios.get).mockResolvedValue({ data: { id: 1 } });
const result = await myService.getUser(1);
expect(axios.get).toHaveBeenCalledWith('/api/users/1');
expect(result.id).toBe(1);
});
Spying with vi.spyOn()
Spy on methods while keeping original implementation:
import { describe, it, expect, vi } from 'vitest';
describe('console logging', () => {
it('logs error messages', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
logError('Something went wrong');
expect(consoleSpy).toHaveBeenCalledWith('Error:', 'Something went wrong');
consoleSpy.mockRestore(); // Restore original implementation
});
});
Spy on object methods:
const user = {
getName() {
return 'John';
},
};
const spy = vi.spyOn(user, 'getName');
user.getName();
expect(spy).toHaveBeenCalled();
expect(spy).toHaveReturnedWith('John');
Mocking Timers
Control time-based code with fake timers:
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('delays execution', () => {
const fn = vi.fn();
const debounced = debounce(fn, 1000);
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(500);
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(500);
expect(fn).toHaveBeenCalledTimes(1);
});
it('resets timer on subsequent calls', () => {
const fn = vi.fn();
const debounced = debounce(fn, 1000);
debounced();
vi.advanceTimersByTime(500);
debounced(); // Reset timer
vi.advanceTimersByTime(500);
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(500);
expect(fn).toHaveBeenCalledTimes(1);
});
});
Other timer utilities:
vi.runAllTimers(); // Run all pending timers
vi.runOnlyPendingTimers(); // Run only currently pending (not new ones)
vi.advanceTimersToNextTimer(); // Advance to next scheduled timer
vi.setSystemTime(new Date('2024-01-01')); // Set current date
vi.getRealSystemTime(); // Get actual system time
Resetting Mocks
afterEach(() => {
vi.clearAllMocks(); // Clear call history (mock.calls, mock.results)
vi.resetAllMocks(); // Clear history + reset to initial state
vi.restoreAllMocks(); // Restore original implementations (for spies)
});
Code Coverage
Install the coverage provider:
pnpm add -D @vitest/coverage-v8
Configure coverage in vitest.config.ts:
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
'**/types/*',
],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
});
Run with coverage:
pnpm test:coverage
Coverage output appears in the terminal and in ./coverage/ directory. Open coverage/index.html for an interactive report.
Coverage Thresholds
Enforce minimum coverage in CI:
coverage: {
thresholds: {
// Global thresholds
lines: 80,
functions: 80,
branches: 75,
statements: 80,
// Per-file thresholds (stricter for critical code)
'src/utils/**': {
lines: 95,
functions: 95,
},
},
}
Running Tests
Watch Mode (Default)
pnpm test
Watch mode re-runs tests on file changes. Press keys to interact:
a- Run all testsf- Run only failed testsp- Filter by filename patternt- Filter by test name patternq- Quit
Single Run
pnpm test run
Filtering Tests
# Run tests matching filename
pnpm test user
# Run tests matching pattern
pnpm test --testNamePattern="creates user"
# Run specific file
pnpm test src/utils/string.test.ts
# Run tests in specific directory
pnpm test src/components/
In-Code Filtering
Skip or focus tests during development:
describe.skip('broken feature', () => {
// All tests skipped
});
describe.only('feature I am working on', () => {
// Only this describe block runs
});
it.skip('incomplete test', () => {});
it.only('just this one', () => {});
// Conditional skip
it.skipIf(process.env.CI)('slow test', () => {});
it.runIf(process.env.CI)('CI only test', () => {});
Warning: Don't commit .only calls. Use a lint rule to catch them.
TypeScript Configuration
Vitest handles TypeScript natively, but you should configure a few things:
// tsconfig.json
{
"compilerOptions": {
"types": ["vitest/globals"],
"esModuleInterop": true,
"strict": true
},
"include": ["src/**/*", "src/**/*.test.ts"]
}
Type-Checking in Tests
Vitest doesn't type-check by default (for speed). Run tsc separately or enable type-checking:
// vitest.config.ts
export default defineConfig({
test: {
typecheck: {
enabled: true,
tsconfig: './tsconfig.json',
},
},
});
Or run type-checking as a separate command:
pnpm tsc --noEmit && pnpm test run
Typed Mocks
Use vi.mocked() for proper typing of mocked functions:
import { vi, expect } from 'vitest';
import { fetchUser } from './api';
vi.mock('./api');
it('handles typed mock', async () => {
vi.mocked(fetchUser).mockResolvedValue({ id: 1, name: 'John' });
const user = await fetchUser(1);
expect(user.name).toBe('John'); // TypeScript knows the shape
});
Testing React/Vue/Svelte Components
Install Testing Library for your framework:
# React
pnpm add -D @testing-library/react @testing-library/jest-dom jsdom
# Vue
pnpm add -D @testing-library/vue jsdom
# Svelte
pnpm add -D @testing-library/svelte jsdom
Setup file for React with Testing Library:
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});
Example React component test:
// src/components/Counter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { Counter } from './Counter';
describe('Counter', () => {
it('increments count on button click', async () => {
const user = userEvent.setup();
render(<Counter initialCount={0} />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
it('respects initial count', () => {
render(<Counter initialCount={10} />);
expect(screen.getByText('Count: 10')).toBeInTheDocument();
});
});
Gotchas and Tips
ESM and CommonJS Interop
If you import a CommonJS package that Vitest can't transform:
// vitest.config.ts
export default defineConfig({
test: {
deps: {
inline: ['problematic-package'],
},
},
});
Mocking ES Modules
ES modules are hoisted, so vi.mock() calls are automatically moved to the top:
// This works despite being after the import
import { foo } from './module';
vi.mock('./module'); // Hoisted above the import
But dynamic mocks need vi.doMock():
it('uses dynamic mock', async () => {
vi.doMock('./module', () => ({
foo: vi.fn().mockReturnValue('mocked'),
}));
// Must use dynamic import after doMock
const { foo } = await import('./module');
expect(foo()).toBe('mocked');
});
Parallel Test Execution
Tests in different files run in parallel by default. For tests that share state:
// vitest.config.ts
export default defineConfig({
test: {
// Run all tests sequentially
sequence: {
concurrent: false,
},
// Or limit parallelism
maxConcurrency: 5,
// Or isolate tests in separate threads
isolate: true,
},
});
Debugging Tests
Run in headed mode with Node inspector:
# Debug with Node
node --inspect-brk ./node_modules/vitest/vitest.mjs --run
# Or use Vitest UI
pnpm add -D @vitest/ui
pnpm test --ui
VSCode launch configuration:
{
"type": "node",
"request": "launch",
"name": "Debug Vitest",
"program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
"args": ["--run", "--reporter=verbose"],
"cwd": "${workspaceRoot}",
"console": "integratedTerminal"
}
See Also
- Vitest Documentation
- Testing Overview - Testing philosophy and strategies
- Jest - If migrating from Jest or maintaining a Jest project
- Mocking Strategies - Deep dive into mocking techniques
- Vite article - The build tool Vitest is built on