Tech Guides
blazing fast unit testing

V1TEST

Vite-native testing framework // describe > test > expect

01

Quick Reference

The commands and patterns you will use every day with Vitest.

Run tests

npx vitest npx vitest run

Watch mode (default) or single run

Run specific file

npx vitest src/utils.test.ts npx vitest -t "should add"

By file path or test name pattern

Coverage

npx vitest run --coverage

Requires @vitest/coverage-v8 or -istanbul

UI Dashboard

npx vitest --ui

Browser-based test explorer at localhost

Type checking

npx vitest typecheck

Run type-level tests with expectTypeOf

Benchmarks

npx vitest bench

Run .bench.ts files for perf testing

Minimal Test File

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)
  })
})
02

Setup & Configuration

Get Vitest running in your project. It shares your Vite config automatically.

Installation

# 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.config.ts

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 Options

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
Set per-file environment with a magic comment: // @vitest-environment jsdom at top of file.

package.json Scripts

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui",
    "test:watch": "vitest --watch"
  }
}

TypeScript Setup

Add Vitest types to your tsconfig.json:

{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}
Only needed when globals: true is set in config. Otherwise, import from 'vitest' directly.
03

Test API

The core building blocks: describe, it/test, lifecycle hooks, and test modifiers.

describe & test

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)
    })
  })
})

Test Modifiers

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

Parameterized Tests with each()

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)
})

Lifecycle Hooks

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()
  })
})
Hooks in outer describe blocks run for nested suites too. Execution order: outerBefore → innerBefore → test → innerAfter → outerAfter.

Timeouts

// Per-test timeout (default: 5000ms)
it('slow operation', async () => {
  await heavyComputation()
}, 30000)  // 30s timeout

// Global timeout in config
// test: { testTimeout: 10000 }
04

Expect & Matchers

Vitest uses Chai-compatible assertions under the hood, plus its own extended matchers.

Equality

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)

Truthiness & Type Checks

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.)

Numbers

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)

Strings

expect('hello world').toContain('world')
expect('hello world').toMatch(/hello/)
expect('hello').toHaveLength(5)

Arrays & Iterables

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' })

Exceptions

// 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 & Asymmetric Matchers

// 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 })
)

Soft Assertions

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
})
05

Mocking

Replace modules, functions, and timers with test doubles using the built-in vi utility.

vi.fn() — Mock Functions

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'))

Mock Function Assertions

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]

vi.spyOn() — Spy on Methods

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)

vi.mock() — Module Mocking

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.

vi.hoisted() — Hoist Variables

// Variables used in vi.mock factories must be hoisted
const { mockFetch } = vi.hoisted(() => ({
  mockFetch: vi.fn(),
}))

vi.mock('./api', () => ({
  fetchData: mockFetch,
}))

Timers

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()

Cleanup

vi.restoreAllMocks()   // Restore original implementations
vi.resetAllMocks()     // Reset call history + implementations
vi.clearAllMocks()     // Clear call history only

// Auto-cleanup in config:
// test: { restoreMocks: true }
Set restoreMocks: true in your config to auto-restore after each test. Saves writing afterEach cleanup.
06

Snapshots

Capture and compare output against saved snapshots. Great for serialized objects, HTML, and large structures.

File Snapshots

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

Inline Snapshots

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"`)
})

File Snapshots (toMatchFileSnapshot)

// Compare against a dedicated snapshot file
it('generates config', async () => {
  const config = generateConfig()
  await expect(config).toMatchFileSnapshot('./fixtures/config.snap')
})

Snapshot Serializers

// 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'
  },
})
07

Async Testing

Handle promises, async/await, callbacks, and concurrent test execution.

Async / Await

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')
})

Concurrent Tests

// 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 () => { /* ... */ })

vi.waitFor & vi.waitUntil

// 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 })
08

Test Fixtures

Reusable test context with automatic setup/teardown. Inspired by Playwright's fixture model.

Defining Fixtures

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)
  },
})

Using Fixtures

// 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
})

Automatic Fixtures

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 }],
})
09

Coverage

Measure code coverage with V8 or Istanbul providers.

Setup

# V8 (faster, native, recommended)
npm install -D @vitest/coverage-v8

# Istanbul (battle-tested, wider compatibility)
npm install -D @vitest/coverage-istanbul

Configuration

// 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,
      },
    },
  },
})

Coverage Providers Compared

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

Ignoring Coverage

/* 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 */
10

Workspace Mode

Test monorepos and multi-project setups with a single Vitest process.

vitest.workspace.ts

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,
    },
  },
])

Running Workspace Projects

# 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

Monorepo Example

// 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',
  },
})
Each workspace project gets its own Vite server, enabling different environments and transforms per project.
11

Browser Mode

Run tests in real browsers instead of simulated DOM environments. Uses Playwright or WebDriverIO under the hood.

Setup

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
    },
  },
})

Browser Testing API

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')
})

When to Use Browser Mode

Use Browser Mode

  • Canvas / WebGL testing
  • Real CSS computed styles
  • Web Workers / Service Workers
  • Intersection / Resize observers
  • Browser-specific APIs

Use jsdom / happy-dom

  • Unit tests for components
  • Simple DOM manipulation
  • Speed is priority
  • CI with no browser install
  • Most React/Vue testing
12

Vitest vs Jest

Vitest is designed as a drop-in Jest replacement. Here is what changes and what stays the same.

API Compatibility

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)

Key Differences

Speed

Vitest uses Vite's native ESM transform. No babel. Watch mode re-runs only affected tests via HMR.

📦

ESM First

Native ESM support. No more transformIgnorePatterns headaches with node_modules.

🔧

Config Sharing

Shares vite.config.ts - same aliases, plugins, and transforms in dev and test.

💻

TypeScript

First-class TS support out of the box. No ts-jest or babel-jest needed.

🎨

UI Mode

Built-in browser dashboard with --ui flag. Visual test explorer with filtering.

🔌

In-source Testing

Write tests inside source files. Tree-shaken in production builds.

Migration from Jest

// 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'
Most Jest test files work with only the jestvi rename. Vitest maintains API compatibility by design.

In-Source Testing

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'],
  },
})