Vitest
Vitest is a Vite-native unit testing framework that reuses your Vite configuration for testing. It is dramatically faster than Jest for TypeScript and ESM projects, supports the same test APIs as Jest, and adds features like workspace mode, browser testing, and a built-in UI.
Installation
# npm
npm install --save-dev vitest
# pnpm
pnpm add -D vitest
# Bun
bun add -d vitest
# With UI (interactive test runner)
npm install --save-dev @vitest/ui
# With coverage provider
npm install --save-dev @vitest/coverage-v8
# or
npm install --save-dev @vitest/coverage-istanbul
# With browser mode
npm install --save-dev @vitest/browser
Add Scripts to package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest --watch"
}
}
Configuration
Vitest can be configured inside vite.config.ts or a separate vitest.config.ts:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
// Test environment
environment: 'node', // 'node' | 'jsdom' | 'happy-dom' | 'edge-runtime'
// Setup files run before each test file
setupFiles: ['./tests/setup.ts'],
// Global APIs (no need to import test, expect, etc.)
globals: true,
// Include/exclude patterns
include: ['**/*.{test,spec}.{js,ts,jsx,tsx}'],
exclude: ['node_modules', 'dist', 'e2e/**'],
// Coverage
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
reportsDirectory: './coverage',
include: ['src/**'],
exclude: ['src/**/*.d.ts', 'src/**/*.stories.ts'],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
},
// Timeout per test (ms)
testTimeout: 10000,
// Reporter
reporter: ['verbose'],
// Inline snapshots in the test file
snapshotOptions: {
snapshotFormat: {
printBasicPrototype: false,
},
},
// Pool and concurrency
pool: 'forks', // 'threads' | 'forks' | 'vmThreads'
poolOptions: {
forks: {
maxForks: 4,
},
},
// Retry flaky tests
retry: 2,
// Pass-through to Node for coverage
passWithNoTests: false,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
Setup File
// tests/setup.ts
import '@testing-library/jest-dom'; // extends expect with DOM matchers
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
// Clean up after each test
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// Global mocks
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
})),
});
Core Commands
| Command | Description |
|---|---|
vitest | Run tests in watch mode |
vitest run | Run tests once and exit |
vitest watch | Explicit watch mode |
vitest --ui | Open interactive UI |
vitest run --coverage | Run with coverage report |
vitest run --reporter=verbose | Verbose output |
vitest run --reporter=json | JSON output |
vitest run --reporter=junit | JUnit XML output |
vitest run path/to/test.ts | Run specific file |
vitest run -t "test name" | Run matching test names |
vitest related src/foo.ts | Run tests related to a file |
vitest bench | Run benchmark tests |
vitest typecheck | Run TypeScript type checking |
vitest --project=my-lib | Run workspace project |
vitest run --shard=1/3 | Run a shard (CI parallelization) |
Advanced Usage
Test, Describe, and Expect API
import { test, describe, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
describe('User authentication', () => {
beforeAll(async () => {
// Runs once before all tests in this describe
await db.connect();
});
afterAll(async () => {
await db.disconnect();
});
beforeEach(() => {
// Runs before each test
});
afterEach(() => {
// Runs after each test
});
test('logs in with valid credentials', async () => {
const user = await login('alice', 'password123');
expect(user).toBeDefined();
expect(user.name).toBe('Alice');
});
test.skip('skipped test', () => {
// Not run
});
test.todo('implement later');
test.each([
[1, 1, 2],
[2, 3, 5],
[10, 5, 15],
])('adds %i + %i = %i', (a, b, expected) => {
expect(a + b).toBe(expected);
});
});
Mocking with vi
import { vi, test, expect } from 'vitest';
// Mock a module
vi.mock('./db', () => ({
query: vi.fn().mockResolvedValue([{ id: 1, name: 'Alice' }]),
}));
// Mock a module with factory
vi.mock('axios', async (importActual) => {
const actual = await importActual<typeof import('axios')>();
return {
...actual,
default: {
get: vi.fn().mockResolvedValue({ data: { ok: true } }),
},
};
});
test('mocks function', () => {
const fn = vi.fn();
fn.mockReturnValue(42);
expect(fn()).toBe(42);
expect(fn).toHaveBeenCalledTimes(1);
});
test('spies on method', () => {
const obj = { getValue: () => 'original' };
const spy = vi.spyOn(obj, 'getValue').mockReturnValue('mocked');
expect(obj.getValue()).toBe('mocked');
spy.mockRestore(); // restore original
});
test('mocks timers', () => {
vi.useFakeTimers();
const fn = vi.fn();
setTimeout(fn, 1000);
vi.advanceTimersByTime(1000);
expect(fn).toHaveBeenCalledOnce();
vi.useRealTimers();
});
// Stub environment variables
test('reads env var', () => {
vi.stubEnv('API_URL', 'https://test.example.com');
expect(process.env.API_URL).toBe('https://test.example.com');
vi.unstubAllEnvs();
});
Snapshot Testing
import { test, expect } from 'vitest';
test('renders correctly', () => {
const html = render(<Button label="Click me" />);
expect(html).toMatchSnapshot();
});
// Inline snapshot
test('inline snapshot', () => {
const user = { name: 'Alice', age: 30 };
expect(user).toMatchInlineSnapshot(`
{
"age": 30,
"name": "Alice",
}
`);
});
// Update snapshots
// vitest run -u (or vitest run --update-snapshots)
Browser Mode (Component Testing)
// vitest.config.ts
export default defineConfig({
test: {
browser: {
enabled: true,
name: 'chromium',
provider: 'playwright',
headless: true,
},
},
});
// Button.test.tsx — runs in real browser
import { render, screen } from '@testing-library/react';
import { test, expect } from 'vitest';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
test('calls onClick handler', async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledOnce();
});
Workspace Mode (Monorepo)
// vitest.workspace.ts
import { defineWorkspace } from 'vitest/config';
export default defineWorkspace([
'packages/*/vitest.config.ts',
{
test: {
name: 'unit',
include: ['packages/**/*.unit.test.ts'],
environment: 'node',
},
},
{
test: {
name: 'browser',
include: ['packages/**/*.browser.test.tsx'],
browser: {
enabled: true,
name: 'chromium',
},
},
},
]);
CI Sharding for Parallelism
# GitHub Actions — split tests across 3 machines
strategy:
matrix:
shard: [1, 2, 3]
steps:
- run: pnpm test:run --shard=${{ matrix.shard }}/3
Common Workflows
Testing React Components
npm install --save-dev @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
// vitest.config.ts — set jsdom environment
test: { environment: 'jsdom', setupFiles: ['./tests/setup.ts'] }
// tests/setup.ts
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { test, expect } from 'vitest';
import { Counter } from './Counter';
test('increments counter on click', async () => {
render(<Counter />);
const button = screen.getByRole('button', { name: /increment/i });
await userEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
Generating a Coverage Report
# Run with HTML coverage report
vitest run --coverage
# Open HTML report
open coverage/index.html
# Check thresholds (fails CI if below)
# Configure in vitest.config.ts: coverage.thresholds
Tips and Best Practices
Use globals: true for familiar Jest API — Enable globals to use test, expect, describe, vi without importing them in every file. Add "types": ["vitest/globals"] to tsconfig.json for type support.
Prefer vi.mock() at the top level — Module mocks are hoisted to the top of the file by Vitest (just like Jest). Don’t put vi.mock() inside beforeEach — it won’t work as expected.
Use vi.restoreAllMocks() in afterEach — Call this in your setup file to automatically restore spies after each test. Prevents mock state from leaking between tests.
Use test.each for data-driven tests — Instead of duplicating tests with different inputs, use test.each with a table or array. It produces clearer output and is easier to extend.
Pool selection matters — Use pool: 'forks' (default) for most cases. Use pool: 'threads' if you need shared memory between workers. Use pool: 'vmThreads' to isolate global state between test files.
Set testTimeout for async tests — The default timeout is 5 seconds. For tests involving network calls or slow async operations, increase it per-test with test('name', async () => { ... }, { timeout: 30000 }).
Use vitest --ui for debugging — The browser-based UI shows test output, coverage, and lets you re-run individual tests interactively. Much better than scanning terminal output for failures.
Use sharding in CI for large test suites — If your test suite takes more than 3 minutes in CI, split it across machines with --shard=N/TOTAL. Combine with a coverage merge step for accurate overall coverage numbers.