← Back to DevBytes

Vitest: Complete Testing Guide for Developers

Vitest: Complete Testing Guide for Developers

Testing is a cornerstone of modern software development, and choosing the right testing framework can dramatically improve your productivity and code quality. Vitest is a blazing-fast, modern testing framework built by the Vite team, designed to integrate seamlessly with Vite-powered projects while remaining flexible enough for any JavaScript or TypeScript codebase. In this guide, we'll explore what Vitest is, why it matters, how to set it up, and how to write effective tests following best practices.

What Is Vitest?

Vitest is a unit testing framework created by Anthony Fu and the Vite ecosystem team. It is built on top of Vite's transform pipeline, which means it leverages the same blazing-fast bundler and HMR (Hot Module Replacement) infrastructure that powers modern frontend development. Vitest is designed to be a drop-in replacement for Jest in many cases, offering a familiar API while delivering superior performance and native ESM support.

Key characteristics of Vitest include:

Why Vitest Matters

Before Vitest, JavaScript developers typically relied on Jest for testing. While Jest is mature and capable, it was built before ESM became standard and can be slow in large projects, especially those using Vite. Vitest solves several pain points that developers face with traditional testing frameworks.

Performance: Vitest's use of Vite's transform pipeline means tests start almost instantly. In watch mode, only affected tests re-run, which can reduce feedback loops from seconds to milliseconds. For large monorepos, this difference is transformative.

Configuration simplicity: If you already use Vite, Vitest reads your vite.config.ts file. Aliases, environment variables, and plugins are shared automatically. There's no need to duplicate configuration between your build tool and your test runner.

Modern JavaScript: Vitest fully supports ESM, top-level await, and modern browser APIs. You don't need to fight with Babel transforms or CommonJS interop issues.

Developer experience: The watch mode is intelligent and interactive. You can filter tests, update snapshots, and see results in a clean terminal UI. Source map support makes stack traces accurate and easy to follow.

Installing Vitest

Getting started with Vitest is straightforward. You can add it to any project, whether or not you already use Vite. The simplest way is to install it as a development dependency.

npm install -D vitest

If you're starting a new Vite project, you can scaffold one with the Vitest template:

npm create vite@latest my-app -- --template vue-ts
cd my-app
npm install -D vitest @vue/test-utils jsdom

For projects that need DOM testing, you'll also want a DOM environment. Vitest supports jsdom and happy-dom. Happy-dom is generally faster, while jsdom is more complete.

npm install -D happy-dom

Configuring Vitest

Vitest can be configured in several ways. The most common approach is to add a test property to your existing vite.config.ts file. This keeps all configuration in one place.

// vite.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    environment: 'happy-dom',
    globals: true,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
    },
  },
})

If you prefer a separate configuration file, you can create a vitest.config.ts file. This is useful when your test configuration differs significantly from your build configuration.

// vitest.config.ts
import { defineConfig } from 'vitest/config'
import path from 'path'

export default defineConfig({
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  test: {
    environment: 'node',
    include: ['tests/**/*.test.ts'],
    exclude: ['node_modules', 'dist'],
  },
})

Important configuration options to know:

Adding Scripts to package.json

Add convenient npm scripts to run your tests. This is a small but important step for team workflows.

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

The test script starts Vitest in watch mode, ideal for local development. The test:run script runs tests once and exits, which is what you want in CI pipelines. The test:ui script launches a browser-based UI for viewing and managing tests, and test:coverage generates a coverage report.

Writing Your First Test

Let's start with a simple example. Suppose you have a utility function that calculates the sum of an array of numbers.

// src/utils/math.ts
export function sum(numbers: number[]): number {
  return numbers.reduce((acc, n) => acc + n, 0)
}

export function average(numbers: number[]): number {
  if (numbers.length === 0) throw new Error('Cannot average empty array')
  return sum(numbers) / numbers.length
}

Now let's write tests for these functions. Vitest test files typically use the .test.ts or .spec.ts extension.

// src/utils/math.test.ts
import { describe, it, expect } from 'vitest'
import { sum, average } from './math'

describe('sum', () => {
  it('adds positive numbers correctly', () => {
    expect(sum([1, 2, 3])).toBe(6)
  })

  it('returns 0 for an empty array', () => {
    expect(sum([])).toBe(0)
  })

  it('handles negative numbers', () => {
    expect(sum([-1, -2, 3])).toBe(0)
  })
})

describe('average', () => {
  it('calculates the average correctly', () => {
    expect(average([2, 4, 6])).toBe(4)
  })

  it('throws on empty array', () => {
    expect(() => average([])).toThrow('Cannot average empty array')
  })
})

Run your tests with npm test and you should see green checkmarks in your terminal. The describe block groups related tests, it defines individual test cases, and expect makes assertions about values.

Understanding Matchers

Vitest's expect API provides a rich set of matchers for making assertions. Here are the most commonly used ones.

// Equality matchers
expect(value).toBe(other)        // Strict equality (===)
expect(value).toEqual(other)     // Deep equality
expect(value).toStrictEqual(other) // Deep equality, strict types

// Truthiness matchers
expect(value).toBeTruthy()
expect(value).toBeFalsy()
expect(value).toBeNull()
expect(value).toBeUndefined()
expect(value).toBeDefined()
expect(value).toBeNaN()

// Number matchers
expect(value).toBeGreaterThan(5)
expect(value).toBeGreaterThanOrEqual(5)
expect(value).toBeLessThan(10)
expect(value).toBeCloseTo(0.3, 5) // Floating point comparison

// String matchers
expect(str).toContain('substring')
expect(str).toMatch(/regex/)
expect(str).toHaveLength(5)

// Array matchers
expect(arr).toContain(item)
expect(arr).toHaveLength(3)
expect(arr).toContainEqual({ key: 'value' })

// Object matchers
expect(obj).toHaveProperty('key')
expect(obj).toHaveProperty('key', 'value')
expect(obj).toMatchObject({ key: 'value' })

// Negation
expect(value).not.toBe(other)

Testing Asynchronous Code

Modern JavaScript is full of asynchronous operations. Vitest provides several ways to test async code. The simplest approach is to make your test function async and await the operation.

// src/utils/api.ts
export async function fetchUser(id: number): Promise<{ id: number; name: string }> {
  const response = await fetch(`/api/users/${id}`)
  if (!response.ok) {
    throw new Error(`Failed to fetch user ${id}`)
  }
  return response.json()
}
// src/utils/api.test.ts
import { describe, it, expect } from 'vitest'
import { fetchUser } from './api'

describe('fetchUser', () => {
  it('returns user data for valid id', async () => {
    const user = await fetchUser(1)
    expect(user).toEqual({ id: 1, name: 'Alice' })
  })

  it('throws for invalid id', async () => {
    await expect(fetchUser(999)).rejects.toThrow('Failed to fetch user 999')
  })
})

You can also test that a promise resolves or rejects using resolves and rejects matchers, which make async assertions cleaner.

Mocking Functions and Modules

Mocking is essential for isolating the code under test from external dependencies. Vitest provides powerful mocking utilities that work with both CommonJS and ESM modules.

Mocking a function: Use vi.fn() to create a mock function that you can assert against.

// src/utils/calculator.ts
export function calculate(operation: (a: number, b: number) => number, a: number, b: number): number {
  return operation(a, b)
}
// src/utils/calculator.test.ts
import { describe, it, expect, vi } from 'vitest'
import { calculate } from './calculator'

describe('calculate', () => {
  it('calls the operation function with correct arguments', () => {
    const mockOperation = vi.fn((a, b) => a + b)
    const result = calculate(mockOperation, 3, 4)

    expect(result).toBe(7)
    expect(mockOperation).toHaveBeenCalledWith(3, 4)
    expect(mockOperation).toHaveBeenCalledTimes(1)
  })
})

Mocking a module: Use vi.mock() to replace an entire module with a mock implementation. This is especially useful for mocking API calls, database access, or external services.

// src/services/userService.ts
import { fetchUser } from '../utils/api'

export async function getUserName(id: number): Promise<string> {
  const user = await fetchUser(id)
  return user.name
}
// src/services/userService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'

// Mock the api module
vi.mock('../utils/api', () => ({
  fetchUser: vi.fn(),
}))

import { fetchUser } from '../utils/api'
import { getUserName } from './userService'

describe('getUserName', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('returns the user name', async () => {
    vi.mocked(fetchUser).mockResolvedValue({ id: 1, name: 'Alice' })

    const name = await getUserName(1)

    expect(name).toBe('Alice')
    expect(fetchUser).toHaveBeenCalledWith(1)
  })

  it('propagates errors from fetchUser', async () => {
    vi.mocked(fetchUser).mockRejectedValue(new Error('Network error'))

    await expect(getUserName(1)).rejects.toThrow('Network error')
  })
})

Note that vi.mock() calls are hoisted to the top of the file automatically by Vitest's transformer, so they work regardless of where you place them in the file. This is a common source of confusion, so it's worth understanding.

Mocking Timers

When testing code that uses setTimeout, setInterval, or Date, you often don't want to wait for real time to pass. Vitest provides fake timers for this purpose.

// src/utils/debounce.ts
export function debounce<T extends (...args: any[]) => void>(
  fn: T,
  delay: number
): (...args: Parameters<T>) => void {
  let timer: ReturnType<typeof setTimeout>
  return (...args: Parameters<T>) => {
    clearTimeout(timer)
    timer = setTimeout(() => fn(...args), delay)
  }
}
// src/utils/debounce.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { debounce } from './debounce'

describe('debounce', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })

  afterEach(() => {
    vi.useRealTimers()
  })

  it('calls the function after the delay', () => {
    const fn = vi.fn()
    const debounced = debounce(fn, 300)

    debounced()
    expect(fn).not.toHaveBeenCalled()

    vi.advanceTimersByTime(299)
    expect(fn).not.toHaveBeenCalled()

    vi.advanceTimersByTime(1)
    expect(fn).toHaveBeenCalledTimes(1)
  })

  it('resets the timer on subsequent calls', () => {
    const fn = vi.fn()
    const debounced = debounce(fn, 300)

    debounced()
    vi.advanceTimersByTime(200)
    debounced()
    vi.advanceTimersByTime(200)
    expect(fn).not.toHaveBeenCalled()

    vi.advanceTimersByTime(100)
    expect(fn).toHaveBeenCalledTimes(1)
  })
})

Snapshot Testing

Snapshot testing is useful for verifying that the output of a function or component hasn't changed unexpectedly. Vitest stores a snapshot file alongside your test, and future runs compare against it.

// src/utils/format.ts
export function formatUser(user: { name: string; age: number; email: string }): string {
  return `Name: ${user.name}, Age: ${user.age}, Email: ${user.email}`
}
// src/utils/format.test.ts
import { describe, it, expect } from 'vitest'
import { formatUser } from './format'

describe('formatUser', () => {
  it('matches snapshot', () => {
    const result = formatUser({ name: 'Alice', age: 30, email: 'alice@example.com' })
    expect(result).toMatchInlineSnapshot(`"Name: Alice, Age: 30, Email: alice@example.com"`)
  })
})

When you use toMatchSnapshot() without an inline argument, Vitest creates a __snapshots__ directory with .snap files. To update snapshots after intentional changes, run vitest -u. Use snapshot testing judiciously—it's great for serializable output but can lead to false confidence if overused.

Testing Vue Components

Vitest pairs naturally with @vue/test-utils for testing Vue components. Here's a complete example of testing a Vue 3 component.

<!-- src/components/Counter.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)
const increment = () => count.value++
const decrement = () => count.value--
</script>

<template>
  <div>
    <p data-testid="count">{{ count }}</p>
    <button data-testid="increment" @click="increment">+</button>
    <button data-testid="decrement" @click="decrement">-</button>
  </div>
</template>
// src/components/Counter.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'

describe('Counter', () => {
  it('renders initial count of 0', () => {
    const wrapper = mount(Counter)
    expect(wrapper.find('[data-testid="count"]').text()).toBe('0')
  })

  it('increments count when button clicked', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('[data-testid="increment"]').trigger('click')
    expect(wrapper.find('[data-testid="count"]').text()).toBe('1')
  })

  it('decrements count when button clicked', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('[data-testid="decrement"]').trigger('click')
    expect(wrapper.find('[data-testid="count"]').text()).toBe('-1')
  })
})

Testing React Components

For React projects, Vitest works beautifully with React Testing Library. Here's an example.

// src/components/Todo.tsx
import { useState } from 'react'

interface Todo {
  id: number
  text: string
  done: boolean
}

export function Todo() {
  const [todos, setTodos] = useState<Todo[]>([])
  const [input, setInput] = useState('')

  const addTodo = () => {
    if (input.trim()) {
      setTodos([...todos, { id: Date.now(), text: input, done: false }])
      setInput('')
    }
  }

  return (
    <div>
      <input
        data-testid="todo-input"
        value={input}
        onChange={(e) => setInput(e.target.value)}
      />
      <button data-testid="add-button" onClick={addTodo}>
        Add
      </button>
      <ul data-testid="todo-list">
        {todos.map((todo) => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </div>
  )
}
// src/components/Todo.test.tsx
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { Todo } from './Todo'

describe('Todo', () => {
  it('adds a new todo', () => {
    render(<Todo />)

    const input = screen.getByTestId('todo-input') as HTMLInputElement
    const button = screen.getByTestId('add-button')

    fireEvent.change(input, { target: { value: 'Buy groceries' } })
    fireEvent.click(button)

    expect(screen.getByText('Buy groceries')).toBeDefined()
    expect(input.value).toBe('')
  })
})

Setup and Teardown Hooks

Vitest provides lifecycle hooks that run before or after tests. These are useful for setting up common state, initializing databases, or cleaning up resources.

import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from 'vitest'

describe('database operations', () => {
  beforeAll(async () => {
    // Runs once before all tests in this describe block
    await setupDatabase()
  })

  beforeEach(async () => {
    // Runs before each test
    await clearTable('users')
  })

  afterEach(async () => {
    // Runs after each test
    await cleanupFiles()
  })

  afterAll(async () => {
    // Runs once after all tests
    await closeDatabase()
  })

  it('inserts a user', async () => {
    // Test logic here
  })

  it('deletes a user', async () => {
    // Test logic here
  })
})

Code Coverage

Measuring code coverage helps you identify untested code paths. Vitest supports both v8 and istanbul coverage providers. V8 is faster and recommended for most projects.

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      include: ['src/**/*.{ts,tsx}'],
      exclude: ['src/**/*.test.{ts,tsx}', 'src/types/**'],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 75,
        statements: 80,
      },
    },
  },
})

Run coverage with npm run test:coverage. The text reporter prints a summary table in the terminal, while html generates a detailed browsable report in the coverage/ directory. Setting thresholds ensures your CI pipeline fails if coverage drops below acceptable levels.

Running Tests in CI

In continuous integration, you want tests to run once and exit with a non-zero code on failure. Use the run command for this.

# GitHub Actions example
name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run test:coverage

Vitest automatically detects CI environments and disables watch mode. You can also explicitly set CI=true in your environment variables to ensure consistent behavior.

Best Practices

Writing good tests is as important as choosing the right framework. Here are proven best practices to follow when working with Vitest.

Advanced Features

In-source testing: Vitest allows you to write tests directly inside your source files. This is useful for small utility functions where a separate test file feels excessive.

// src/utils/math.ts
export function sum(a: number, b: number): number {
  return a + b
}

// Run with: vitest --run --testNamePattern="in-source"
if (import.meta.vitest) {
  const { describe, it, expect } = import.meta.vitest
  describe('sum (in-source)', () => {
    it('adds two numbers', () => {
      expect(sum(2, 3)).toBe(5)
    })
  })
}

Enable this feature in your config:

// vitest.config.ts
export default defineConfig({
  test: {
    includeSource: ['src/**/*.{ts,js}'],
  },
})

Custom matchers: You can extend Vitest's expect with custom matchers using the vitest module. This is useful for domain-specific assertions.

// src/test/matchers.ts
import { expect } from 'vitest'

expect.extend({
  toBeWithinRange(received: number, floor: number, ceiling: number) {
    const pass = received >= floor && received <= ceiling
    if (pass) {
      return {
        message: () => `expected ${received} not to be within range ${floor} - ${ceiling}`,
        pass: true,
      }
    }
    return {
      message: () => `expected ${received} to be within range ${floor} - ${ceiling}`,
      pass: false,
    }
  },
})

declare module 'vitest' {
  interface Assertion<T = any> {
    toBeWithinRange(floor: number, ceiling: number): void
  }
}

Import this file in your setup file to make the custom matcher available everywhere:

// vitest.config.ts
export default defineConfig({
  test: {
    setupFiles: ['./src/test/matchers.ts'],
  },
})

UI mode: Vitest offers a visual UI for browsing and running tests. Install the UI package and launch it with a flag.

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

The UI opens in your browser and shows a tree of your test files, real-time results, code coverage, and detailed error messages with source maps. It's an excellent tool for debugging failing tests.

Common Pitfalls and How to Avoid Them

Even with a great framework, there are common mistakes developers make. Being aware of these will save you hours of debugging.

Conclusion

Vitest is a powerful, modern testing framework that combines the familiarity of Jest with the speed and developer experience of Vite. Its native ESM support, intelligent watch mode, and seamless integration with the Vite ecosystem make it an excellent choice for any JavaScript or TypeScript project. By understanding its core APIs—matchers, mocking, timers, snapshots, and lifecycle hooks—you can write tests that are fast, reliable, and maintainable. Pair that with the best practices outlined in this guide, and you'll have a robust testing strategy that catches bugs early, documents your code's behavior, and gives you confidence to refactor and ship. Whether you're starting a new project or migrating from Jest, Vitest is well worth your time to learn and adopt.

— Ad —

Google AdSense will appear here after approval

← Back to all articles