Vite-native testing framework // describe > test > expect
The commands and patterns you will use every day with Vitest.
npx vitest
npx vitest run
Watch mode (default) or single run
npx vitest src/utils.test.ts
npx vitest -t "should add"
By file path or test name pattern
npx vitest run --coverage
Requires @vitest/coverage-v8 or -istanbul
npx vitest --ui
Browser-based test explorer at localhost
npx vitest typecheck
Run type-level tests with expectTypeOf
npx vitest bench
Run .bench.ts files for perf testing
import { describe, it, expect } from 'vitest'
import { sum } from './sum'
describe('sum', () => {
it('adds two numbers', () => {
expect(sum(1, 2)).toBe(3)
})
it('handles negatives', () => {
expect(sum(-1, 1)).toBe(0)
})
})
Get Vitest running in your project. It shares your Vite config automatically.
# Install Vitest
npm install -D vitest
# With coverage support
npm install -D vitest @vitest/coverage-v8
# With UI dashboard
npm install -D vitest @vitest/ui
# With browser mode
npm install -D vitest @vitest/browser playwright
Vitest reads from vite.config.ts by default. Use a separate config for test-only settings:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
// Global test settings
globals: true, // Use describe/it/expect without imports
environment: 'jsdom', // DOM environment for UI tests
setupFiles: ['./test/setup.ts'],
include: ['**/*.{test,spec}.{ts,tsx,js,jsx}'],
exclude: ['node_modules', 'dist', 'e2e'],
// Coverage config
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.d.ts', 'src/**/*.test.ts'],
thresholds: {
lines: 80,
branches: 80,
},
},
},
})
| Environment | Package | Use Case |
|---|---|---|
node |
Built-in | Default. Server-side logic, utilities |
jsdom |
jsdom |
DOM testing (React, Vue, etc.) |
happy-dom |
happy-dom |
Faster DOM alternative to jsdom |
edge-runtime |
@edge-runtime/vm |
Vercel Edge, Cloudflare Workers |
// @vitest-environment jsdom at top of file.{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"test:watch": "vitest --watch"
}
}
Add Vitest types to your tsconfig.json:
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}
globals: true is set in config. Otherwise, import from 'vitest' directly.The core building blocks: describe, it/test, lifecycle hooks, and test modifiers.
import { describe, it, test, expect } from 'vitest'
// describe groups related tests
describe('Calculator', () => {
// it() and test() are identical
it('adds numbers', () => {
expect(1 + 1).toBe(2)
})
test('subtracts numbers', () => {
expect(5 - 3).toBe(2)
})
// Nested describe blocks
describe('edge cases', () => {
it('handles Infinity', () => {
expect(1 / 0).toBe(Infinity)
})
})
})
| Modifier | Behavior |
|---|---|
it.skip() |
Skip this test |
it.only() |
Run only this test (in this file) |
it.todo() |
Placeholder for unwritten test |
it.fails() |
Expect test to fail (inverted assertion) |
it.concurrent() |
Run this test in parallel with others |
it.each(cases)() |
Parameterized test for multiple inputs |
it.skipIf(cond)() |
Conditionally skip |
it.runIf(cond)() |
Conditionally run |
describe.shuffle() |
Randomize test order in suite |
it.each([
[1, 1, 2],
[2, 3, 5],
[0, 0, 0],
])('add(%i, %i) = %i', (a, b, expected) => {
expect(add(a, b)).toBe(expected)
})
// Object syntax
it.each([
{ input: 'hello', expected: 5 },
{ input: '', expected: 0 },
])('length of "$input" is $expected', ({ input, expected }) => {
expect(input.length).toBe(expected)
})
import { beforeAll, afterAll, beforeEach, afterEach } from 'vitest'
describe('Database', () => {
beforeAll(async () => {
// Runs once before all tests in this describe
await db.connect()
})
afterAll(async () => {
// Runs once after all tests
await db.disconnect()
})
beforeEach(async () => {
// Runs before each test
await db.reset()
})
afterEach(() => {
// Runs after each test (cleanup)
vi.restoreAllMocks()
})
})
describe blocks run for nested suites too. Execution order: outerBefore → innerBefore → test → innerAfter → outerAfter.// Per-test timeout (default: 5000ms)
it('slow operation', async () => {
await heavyComputation()
}, 30000) // 30s timeout
// Global timeout in config
// test: { testTimeout: 10000 }
Vitest uses Chai-compatible assertions under the hood, plus its own extended matchers.
| Matcher | Description |
|---|---|
.toBe(value) |
Strict equality (===). Use for primitives. |
.toEqual(value) |
Deep equality. Use for objects/arrays. |
.toStrictEqual(value) |
Deep equality + checks undefined properties and array sparseness |
.toMatchObject(obj) |
Partial deep match (subset OK) |
| Matcher | Description |
|---|---|
.toBeTruthy() |
Any truthy value |
.toBeFalsy() |
Any falsy value |
.toBeNull() |
Exactly null |
.toBeUndefined() |
Exactly undefined |
.toBeDefined() |
Not undefined |
.toBeNaN() |
Is NaN |
.toBeInstanceOf(Class) |
instanceof check |
.toBeTypeOf(type) |
typeof check ('string', 'number', etc.) |
expect(3.14).toBeCloseTo(3.14159, 1) // 1 decimal precision
expect(10).toBeGreaterThan(5)
expect(10).toBeGreaterThanOrEqual(10)
expect(3).toBeLessThan(5)
expect(3).toBeLessThanOrEqual(3)
expect('hello world').toContain('world')
expect('hello world').toMatch(/hello/)
expect('hello').toHaveLength(5)
expect([1, 2, 3]).toContain(2)
expect([1, 2, 3]).toHaveLength(3)
expect([
{ id: 1, name: 'a' },
{ id: 2, name: 'b' },
]).toContainEqual({ id: 1, name: 'a' })
// Wrap in a function
expect(() => divide(1, 0)).toThrow()
expect(() => divide(1, 0)).toThrow('division by zero')
expect(() => divide(1, 0)).toThrow(/zero/)
expect(() => divide(1, 0)).toThrowError(DivisionError)
// Async
await expect(fetchData()).rejects.toThrow('Not found')
// Negation
expect(1).not.toBe(2)
// Asymmetric matchers (partial matching)
expect(user).toEqual({
name: expect.any(String),
id: expect.any(Number),
createdAt: expect.any(Date),
})
expect('hello world').toEqual(
expect.stringContaining('world')
)
expect([1, 2, 3]).toEqual(
expect.arrayContaining([1, 3])
)
expect({ a: 1, b: 2 }).toEqual(
expect.objectContaining({ a: 1 })
)
Continue checking after first failure:
import { expect } from 'vitest'
it('checks all fields', () => {
expect.soft(response.status).toBe(200)
expect.soft(response.body.name).toBe('Alice')
expect.soft(response.body.age).toBe(30)
// All failures reported together
})
Replace modules, functions, and timers with test doubles using the built-in vi utility.
import { vi, expect, it } from 'vitest'
// Create a mock function
const mockFn = vi.fn()
// With implementation
const mockAdd = vi.fn((a, b) => a + b)
// Chained return values
mockFn
.mockReturnValueOnce(1)
.mockReturnValueOnce(2)
.mockReturnValue(99) // default after
// Async mock
const mockFetch = vi.fn()
.mockResolvedValueOnce({ data: 'first' })
.mockRejectedValueOnce(new Error('fail'))
expect(mockFn).toHaveBeenCalled()
expect(mockFn).toHaveBeenCalledTimes(3)
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2')
expect(mockFn).toHaveBeenLastCalledWith('last')
expect(mockFn).toHaveBeenNthCalledWith(1, 'first')
expect(mockFn).toHaveReturnedWith(42)
// Access call history directly
mockFn.mock.calls // [[arg1, arg2], [arg3]]
mockFn.mock.results // [{ type: 'return', value: 42 }]
mockFn.mock.instances // [this context per call]
const cart = {
items: [],
add(item) { this.items.push(item) },
total() { return this.items.reduce((s, i) => s + i.price, 0) }
}
// Spy preserves original implementation
const spy = vi.spyOn(cart, 'add')
cart.add({ name: 'book', price: 10 })
expect(spy).toHaveBeenCalledOnce()
expect(cart.items).toHaveLength(1) // original still works
// Override implementation
vi.spyOn(cart, 'total').mockReturnValue(999)
import { vi } from 'vitest'
import { readFile } from './fs-utils'
// Auto-mock: all exports become vi.fn()
vi.mock('./fs-utils')
// Manual factory
vi.mock('./fs-utils', () => ({
readFile: vi.fn(() => 'mock content'),
writeFile: vi.fn(),
}))
// Mock node built-ins
vi.mock('node:fs/promises', () => ({
readFile: vi.fn().mockResolvedValue('data'),
}))
// Partial mock: keep originals, override some
vi.mock('./utils', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
fetchData: vi.fn(),
}
})
vi.mock() calls are hoisted to the top of the file automatically. They run before any imports.// Variables used in vi.mock factories must be hoisted
const { mockFetch } = vi.hoisted(() => ({
mockFetch: vi.fn(),
}))
vi.mock('./api', () => ({
fetchData: mockFetch,
}))
vi.useFakeTimers()
it('debounces calls', () => {
const callback = vi.fn()
debounce(callback, 300)()
expect(callback).not.toHaveBeenCalled()
vi.advanceTimersByTime(300)
expect(callback).toHaveBeenCalledOnce()
})
// Other timer controls
vi.runAllTimers() // Fast-forward all pending timers
vi.runOnlyPendingTimers() // Run only currently queued
vi.advanceTimersToNextTimer() // Jump to next timer
vi.setSystemTime(new Date('2024-01-01')) // Freeze date
// Restore real timers
vi.useRealTimers()
vi.restoreAllMocks() // Restore original implementations
vi.resetAllMocks() // Reset call history + implementations
vi.clearAllMocks() // Clear call history only
// Auto-cleanup in config:
// test: { restoreMocks: true }
restoreMocks: true in your config to auto-restore after each test. Saves writing afterEach cleanup.Capture and compare output against saved snapshots. Great for serialized objects, HTML, and large structures.
it('renders user card', () => {
const html = renderUserCard({ name: 'Alice', age: 30 })
// Creates/compares __snapshots__/file.test.ts.snap
expect(html).toMatchSnapshot()
})
// Update snapshots when output intentionally changes:
// npx vitest run --update
// or press 'u' in watch mode
it('formats date', () => {
const result = formatDate(new Date('2024-03-15'))
// Vitest writes the snapshot inline on first run
expect(result).toMatchInlineSnapshot(`"March 15, 2024"`)
})
// Compare against a dedicated snapshot file
it('generates config', async () => {
const config = generateConfig()
await expect(config).toMatchFileSnapshot('./fixtures/config.snap')
})
// Custom serializer for clean snapshots
expect.addSnapshotSerializer({
serialize(val, config, indent, depth, refs, printer) {
return `User(${val.name})`
},
test(val) {
return val && val.constructor?.name === 'User'
},
})
Handle promises, async/await, callbacks, and concurrent test execution.
it('fetches user', async () => {
const user = await fetchUser(1)
expect(user.name).toBe('Alice')
})
// Resolves / Rejects helpers
it('resolves with data', async () => {
await expect(fetchUser(1)).resolves.toMatchObject({ name: 'Alice' })
})
it('rejects with error', async () => {
await expect(fetchUser(-1)).rejects.toThrow('Invalid ID')
})
// Run tests in parallel within a suite
describe.concurrent('API endpoints', () => {
it('GET /users', async () => {
const res = await fetch('/users')
expect(res.status).toBe(200)
})
it('GET /posts', async () => {
const res = await fetch('/posts')
expect(res.status).toBe(200)
})
})
// Or per-test
it.concurrent('fast async test', async () => { /* ... */ })
// Poll until assertion passes
await vi.waitFor(() => {
expect(element.textContent).toBe('Loaded')
}, { timeout: 5000, interval: 100 })
// Wait until condition is truthy
await vi.waitUntil(() => {
return document.querySelector('.loaded')
}, { timeout: 3000 })
Reusable test context with automatic setup/teardown. Inspired by Playwright's fixture model.
import { test as base } from 'vitest'
// Extend test with custom fixtures
const test = base.extend<{
db: Database
user: User
}>({
// Each fixture gets a 'use' callback
async db({}, use) {
const db = await createTestDb()
await use(db) // provide to test
await db.cleanup() // teardown after
},
// Fixtures can depend on other fixtures
async user({ db }, use) {
const user = await db.createUser({ name: 'Alice' })
await use(user)
await db.deleteUser(user.id)
},
})
// Fixtures are injected by name via destructuring
test('creates a post', async ({ db, user }) => {
const post = await db.createPost({
authorId: user.id,
title: 'Hello',
})
expect(post.authorId).toBe(user.id)
})
// Only requested fixtures are initialized
test('db health check', async ({ db }) => {
expect(await db.ping()).toBe(true)
// 'user' fixture is NOT created for this test
})
const test = base.extend<{
logger: Logger
}>({
// { auto: true } means always active, even if not destructured
logger: [async ({}, use) => {
const logger = createLogger()
await use(logger)
logger.flush()
}, { auto: true }],
})
Measure code coverage with V8 or Istanbul providers.
# V8 (faster, native, recommended)
npm install -D @vitest/coverage-v8
# Istanbul (battle-tested, wider compatibility)
npm install -D @vitest/coverage-istanbul
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8', // or 'istanbul'
enabled: true, // always collect
reporter: [
'text', // terminal table
'html', // ./coverage/index.html
'lcov', // for CI tools
'json-summary', // machine-readable
],
include: ['src/**/*.ts'],
exclude: [
'src/**/*.test.ts',
'src/**/*.d.ts',
'src/index.ts', // barrel files
],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
},
},
})
| Feature | V8 | Istanbul |
|---|---|---|
| Speed | fast | moderate |
| Accuracy | Native engine coverage | AST instrumentation |
| Source maps | Automatic | Automatic |
| Decorators/plugins | May miss some transforms | More reliable with transforms |
/* v8 ignore next */
if (process.env.DEBUG) {
console.log('debug mode')
}
/* v8 ignore start */
function debugOnly() {
// This block is excluded from coverage
}
/* v8 ignore stop */
// Istanbul-style (also supported)
/* istanbul ignore next */
Test monorepos and multi-project setups with a single Vitest process.
import { defineWorkspace } from 'vitest/config'
export default defineWorkspace([
// Glob patterns for project configs
'packages/*/vitest.config.ts',
// Inline project definitions
{
test: {
name: 'unit',
include: ['src/**/*.test.ts'],
environment: 'node',
},
},
{
test: {
name: 'ui',
include: ['src/**/*.ui.test.tsx'],
environment: 'jsdom',
},
},
{
test: {
name: 'e2e',
include: ['e2e/**/*.test.ts'],
environment: 'node',
testTimeout: 30000,
},
},
])
# Run all workspace projects
npx vitest
# Run specific project
npx vitest --project unit
npx vitest --project ui
# Run multiple projects
npx vitest --project unit --project e2e
// Root vitest.workspace.ts
export default defineWorkspace([
'packages/*',
])
// packages/api/vitest.config.ts
export default defineConfig({
test: {
name: '@myapp/api',
environment: 'node',
},
})
// packages/web/vitest.config.ts
export default defineConfig({
test: {
name: '@myapp/web',
environment: 'jsdom',
},
})
Run tests in real browsers instead of simulated DOM environments. Uses Playwright or WebDriverIO under the hood.
npm install -D @vitest/browser playwright
export default defineConfig({
test: {
browser: {
enabled: true,
provider: 'playwright', // or 'webdriverio'
name: 'chromium', // browser to use
headless: true, // CI-friendly
},
},
})
import { page } from '@vitest/browser/context'
it('renders button', async () => {
// Interact with actual DOM
document.body.innerHTML = '<button id="btn">Click me</button>'
const btn = document.getElementById('btn')
btn.click()
// Real browser APIs work
expect(window.innerWidth).toBeGreaterThan(0)
expect(document.visibilityState).toBe('visible')
})
Vitest is designed as a drop-in Jest replacement. Here is what changes and what stays the same.
| Feature | Jest | Vitest |
|---|---|---|
| Test syntax | describe/it/expect |
describe/it/expect (identical) |
| Matchers | Built-in set | Same + extras (.toSatisfy, expect.soft) |
| Mocking | jest.fn(), jest.mock() |
vi.fn(), vi.mock() |
| Timers | jest.useFakeTimers() |
vi.useFakeTimers() |
| Snapshots | toMatchSnapshot() |
toMatchSnapshot() (identical) |
| Globals | Auto-global | Import or set globals: true |
| Config | jest.config.js |
vitest.config.ts (extends Vite) |
Vitest uses Vite's native ESM transform. No babel. Watch mode re-runs only affected tests via HMR.
Native ESM support. No more transformIgnorePatterns headaches with node_modules.
Shares vite.config.ts - same aliases, plugins, and transforms in dev and test.
First-class TS support out of the box. No ts-jest or babel-jest needed.
Built-in browser dashboard with --ui flag. Visual test explorer with filtering.
Write tests inside source files. Tree-shaken in production builds.
// Step 1: Replace jest globals with vi
// Find & replace in your codebase:
jest.fn() → vi.fn()
jest.mock() → vi.mock()
jest.spyOn() → vi.spyOn()
jest.useFakeTimers → vi.useFakeTimers
jest.clearAllMocks → vi.clearAllMocks
// Step 2: Update config
// jest.config.js → vitest.config.ts
// Step 3: Update imports (if not using globals)
import { describe, it, expect, vi } from 'vitest'
jest → vi rename. Vitest maintains API compatibility by design.A Vitest-exclusive feature: co-locate tests with source code.
// src/math.ts
export function add(a: number, b: number): number {
return a + b
}
// Tests are tree-shaken in production!
if (import.meta.vitest) {
const { it, expect } = import.meta.vitest
it('adds numbers', () => {
expect(add(1, 2)).toBe(3)
})
}
// Enable in vitest.config.ts
export default defineConfig({
define: {
'import.meta.vitest': 'undefined', // tree-shake in prod
},
test: {
includeSource: ['src/**/*.ts'],
},
})