Tutorials
|stacknotice.com
14 min left|
0%
|2,800 words
Tutorials

Vitest Complete Guide: Fast Unit Testing for JavaScript (2026)

Vitest is the fastest unit test runner for Vite projects. Setup, mocking, coverage, component testing, and when to pick it over Jest — practical guide with real code.

C
Carlos Oliva
Software Developer
July 29, 202614 min read
Share:
Vitest Complete Guide: Fast Unit Testing for JavaScript (2026)

Vitest is a unit test framework built by the Vite team. It reuses your existing Vite config — so TypeScript, JSX, path aliases, and environment variables all work in tests without extra setup. It implements nearly the entire Jest API, so migrating existing test suites is usually a matter of swapping the import and running vitest.

The speed difference is noticeable: Vitest starts in under 200ms while Jest spins up in 2-3 seconds. For large test suites, that compounds.

This guide covers Vitest from scratch — not just the basics, but the parts that trip people up: mocking, async, component testing, and configuration for real projects.

Why Vitest Instead of Jest

The core argument is configuration cost. A typical Jest setup for a TypeScript + Vite project needs:

  • babel-jest or ts-jest to handle TypeScript
  • A Babel config or jest.config.ts with transform
  • Separate handling for ESM packages that don't ship CommonJS
  • Path alias replication (your vite.config.ts aliases don't work in Jest)

With Vitest, none of that is needed. Your existing vite.config.ts is already the Vitest config. TypeScript works. JSX works. Path aliases work.

When to stick with Jest:

  • You're not using Vite (plain Node.js, Express, NestJS without Vite)
  • Your team knows Jest deeply and migration cost isn't justified
  • You need specific Jest ecosystem tools without Vitest equivalents

For anything Vite-based — React, Vue, Svelte, or even a Node.js library built with Vite — Vitest is the obvious choice.

Installation

# Vite-based project
npm install -D vitest
 
# Adds test script to package.json:
# "test": "vitest"
# "test:run": "vitest run"
# "coverage": "vitest run --coverage"

For non-Vite projects, Vitest still works but you'll want a config file:

npm install -D vitest vite

Running Tests

npx vitest          # watch mode (default in dev)
npx vitest run      # single run (for CI)
npx vitest run --reporter=verbose
npx vitest --coverage
npx vitest src/utils.test.ts  # specific file
npx vitest -t "handles errors"  # filter by test name

Basic Test Structure

Vitest uses the same API as Jest:

// src/utils/slug.test.ts
import { describe, it, expect } from 'vitest'
import { slugify, truncate, formatDate } from './string-utils'
 
describe('slugify', () => {
  it('converts spaces to hyphens', () => {
    expect(slugify('Hello World')).toBe('hello-world')
  })
 
  it('removes special characters', () => {
    expect(slugify('TypeScript: The Good Parts!')).toBe('typescript-the-good-parts')
  })
 
  it('handles leading and trailing spaces', () => {
    expect(slugify('  spaced  ')).toBe('spaced')
  })
 
  it('collapses multiple hyphens', () => {
    expect(slugify('hello---world')).toBe('hello-world')
  })
})
 
describe('truncate', () => {
  it('returns full string when under limit', () => {
    expect(truncate('short', 10)).toBe('short')
  })
 
  it('truncates and appends ellipsis', () => {
    expect(truncate('hello world', 8)).toBe('hello...')
  })
})

You can also use test instead of it — they're aliases:

test('formatDate returns ISO format', () => {
  const date = new Date('2026-01-15')
  expect(formatDate(date)).toBe('2026-01-15')
})

Configuration

Vitest config lives in vitest.config.ts or inside vite.config.ts. For projects that already have Vite:

// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
 
export default defineConfig({
  plugins: [react(), tsconfigPaths()],
 
  test: {
    // Run tests in a simulated browser environment
    environment: 'jsdom',  // or 'happy-dom' (faster) or 'node'
 
    // Global test APIs — no need to import describe/it/expect
    globals: true,
 
    // Run this before each test file
    setupFiles: ['./src/test/setup.ts'],
 
    // Coverage configuration
    coverage: {
      provider: 'v8',  // or 'istanbul'
      reporter: ['text', 'html', 'lcov'],
      exclude: ['node_modules/', 'src/test/']
    },
 
    // Include pattern
    include: ['**/*.{test,spec}.{ts,tsx}'],
  }
})

For a pure Vitest config (separate file):

// vitest.config.ts
import { defineConfig } from 'vitest/config'
 
export default defineConfig({
  test: {
    environment: 'node',
    globals: true,
  }
})

If you set globals: true, add the types to tsconfig.json:

{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

The expect API

Vitest's expect is compatible with Jest matchers:

// Primitives
expect(value).toBe(42)           // strict equality (===)
expect(value).toEqual({ a: 1 }) // deep equality
expect(value).not.toBe(null)
 
// Truthiness
expect(value).toBeTruthy()
expect(value).toBeFalsy()
expect(value).toBeNull()
expect(value).toBeUndefined()
expect(value).toBeDefined()
 
// Numbers
expect(value).toBeGreaterThan(5)
expect(value).toBeLessThanOrEqual(10)
expect(0.1 + 0.2).toBeCloseTo(0.3, 5) // floating point comparison
 
// Strings
expect(str).toContain('substring')
expect(str).toMatch(/regex/)
expect(str).toHaveLength(10)
 
// Arrays
expect(arr).toContain('item')
expect(arr).toHaveLength(3)
expect(arr).toEqual(expect.arrayContaining(['a', 'b']))
 
// Objects
expect(obj).toHaveProperty('key')
expect(obj).toHaveProperty('nested.key', 'value')
expect(obj).toMatchObject({ name: 'test' }) // partial match
 
// Errors
expect(() => parseInput('')).toThrow()
expect(() => parseInput('')).toThrow('Input cannot be empty')
expect(() => parseInput('')).toThrow(ValidationError)
 
// Async errors
await expect(fetchUser(-1)).rejects.toThrow('User not found')

Mocking with vi

The vi object replaces Jest's global jest:

import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
 
// Mock a function
const mockFn = vi.fn()
mockFn('hello')
 
expect(mockFn).toHaveBeenCalledOnce()
expect(mockFn).toHaveBeenCalledWith('hello')
 
// Mock return values
mockFn.mockReturnValue('result')
mockFn.mockReturnValueOnce('first').mockReturnValueOnce('second')
 
// Mock async functions
const mockAsync = vi.fn().mockResolvedValue({ id: 1, name: 'Test' })
const result = await mockAsync()
 
// Spy on an existing function
import { formatDate } from './utils'
const spy = vi.spyOn({ formatDate }, 'formatDate')
 
// Reset between tests
beforeEach(() => {
  vi.clearAllMocks()  // clears call history
})
 
afterEach(() => {
  vi.restoreAllMocks()  // restores original implementations
})

Mocking Modules

// Mock an entire module
vi.mock('./database', () => ({
  db: {
    user: {
      findUnique: vi.fn(),
      create: vi.fn(),
      update: vi.fn(),
    }
  }
}))
 
// Use the mock in tests
import { db } from './database'
 
describe('UserService', () => {
  it('returns user by id', async () => {
    vi.mocked(db.user.findUnique).mockResolvedValue({
      id: '1',
      email: 'test@example.com',
      name: 'Test User'
    })
 
    const user = await getUserById('1')
 
    expect(db.user.findUnique).toHaveBeenCalledWith({
      where: { id: '1' }
    })
    expect(user?.email).toBe('test@example.com')
  })
})

Partial Mocks

Sometimes you only want to mock part of a module:

// Keep the real implementation but mock specific exports
vi.mock('./utils', async (importOriginal) => {
  const actual = await importOriginal<typeof import('./utils')>()
  return {
    ...actual,
    sendEmail: vi.fn(),  // mock only this
    // formatDate stays real
  }
})

Mocking Dates and Timers

describe('scheduled tasks', () => {
  beforeEach(() => {
    vi.useFakeTimers()
    vi.setSystemTime(new Date('2026-01-15T10:00:00Z'))
  })
 
  afterEach(() => {
    vi.useRealTimers()
  })
 
  it('expires tokens after 24 hours', async () => {
    const token = createToken()
    expect(isTokenValid(token)).toBe(true)
 
    vi.advanceTimersByTime(24 * 60 * 60 * 1000 + 1)
 
    expect(isTokenValid(token)).toBe(false)
  })
})

Testing Async Code

describe('API calls', () => {
  // Async/await — cleanest approach
  it('fetches user data', async () => {
    const user = await fetchUser('123')
    expect(user.id).toBe('123')
  })
 
  // Test promises directly
  it('rejects for invalid id', () => {
    return expect(fetchUser('')).rejects.toThrow('Invalid user ID')
  })
 
  // Polling with waitFor (for state that changes over time)
  it('eventually processes queue', async () => {
    startBackgroundProcessor()
 
    await vi.waitFor(
      () => expect(getProcessedCount()).toBeGreaterThan(0),
      { timeout: 2000, interval: 100 }
    )
  })
})

React Component Testing

Install testing utilities:

npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom

Setup file:

// src/test/setup.ts
import '@testing-library/jest-dom'

Component tests:

// src/components/SearchBar.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import SearchBar from './SearchBar'
 
describe('SearchBar', () => {
  it('renders the search input', () => {
    render(<SearchBar onSearch={vi.fn()} />)
 
    expect(screen.getByRole('textbox', { name: /search/i })).toBeInTheDocument()
    expect(screen.getByRole('button', { name: /search/i })).toBeInTheDocument()
  })
 
  it('calls onSearch with input value on submit', async () => {
    const user = userEvent.setup()
    const onSearch = vi.fn()
    render(<SearchBar onSearch={onSearch} />)
 
    const input = screen.getByRole('textbox')
    await user.type(input, 'vitest guide')
    await user.click(screen.getByRole('button', { name: /search/i }))
 
    expect(onSearch).toHaveBeenCalledWith('vitest guide')
  })
 
  it('debounces rapid typing', async () => {
    const user = userEvent.setup()
    const onSearch = vi.fn()
    vi.useFakeTimers()
 
    render(<SearchBar onSearch={onSearch} debounce={300} />)
 
    await user.type(screen.getByRole('textbox'), 'abc')
    expect(onSearch).not.toHaveBeenCalled()
 
    vi.advanceTimersByTime(300)
    expect(onSearch).toHaveBeenCalledOnce()
    expect(onSearch).toHaveBeenCalledWith('abc')
 
    vi.useRealTimers()
  })
 
  it('shows no results message when search returns empty', async () => {
    const user = userEvent.setup()
    const onSearch = vi.fn().mockResolvedValue([])
    render(<SearchBar onSearch={onSearch} />)
 
    await user.type(screen.getByRole('textbox'), 'xyzabc')
    await user.click(screen.getByRole('button', { name: /search/i }))
 
    await waitFor(() => {
      expect(screen.getByText(/no results/i)).toBeInTheDocument()
    })
  })
})

Testing Hooks

import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'
 
describe('useCounter', () => {
  it('starts at the initial value', () => {
    const { result } = renderHook(() => useCounter(5))
    expect(result.current.count).toBe(5)
  })
 
  it('increments', () => {
    const { result } = renderHook(() => useCounter(0))
 
    act(() => {
      result.current.increment()
    })
 
    expect(result.current.count).toBe(1)
  })
})

Coverage

npm install -D @vitest/coverage-v8
npx vitest run --coverage

Coverage config in vitest.config.ts:

test: {
  coverage: {
    provider: 'v8',
    reporter: ['text', 'html', 'lcov'],
 
    // Minimum thresholds — fails CI if not met
    thresholds: {
      lines: 80,
      functions: 80,
      branches: 70,
      statements: 80
    },
 
    // What to include/exclude
    include: ['src/**/*.{ts,tsx}'],
    exclude: [
      'src/**/*.test.{ts,tsx}',
      'src/**/*.stories.{ts,tsx}',
      'src/test/**',
      'src/types/**',
    ]
  }
}

The HTML report is generated in ./coverage/index.html and shows line-by-line coverage. Open it after running tests to identify untested branches.

Snapshot Testing

it('renders post card correctly', () => {
  const { container } = render(
    <PostCard
      title="Test Post"
      excerpt="Short description"
      date="2026-01-15"
    />
  )
  expect(container.firstChild).toMatchSnapshot()
})

Update snapshots when the component changes intentionally:

npx vitest run --update-snapshots

Prefer snapshot testing for stable output (serialized data, HTML structure) rather than complex components that change frequently.

Vitest UI

npm install -D @vitest/ui
npx vitest --ui

Opens a browser-based dashboard at http://localhost:51204 that shows:

  • Live test results as they run
  • Filterable test tree
  • Console output per test
  • Module graph for each test file

Useful during development when you're working on a specific feature and want to see results without watching the terminal.

In-Source Testing

Vitest supports writing tests directly in your source files (disabled by default):

// src/utils/math.ts
export function clamp(value: number, min: number, max: number): number {
  return Math.min(Math.max(value, min), max)
}
 
// Tests live alongside the code — stripped from production builds
if (import.meta.vitest) {
  const { describe, it, expect } = import.meta.vitest
 
  describe('clamp', () => {
    it('clamps below min', () => expect(clamp(0, 1, 10)).toBe(1))
    it('clamps above max', () => expect(clamp(15, 1, 10)).toBe(10))
    it('returns value within range', () => expect(clamp(5, 1, 10)).toBe(5))
  })
}

Enable with:

test: {
  includeSource: ['src/**/*.ts']
}

Vitest vs Jest

VitestJest
SpeedFast (esbuild transform)Slower (Babel/ts-jest)
TypeScriptNative with ViteNeeds config
ESMNativeNeeds transform workarounds
ConfigExtends vite.config.tsSeparate jest.config
APIJest-compatibleOriginal
Mockingvi.*jest.*
GlobalsOptionalDefault
Browser testing@vitest/browserjsdom only
EcosystemGrowingMature
IDE supportGoodExcellent
Non-Vite projectsWorks but adds Vite depBetter fit

Migration from Jest is mostly mechanical:

  • Replace jest.* with vi.*
  • Remove Jest config and Babel transform setup
  • Update imports: import { vi } from 'vitest'
  • Ensure globals: true in Vitest config if you had Jest globals

Most test code runs without changes.

CI Setup

# .github/workflows/test.yml
- name: Run tests
  run: npx vitest run
 
- name: Run tests with coverage
  run: npx vitest run --coverage
 
- name: Upload coverage
  uses: codecov/codecov-action@v3
  with:
    file: ./coverage/lcov.info

Workspace Testing (Monorepos)

// vitest.workspace.ts
export default [
  'packages/*/vitest.config.ts',
  {
    test: {
      name: 'shared',
      root: './packages/shared',
    }
  }
]

Run with:

npx vitest --workspace

For testing React specifically in a Next.js project, the setup adds a few steps — see Testing in Next.js with Vitest and Playwright for the App Router-specific configuration including server component testing and route handlers.

If you're on Bun, bun test implements a similar Jest-compatible API natively — see Bun Complete Guide for the comparison. For vanilla Node.js projects where you want a faster Jest, Vitest with environment: 'node' works well as a drop-in replacement regardless of whether you're using Vite.

#vitest#testing#javascript#typescript#vite
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.