We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide28 min read

Vitest, unit testing built on Vite

Vitest is a test runner reusing your Vite configuration. Version 4, browser mode, module mocking, coverage, and migrating from Jest.

Vitest, unit testing built on Vite

Vitest is a test runner that uses the same configuration as Vite. If your project builds through Vite, tests inherit path aliases, plugins, and transforms without duplicating settings. MIT licensed, currently stable at 4.1.10, with version 5 in beta.

Why this won over Jest

The answer is not "because it is faster", even though that is how it tends to be marketed. The reason is more mundane and concerns configuration.

For years a typical frontend project carried two parallel configurations: one for the build tool and one for the test runner. Both had to know the same things about path aliases, TypeScript handling, and what to do with an imported stylesheet or image. They drifted apart regularly, and the symptom was a test unable to import a module the application imported without trouble.

Vitest removes that problem by reading the build tool's configuration. A single source of truth means an alias added for the application works in tests immediately, and a plugin handling an unusual file format needs no second implementation.

Speed is a side effect here, but a real one. Tests run from native modules rather than passing through a transpilation layer for every file, and in watch mode only files depending on what you changed are recomputed. Across a large suite the difference is noticeable.

Interface compatibility with Jest was a strategic decision and it is what drove adoption, including in Next.js projects where migration used to hurt most. The functions describing tests, the assertions, and the mocks carry the same names, so migrating an existing suite is mostly a configuration change rather than a rewrite.

There is a boundary to that compatibility worth asking about before migrating, though. Tests relying on mechanisms specific to the older tool, its own file transformation approach or plugins written against its internals, will not carry over automatically. The same applies to suites leaning on implicit module substitution, since here substitution is always explicit. In practice moving a typical unit test suite takes an afternoon, while a suite with elaborate bespoke test infrastructure takes days.

Browser mode, the biggest change in version 4

This is the most important thing to know, because material predating that release describes it as experimental.

Browser mode stopped being an experiment in version 4 and is marked stable. It runs the same tests in a real browser instead of an environment simulating a document, with no change to test code.

The difference matters practically. A simulated document environment is a browser implemented in JavaScript and does not reproduce everything: computed styles, element dimensions, visibility observers, and pointer event behaviour work differently there or not at all. A test checking whether an element is visible can pass there and fail for a user.

Stabilisation changed how it is configured, though, and that is the trap when upgrading. The browser provider is now installed as a separate package depending on whether you use Playwright, WebDriverIO, or preview mode. Configuration written before version 4 stops working and needs adjusting.

Screenshot comparison arrived too, along with execution trace recording familiar from Playwright. The latter is particularly useful for tests failing only on a build server, since instead of guessing you open the trace and see the page state at every step.

The cost is worth remembering, though. Launching a real browser takes longer than a simulated environment, so a sensible arrangement keeps most tests lightweight and reserves browser mode for cases where actual rendering matters.

What is Vitest?

Vitest is a modern testing framework built specifically for the Vite ecosystem. It uses the same configuration as Vite, which means tests run considerably faster than through a separate transpilation layer. Vitest offers full compatibility with the Jest API, meaning you can migrate existing tests with virtually no changes.

The framework is developed by the creators of Vite (Evan You and team) and has quickly become the testing standard in Vue, React and Svelte projects that use Vite. Native support for ESM, TypeScript and JSX means configuration is minimal - in most cases you just install the package and start writing tests.

Why Vitest instead of Jest?

Key advantages of Vitest

  1. Blazing speed - Shares the transformation pipeline with Vite
  2. Zero configuration - Works out-of-the-box with TypeScript, JSX, ESM
  3. Smart watch mode - Only reruns affected tests
  4. Native ESM - No need to transpile to CommonJS
  5. Jest compatibility - Migrate existing tests without changes
  6. Inline testing - Tests can live in the same file as the code
  7. Browser mode - Tests can run in a real browser

Vitest vs Jest - detailed comparison

FeatureVitestJest
Startup time~10x fasterSlower cold start
Hot Module ReplacementYes (with Vite)No
ESMNativeRequires configuration
TypeScriptOut-of-boxRequires ts-jest or babel
Watch modeSmart (dependency graph)Basic (file change)
ConfigurationUses vite.configSeparate jest.config
Browser testingBuilt-inRequires Puppeteer/Playwright
In-source testingYesNo
Snapshot testingYesYes
Coveragev8 or istanbulistanbul

When to choose Vitest?

  • Project uses Vite - Obvious choice, shares configuration
  • New project - Faster setup and better developer experience
  • Migrating from Jest - The API is practically identical
  • TypeScript/ESM - Zero additional configuration
  • You need speed - Watch mode is incomparably faster

When to stay with Jest?

  • Project without Vite - Vitest can work standalone, but loses its advantage
  • Large existing test base - Migration can be risky
  • Specific Jest plugins - Not all have equivalents
  • Create React App - Jest is the default and well integrated

Installation and configuration

Basic installation

Code
Bash
# npm
npm install -D vitest

# pnpm
pnpm add -D vitest

# yarn
yarn add -D vitest

# bun
bun add -D vitest

Package.json configuration

Code
JSON
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest run --coverage",
    "test:watch": "vitest --watch"
  },
  "devDependencies": {
    "vitest": "^4.0.0"
  }
}

vitest.config.ts configuration

TSvitest.config.ts
TypeScript
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    // Test environment
    environment: 'jsdom', // or 'node', 'happy-dom', 'edge-runtime'

    // Global imports (describe, it, expect without importing)
    globals: true,

    // Setup files (run before tests)
    setupFiles: ['./src/test/setup.ts'],

    // Test file patterns
    include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
    exclude: ['node_modules', 'dist', '.idea', '.git', '.cache'],

    // Coverage
    coverage: {
      provider: 'v8', // or 'istanbul'
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
      ],
    },

    // Timeouts
    testTimeout: 10000,
    hookTimeout: 10000,

    // Parallelism
    pool: 'threads', // or 'forks', 'vmThreads', 'vmForks'
    maxWorkers: '50%',
    isolate: true,

    // Snapshot
    snapshotFormat: {
      escapeString: true,
      printBasicPrototype: true,
    },
  },
})

React configuration

TSvitest.config.ts
TypeScript
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './src/test/setup.ts',
    css: true,
  },
})
TSsrc/test/setup.ts
TypeScript
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'

// Automatic cleanup after each test
afterEach(() => {
  cleanup()
})

Vue configuration

TSvitest.config.ts
TypeScript
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './src/test/setup.ts',
  },
})

TypeScript configuration

tsconfig.json
JSON
// tsconfig.json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

Writing tests

Basic syntax

TSmath.test.ts
TypeScript
// math.test.ts
import { describe, it, expect, test } from 'vitest'
import { sum, multiply, divide } from './math'

// describe groups related tests
describe('Math utilities', () => {
  // Basic test
  it('adds 1 + 2 to equal 3', () => {
    expect(sum(1, 2)).toBe(3)
  })

  // test and it are equivalent
  test('multiplies 2 * 3 to equal 6', () => {
    expect(multiply(2, 3)).toBe(6)
  })

  // Nested describe for organization
  describe('sum function', () => {
    it('handles positive numbers', () => {
      expect(sum(1, 2)).toBe(3)
      expect(sum(100, 200)).toBe(300)
    })

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

    it('handles zero', () => {
      expect(sum(0, 0)).toBe(0)
      expect(sum(5, 0)).toBe(5)
    })
  })

  describe('divide function', () => {
    it('divides numbers correctly', () => {
      expect(divide(10, 2)).toBe(5)
    })

    it('throws on division by zero', () => {
      expect(() => divide(10, 0)).toThrow('Cannot divide by zero')
    })
  })
})

Assertions (expect)

Code
TypeScript
import { describe, it, expect } from 'vitest'

describe('Assertions', () => {
  // Equality
  it('equality matchers', () => {
    expect(2 + 2).toBe(4)              // Strict equality (===)
    expect({ a: 1 }).toEqual({ a: 1 }) // Deep equality
    expect([1, 2]).toStrictEqual([1, 2]) // Strict deep equality
  })

  // Truthiness
  it('truthiness matchers', () => {
    expect(true).toBeTruthy()
    expect(false).toBeFalsy()
    expect(null).toBeNull()
    expect(undefined).toBeUndefined()
    expect('defined').toBeDefined()
  })

  // Numbers
  it('number matchers', () => {
    expect(10).toBeGreaterThan(5)
    expect(5).toBeGreaterThanOrEqual(5)
    expect(3).toBeLessThan(5)
    expect(5).toBeLessThanOrEqual(5)
    expect(0.1 + 0.2).toBeCloseTo(0.3) // Floating point
  })

  // Strings
  it('string matchers', () => {
    expect('Hello World').toContain('World')
    expect('Hello World').toMatch(/world/i)
    expect('Hello').toHaveLength(5)
  })

  // Arrays and iterables
  it('array matchers', () => {
    const arr = [1, 2, 3]
    expect(arr).toContain(2)
    expect(arr).toHaveLength(3)
    expect(arr).toEqual(expect.arrayContaining([1, 3]))
  })

  // Objects
  it('object matchers', () => {
    const user = { name: 'John', age: 30, email: 'john@example.com' }
    expect(user).toHaveProperty('name')
    expect(user).toHaveProperty('name', 'John')
    expect(user).toMatchObject({ name: 'John', age: 30 })
    expect(user).toEqual(expect.objectContaining({ name: 'John' }))
  })

  // Exceptions
  it('exception matchers', () => {
    const throwError = () => { throw new Error('Oops!') }
    expect(throwError).toThrow()
    expect(throwError).toThrow('Oops!')
    expect(throwError).toThrow(Error)
    expect(throwError).toThrow(/oops/i)
  })

  // Negation
  it('negation', () => {
    expect(5).not.toBe(10)
    expect([1, 2]).not.toContain(3)
  })
})

Async tests

Code
TypeScript
import { describe, it, expect } from 'vitest'
import { fetchUser, fetchPosts, saveUser } from './api'

describe('Async tests', () => {
  // Async/await - most readable
  it('fetches user with async/await', async () => {
    const user = await fetchUser(1)
    expect(user).toHaveProperty('name')
    expect(user.id).toBe(1)
  })

  // Promise return
  it('fetches user with promise', () => {
    return fetchUser(1).then(user => {
      expect(user).toHaveProperty('name')
    })
  })

  // Resolves/rejects matchers
  it('uses resolves matcher', async () => {
    await expect(fetchUser(1)).resolves.toHaveProperty('name')
    await expect(fetchUser(-1)).rejects.toThrow('Not found')
  })

  // Multiple parallel operations
  it('fetches multiple resources in parallel', async () => {
    const [user, posts] = await Promise.all([
      fetchUser(1),
      fetchPosts(1)
    ])

    expect(user.id).toBe(1)
    expect(posts).toBeInstanceOf(Array)
  })

  // Timeout for long operations
  it('handles long operation', async () => {
    const result = await saveUser({ name: 'Test' })
    expect(result.success).toBe(true)
  }, 10000) // 10 second timeout
})

Setup and teardown

Code
TypeScript
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'
import { createDatabase, closeDatabase, clearDatabase } from './db'

describe('Database tests', () => {
  // Once before all tests in the describe block
  beforeAll(async () => {
    await createDatabase()
    console.log('Database created')
  })

  // Once after all tests in the describe block
  afterAll(async () => {
    await closeDatabase()
    console.log('Database closed')
  })

  // Before each test
  beforeEach(async () => {
    await clearDatabase()
    console.log('Database cleared')
  })

  // After each test
  afterEach(() => {
    console.log('Test completed')
  })

  it('test 1', () => {
    // Database is clean
  })

  it('test 2', () => {
    // Database is clean
  })
})

Skip, only and todo

Code
TypeScript
import { describe, it, expect } from 'vitest'

describe('Test modifiers', () => {
  // Skip a test
  it.skip('skipped test', () => {
    // This test will not be run
  })

  // Run ONLY this test
  it.only('focused test', () => {
    // Only this test will be run
  })

  // Test to implement later
  it.todo('implement this test later')

  // Conditional skipping
  it.skipIf(process.env.CI)('skip on CI', () => {
    // Skipped on CI
  })

  it.runIf(process.env.CI)('run only on CI', () => {
    // Run only on CI
  })

  // Skip an entire describe block
  describe.skip('skipped suite', () => {
    it('test 1', () => {})
    it('test 2', () => {})
  })

  // Concurrent test execution
  describe.concurrent('parallel tests', () => {
    it('test 1', async () => {})
    it('test 2', async () => {})
  })
})

Mocking

Mocking functions

Code
TypeScript
import { describe, it, expect, vi } from 'vitest'
import { processUser, sendEmail } from './user'

describe('Mocking functions', () => {
  // Basic mock
  it('creates a mock function', () => {
    const mockFn = vi.fn()
    mockFn('arg1', 'arg2')

    expect(mockFn).toHaveBeenCalled()
    expect(mockFn).toHaveBeenCalledTimes(1)
    expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2')
  })

  // Mock with implementation
  it('mocks with implementation', () => {
    const mockFn = vi.fn((x: number) => x * 2)

    expect(mockFn(5)).toBe(10)
    expect(mockFn).toHaveReturnedWith(10)
  })

  // Mock returning a value
  it('mocks return value', () => {
    const mockFn = vi.fn()
      .mockReturnValue('default')
      .mockReturnValueOnce('first')
      .mockReturnValueOnce('second')

    expect(mockFn()).toBe('first')
    expect(mockFn()).toBe('second')
    expect(mockFn()).toBe('default')
  })

  // Mock async function
  it('mocks async function', async () => {
    const mockFn = vi.fn()
      .mockResolvedValue({ id: 1, name: 'John' })
      .mockResolvedValueOnce({ id: 2, name: 'Jane' })

    expect(await mockFn()).toEqual({ id: 2, name: 'Jane' })
    expect(await mockFn()).toEqual({ id: 1, name: 'John' })
  })

  // Mock rejection
  it('mocks rejected promise', async () => {
    const mockFn = vi.fn().mockRejectedValue(new Error('Failed'))

    await expect(mockFn()).rejects.toThrow('Failed')
  })

  // Clearing mocks
  it('clears mock state', () => {
    const mockFn = vi.fn().mockReturnValue(42)
    mockFn()
    mockFn()

    expect(mockFn).toHaveBeenCalledTimes(2)

    mockFn.mockClear() // Clears calls, not implementation
    expect(mockFn).toHaveBeenCalledTimes(0)
    expect(mockFn()).toBe(42)

    mockFn.mockReset() // Clears everything
    expect(mockFn()).toBeUndefined()
  })
})

Mocking modules

TSuser-service.ts
TypeScript
// user-service.ts
import axios from 'axios'

export async function getUser(id: number) {
  const response = await axios.get(`/api/users/${id}`)
  return response.data
}

// user-service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import axios from 'axios'
import { getUser } from './user-service'

// Mock the entire module
vi.mock('axios')
const mockedAxios = vi.mocked(axios)

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

  it('fetches user from API', async () => {
    const mockUser = { id: 1, name: 'John' }
    mockedAxios.get.mockResolvedValue({ data: mockUser })

    const user = await getUser(1)

    expect(mockedAxios.get).toHaveBeenCalledWith('/api/users/1')
    expect(user).toEqual(mockUser)
  })

  it('handles API error', async () => {
    mockedAxios.get.mockRejectedValue(new Error('Network error'))

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

Partial mocking

TSutils.ts
TypeScript
// utils.ts
export function formatDate(date: Date): string {
  return date.toISOString()
}

export function validateEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}

export function generateId(): string {
  return Math.random().toString(36).substring(7)
}

// utils.test.ts
import { describe, it, expect, vi } from 'vitest'

// Mock only selected functions
vi.mock('./utils', async () => {
  const actual = await vi.importActual('./utils')
  return {
    ...actual,
    generateId: vi.fn(() => 'mocked-id'),
  }
})

import { formatDate, validateEmail, generateId } from './utils'

describe('Partial mocking', () => {
  it('uses real formatDate', () => {
    const date = new Date('2024-01-01')
    expect(formatDate(date)).toBe('2024-01-01T00:00:00.000Z')
  })

  it('uses real validateEmail', () => {
    expect(validateEmail('test@example.com')).toBe(true)
    expect(validateEmail('invalid')).toBe(false)
  })

  it('uses mocked generateId', () => {
    expect(generateId()).toBe('mocked-id')
  })
})

Spying on methods

Code
TypeScript
import { describe, it, expect, vi } from 'vitest'

describe('Spying', () => {
  it('spies on object method', () => {
    const user = {
      name: 'John',
      greet() {
        return `Hello, ${this.name}!`
      }
    }

    const spy = vi.spyOn(user, 'greet')

    const result = user.greet()

    expect(spy).toHaveBeenCalled()
    expect(result).toBe('Hello, John!')

    spy.mockRestore() // Restore the original implementation
  })

  it('spies on console.log', () => {
    const spy = vi.spyOn(console, 'log').mockImplementation(() => {})

    console.log('test message')

    expect(spy).toHaveBeenCalledWith('test message')

    spy.mockRestore()
  })

  it('spies and changes implementation', () => {
    const obj = {
      getValue: () => 'original'
    }

    vi.spyOn(obj, 'getValue').mockReturnValue('mocked')

    expect(obj.getValue()).toBe('mocked')
  })
})

Mocking timers

Code
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'

function delay(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms))
}

function debounce<T extends (...args: any[]) => any>(
  fn: T,
  delay: number
): (...args: Parameters<T>) => void {
  let timeoutId: NodeJS.Timeout
  return (...args) => {
    clearTimeout(timeoutId)
    timeoutId = setTimeout(() => fn(...args), delay)
  }
}

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

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

  it('advances timers', async () => {
    const callback = vi.fn()
    setTimeout(callback, 1000)

    expect(callback).not.toHaveBeenCalled()

    vi.advanceTimersByTime(1000)

    expect(callback).toHaveBeenCalled()
  })

  it('runs all timers', () => {
    const callback1 = vi.fn()
    const callback2 = vi.fn()

    setTimeout(callback1, 1000)
    setTimeout(callback2, 2000)

    vi.runAllTimers()

    expect(callback1).toHaveBeenCalled()
    expect(callback2).toHaveBeenCalled()
  })

  it('tests debounce', () => {
    const callback = vi.fn()
    const debouncedFn = debounce(callback, 500)

    debouncedFn('a')
    debouncedFn('b')
    debouncedFn('c')

    expect(callback).not.toHaveBeenCalled()

    vi.advanceTimersByTime(500)

    expect(callback).toHaveBeenCalledTimes(1)
    expect(callback).toHaveBeenCalledWith('c')
  })

  it('mocks Date', () => {
    vi.setSystemTime(new Date('2024-06-15'))

    expect(new Date().toISOString()).toContain('2024-06-15')
  })
})

Mocking global objects

Code
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'

describe('Global mocking', () => {
  it('mocks fetch', async () => {
    const mockResponse = { data: 'test' }

    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve(mockResponse)
    }))

    const response = await fetch('/api/data')
    const data = await response.json()

    expect(data).toEqual(mockResponse)

    vi.unstubAllGlobals()
  })

  it('mocks localStorage', () => {
    const localStorageMock = {
      getItem: vi.fn(),
      setItem: vi.fn(),
      removeItem: vi.fn(),
      clear: vi.fn(),
    }

    vi.stubGlobal('localStorage', localStorageMock)

    localStorage.setItem('key', 'value')
    expect(localStorageMock.setItem).toHaveBeenCalledWith('key', 'value')

    vi.unstubAllGlobals()
  })

  it('mocks window.location', () => {
    const locationMock = {
      href: 'http://localhost:3000',
      assign: vi.fn(),
      reload: vi.fn(),
    }

    vi.stubGlobal('location', locationMock)

    location.assign('http://example.com')
    expect(locationMock.assign).toHaveBeenCalledWith('http://example.com')

    vi.unstubAllGlobals()
  })
})

Testing React components

Setup for React Testing Library

Code
Bash
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
TSsrc/test/setup.ts
TypeScript
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'

afterEach(() => {
  cleanup()
})

Testing components

TSButton.tsx
TypeScript
// Button.tsx
interface ButtonProps {
  children: React.ReactNode
  onClick?: () => void
  disabled?: boolean
  variant?: 'primary' | 'secondary'
}

export function Button({
  children,
  onClick,
  disabled = false,
  variant = 'primary'
}: ButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`btn btn-${variant}`}
      data-testid="button"
    >
      {children}
    </button>
  )
}

// Button.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from './Button'

describe('Button', () => {
  it('renders with text', () => {
    render(<Button>Click me</Button>)
    expect(screen.getByText('Click me')).toBeInTheDocument()
  })

  it('renders with correct class', () => {
    render(<Button variant="secondary">Test</Button>)
    expect(screen.getByTestId('button')).toHaveClass('btn-secondary')
  })

  it('handles click events', async () => {
    const handleClick = vi.fn()
    const user = userEvent.setup()

    render(<Button onClick={handleClick}>Click me</Button>)

    await user.click(screen.getByText('Click me'))

    expect(handleClick).toHaveBeenCalledTimes(1)
  })

  it('is disabled when disabled prop is true', () => {
    render(<Button disabled>Click me</Button>)
    expect(screen.getByText('Click me')).toBeDisabled()
  })

  it('does not call onClick when disabled', async () => {
    const handleClick = vi.fn()
    const user = userEvent.setup()

    render(<Button onClick={handleClick} disabled>Click me</Button>)

    await user.click(screen.getByText('Click me'))

    expect(handleClick).not.toHaveBeenCalled()
  })
})

Testing forms

TSLoginForm.tsx
TypeScript
// LoginForm.tsx
import { useState } from 'react'

interface LoginFormProps {
  onSubmit: (email: string, password: string) => void
}

export function LoginForm({ onSubmit }: LoginFormProps) {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()

    if (!email || !password) {
      setError('All fields are required')
      return
    }

    if (!email.includes('@')) {
      setError('Invalid email format')
      return
    }

    setError('')
    onSubmit(email, password)
  }

  return (
    <form onSubmit={handleSubmit} data-testid="login-form">
      {error && <div role="alert">{error}</div>}

      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />

      <label htmlFor="password">Password</label>
      <input
        id="password"
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />

      <button type="submit">Login</button>
    </form>
  )
}

// LoginForm.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'

describe('LoginForm', () => {
  it('renders form fields', () => {
    render(<LoginForm onSubmit={() => {}} />)

    expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
    expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
    expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument()
  })

  it('shows error when fields are empty', async () => {
    const user = userEvent.setup()
    render(<LoginForm onSubmit={() => {}} />)

    await user.click(screen.getByRole('button', { name: /login/i }))

    expect(screen.getByRole('alert')).toHaveTextContent('All fields are required')
  })

  it('shows error for invalid email', async () => {
    const user = userEvent.setup()
    render(<LoginForm onSubmit={() => {}} />)

    await user.type(screen.getByLabelText(/email/i), 'invalid')
    await user.type(screen.getByLabelText(/password/i), 'password123')
    await user.click(screen.getByRole('button', { name: /login/i }))

    expect(screen.getByRole('alert')).toHaveTextContent('Invalid email format')
  })

  it('calls onSubmit with valid data', async () => {
    const handleSubmit = vi.fn()
    const user = userEvent.setup()

    render(<LoginForm onSubmit={handleSubmit} />)

    await user.type(screen.getByLabelText(/email/i), 'test@example.com')
    await user.type(screen.getByLabelText(/password/i), 'password123')
    await user.click(screen.getByRole('button', { name: /login/i }))

    expect(handleSubmit).toHaveBeenCalledWith('test@example.com', 'password123')
  })
})

Testing with context and providers

TSThemeContext.tsx
TypeScript
// ThemeContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react'

interface ThemeContextType {
  theme: 'light' | 'dark'
  toggleTheme: () => void
}

const ThemeContext = createContext<ThemeContextType | null>(null)

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light')

  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light')
  }

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

export function useTheme() {
  const context = useContext(ThemeContext)
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider')
  }
  return context
}

// ThemedButton.tsx
import { useTheme } from './ThemeContext'

export function ThemedButton() {
  const { theme, toggleTheme } = useTheme()
  return (
    <button onClick={toggleTheme}>
      Current theme: {theme}
    </button>
  )
}

// ThemedButton.test.tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ThemeProvider } from './ThemeContext'
import { ThemedButton } from './ThemedButton'

// Helper for rendering with a provider
function renderWithTheme(ui: React.ReactElement) {
  return render(
    <ThemeProvider>{ui}</ThemeProvider>
  )
}

describe('ThemedButton', () => {
  it('shows current theme', () => {
    renderWithTheme(<ThemedButton />)
    expect(screen.getByText(/current theme: light/i)).toBeInTheDocument()
  })

  it('toggles theme on click', async () => {
    const user = userEvent.setup()
    renderWithTheme(<ThemedButton />)

    await user.click(screen.getByRole('button'))

    expect(screen.getByText(/current theme: dark/i)).toBeInTheDocument()
  })
})

Testing hooks

TSuseCounter.ts
TypeScript
// useCounter.ts
import { useState, useCallback } from 'react'

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue)

  const increment = useCallback(() => setCount(c => c + 1), [])
  const decrement = useCallback(() => setCount(c => c - 1), [])
  const reset = useCallback(() => setCount(initialValue), [initialValue])

  return { count, increment, decrement, reset }
}

// useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('initializes with default value', () => {
    const { result } = renderHook(() => useCounter())
    expect(result.current.count).toBe(0)
  })

  it('initializes with custom value', () => {
    const { result } = renderHook(() => useCounter(10))
    expect(result.current.count).toBe(10)
  })

  it('increments counter', () => {
    const { result } = renderHook(() => useCounter())

    act(() => {
      result.current.increment()
    })

    expect(result.current.count).toBe(1)
  })

  it('decrements counter', () => {
    const { result } = renderHook(() => useCounter(5))

    act(() => {
      result.current.decrement()
    })

    expect(result.current.count).toBe(4)
  })

  it('resets counter', () => {
    const { result } = renderHook(() => useCounter(10))

    act(() => {
      result.current.increment()
      result.current.increment()
      result.current.reset()
    })

    expect(result.current.count).toBe(10)
  })
})

Snapshot testing

TSCard.tsx
TypeScript
// Card.tsx
interface CardProps {
  title: string
  description: string
  imageUrl?: string
}

export function Card({ title, description, imageUrl }: CardProps) {
  return (
    <div className="card">
      {imageUrl && <img src={imageUrl} alt={title} />}
      <h2>{title}</h2>
      <p>{description}</p>
    </div>
  )
}

// Card.test.tsx
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { Card } from './Card'

describe('Card', () => {
  it('matches snapshot', () => {
    const { container } = render(
      <Card
        title="Test Card"
        description="This is a test description"
      />
    )

    expect(container).toMatchSnapshot()
  })

  it('matches snapshot with image', () => {
    const { container } = render(
      <Card
        title="Test Card"
        description="Description"
        imageUrl="https://example.com/image.jpg"
      />
    )

    expect(container).toMatchSnapshot()
  })

  // Inline snapshot - saved in the test file itself
  it('matches inline snapshot', () => {
    const { container } = render(
      <Card title="Title" description="Desc" />
    )

    expect(container.innerHTML).toMatchInlineSnapshot(`
      "<div class=\\"card\\"><h2>Title</h2><p>Desc</p></div>"
    `)
  })
})

Coverage

Coverage configuration

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

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8', // or 'istanbul'
      reporter: ['text', 'json', 'html', 'lcov'],
      reportsDirectory: './coverage',

      // Files to include
      include: ['src/**/*.{ts,tsx}'],

      // Files to exclude
      exclude: [
        'node_modules',
        'src/test',
        '**/*.d.ts',
        '**/*.test.{ts,tsx}',
        '**/index.ts',
      ],

      // Coverage thresholds
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 70,
        statements: 80,

        // Do not raise thresholds automatically after a better run
        autoUpdate: false,
      },
    },
  },
})

Running coverage

Code
Bash
# Generate coverage report
pnpm test:coverage

# With UI
pnpm vitest --coverage --ui

Vitest UI

Code
Bash
# Installation
npm install -D @vitest/ui

# Run
pnpm vitest --ui

Vitest UI offers:

  • Interactive test preview in the browser
  • Filtering and searching tests
  • Coverage preview
  • Re-run individual tests
  • Dependency graph view

In-source testing

Vitest allows writing tests in the same file as the code:

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

export function multiply(a: number, b: number): number {
  return a * b
}

// Tests in the same file - removed during build
if (import.meta.vitest) {
  const { describe, it, expect } = import.meta.vitest

  describe('sum', () => {
    it('adds numbers', () => {
      expect(sum(1, 2)).toBe(3)
    })
  })

  describe('multiply', () => {
    it('multiplies numbers', () => {
      expect(multiply(2, 3)).toBe(6)
    })
  })
}

Configuration:

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

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

Browser mode

Code
Bash
# Installation (the provider is a separate package: playwright, webdriverio or preview)
npm install -D vitest @vitest/browser-playwright
TSvitest.config.ts
TypeScript
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser-playwright'

export default defineConfig({
  test: {
    browser: {
      enabled: true,
      provider: playwright(),
      instances: [
        { browser: 'chromium' },
      ],
    },
  },
})

CI/CD integration

GitHub Actions

.github/workflows/test.yml
YAML
# .github/workflows/test.yml
name: Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'

      - name: Install pnpm
        uses: pnpm/action-setup@v2
        with:
          version: 8

      - name: Install dependencies
        run: pnpm install

      - name: Run tests
        run: pnpm test:run

      - name: Run coverage
        run: pnpm test:coverage

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info
          fail_ci_if_error: true

GitLab CI

.gitlab-ci.yml
YAML
# .gitlab-ci.yml
test:
  image: node:20
  stage: test
  before_script:
    - npm install -g pnpm
    - pnpm install
  script:
    - pnpm test:run
    - pnpm test:coverage
  coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml

Migrating from Jest

Where the migration starts

There is no official tool that rewrites the tests, and the migration guide describes the work as manual. You start by swapping dependencies and configuration, then fix whatever fails afterwards.

Code
Bash
npm uninstall jest ts-jest babel-jest jest-environment-jsdom
npm install -D vitest jsdom

Manual changes

Code
TypeScript
// Jest
jest.fn()
jest.spyOn(obj, 'method')
jest.mock('./module')
jest.useFakeTimers()

// Vitest
vi.fn()
vi.spyOn(obj, 'method')
vi.mock('./module')
vi.useFakeTimers()
Code
TypeScript
// Jest - globals in setupFilesAfterEnv
// jest.config.js
module.exports = {
  setupFilesAfterEnv: ['./src/setupTests.ts']
}

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

Best practices

1. Test organization

Code
TEXT
src/
├── components/
│   ├── Button/
│   │   ├── Button.tsx
│   │   ├── Button.test.tsx    # Tests next to code
│   │   └── index.ts
├── hooks/
│   ├── useCounter.ts
│   └── useCounter.test.ts
├── utils/
│   ├── format.ts
│   └── format.test.ts
└── test/
    ├── setup.ts               # Global setup
    ├── mocks/                  # Shared mocks
    │   └── handlers.ts
    └── utils/                  # Test utilities
        └── renderWithProviders.tsx

2. Test naming

Code
TypeScript
// Descriptive names
describe('Button component', () => {
  describe('when clicked', () => {
    it('should call onClick handler', () => {})
    it('should not call onClick when disabled', () => {})
  })

  describe('when loading', () => {
    it('should show spinner', () => {})
    it('should disable the button', () => {})
  })
})

3. Avoid implementation details

Code
TypeScript
// Bad - testing implementation
it('sets state to true', () => {
  const { result } = renderHook(() => useToggle())
  expect(result.current[0]).toBe(false)
})

// Good - testing behaviour
it('toggles from off to on', () => {
  render(<Toggle />)
  const button = screen.getByRole('switch')

  expect(button).toHaveAttribute('aria-checked', 'false')
  fireEvent.click(button)
  expect(button).toHaveAttribute('aria-checked', 'true')
})

4. Use appropriate queries

Code
TypeScript
// Query priority in React Testing Library:
// 1. getByRole - most accessible
// 2. getByLabelText - for forms
// 3. getByPlaceholderText - for inputs
// 4. getByText - for text
// 5. getByTestId - last resort

// Good
screen.getByRole('button', { name: /submit/i })
screen.getByLabelText(/email/i)

// Bad
screen.getByTestId('submit-button')
container.querySelector('.btn-submit')

5. Isolate tests

Code
TypeScript
describe('UserService', () => {
  let db: TestDatabase

  beforeEach(async () => {
    db = await createTestDatabase()
  })

  afterEach(async () => {
    await db.cleanup()
  })

  it('creates user', async () => {
    // Test uses a clean database
  })
})

FAQ - Frequently asked questions

How to run a single test?

Code
Bash
# By file name
pnpm vitest Button.test.tsx

# By pattern
pnpm vitest -t "should render"

# In watch mode, press 't' and type a pattern

How to debug tests?

Code
TypeScript
// 1. Use console.log
it('debug test', () => {
  const result = someFunction()
  console.log('Result:', result)
})

// 2. Use screen.debug()
it('debug DOM', () => {
  render(<Component />)
  screen.debug() // Displays the entire DOM
  screen.debug(screen.getByRole('button')) // Specific element
})

// 3. VS Code debugger
// Add "test": "vitest --inspect-brk --no-file-parallelism" to scripts

How to test components with external dependencies?

Code
TypeScript
// Mock an external module
vi.mock('next/router', () => ({
  useRouter: () => ({
    push: vi.fn(),
    pathname: '/',
    query: {},
  }),
}))

// Mock fetch
vi.stubGlobal('fetch', vi.fn())

Vitest vs Playwright - when to use which?

VitestPlaywright
Unit/integration testsE2E tests
Fast, isolatedSlower, realistic
jsdom/happy-domReal browsers
Testing logicTesting user flows
Individual componentsEntire application

What version 5 brings

Version 5 is in beta, so details may still shift, but a few things are worth knowing when planning, because they concern environment requirements.

Version floors were raised: Node.js 22 or newer and Vite 6.4 or newer will be required. For projects on older versions that means upgrading the test runner drags the rest along, and that is usually its own task.

Default mock behaviour changes too. Clearing mocks between tests will be on by default, so call state resets before every test without asking. That is a change for the better, since it eliminates a common class of inter test dependencies, but it may expose tests that passed by accident on state left by a predecessor.

Previously deprecated ways of marking sequentially executed tests will be removed. If your suite uses them, review it early, since the change is mechanical but touches many files at once.

Practical advice: do not migrate to a beta release on a production project, but check now whether you meet the environment version requirements. If not, upgrading Node.js is what to schedule before version 5 ships stable.

Module mocking, where the trouble concentrates

Substituting modules in tests is a feature everyone uses and almost everyone applies too broadly. A few things are worth knowing before diving in.

The first is call hoisting. The declaration replacing a module is moved to the top of the file, before imports, regardless of where you wrote it. The consequence is that it cannot reference a variable defined higher in the file, because at execution time that variable does not exist yet. The error mentions access before initialisation and is the most common mistake in early mock heavy tests.

The second is scope. A replaced module stays replaced for the whole test file rather than one case. If you need different behaviour across tests, replace it once and change what the mock returns inside individual tests.

The third, and the most substantive, is whether to mock at all. A test replacing everything around the code under test checks that functions get called in a fixed order rather than that the code does what it should. Such a test passes after a refactor that breaks behaviour and fails after one that breaks nothing. Mock the system boundaries, meaning network, time, randomness, and the filesystem, and test your own logic for real.

The fourth is time. Controlling the clock lets you test time dependent behaviour without waiting, and it is almost always better than counting down in a test. Just remember to restore the real clock afterwards, or later tests in the same file inherit a frozen clock and start behaving strangely.

The fifth concerns code coverage and is more about interpretation than configuration. A percentage says how many lines executed during the tests, not how many behaviours were checked. A test that calls a function without verifying the result raises coverage exactly as much as a sound one, so a threshold set in configuration guards against untouched code rather than against untested code. Treat the number as a signal about where to look rather than as a goal, because a team chasing a high percentage usually reaches it the cheapest way available.

Vitest against the alternatives

FeatureVitestJestPlaywright
Test kindsunit and componentunit and componentend to end and component
Configurationinherited from Viteseparateseparate
Native module supportnativevia a compatibility layernative
Real browseroptional, browser modenoalways
Speed per testhighmediumlow
Ecosystem and materialgrowinglargestlarge

The division of labour between these tools is fairly settled today. Logic, helper functions, and component behaviour go to the lightweight runner. Flows spanning the whole application, where a real browser and a real server matter, are tested separately. The overlap around component testing is real, and the choice depends on whether you need faithful rendering or speed.

Documentation lives on the Vitest site, and the source in the project repository.