Usamos cookies para mejorar tu experiencia en el sitio
CodeWorlds
Volver a colecciones
Guide22 min read

Vitest

Vitest is a blazingly fast testing framework for Vite with full Jest API compatibility, native TypeScript and ESM support.

Vitest - Complete guide to testing with Vite

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 blazingly fast - often 10x faster than Jest. 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
Coveragec8 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": "^2.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'
    poolOptions: {
      threads: {
        singleThread: false,
      },
    },

    // 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 allow dropping below thresholds
      thresholdAutoUpdate: false,

      // All files, even untested ones
      all: true,
    },
  },
})

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
npm install -D @vitest/browser webdriverio
TSvitest.config.ts
TypeScript
// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    browser: {
      enabled: true,
      provider: 'webdriverio',
      name: 'chrome',
    },
  },
})

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

Automatic migration

Code
Bash
npx vitest-migrate

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
// ❌ Testing implementation
it('sets state to true', () => {
  const { result } = renderHook(() => useToggle())
  expect(result.current[0]).toBe(false)
})

// ✅ Testing behavior
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)

// ❌ Avoid
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 --single-thread" 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

Summary

Vitest is a modern testing framework that offers:

  • Blazing speed - Thanks to integration with Vite
  • Zero configuration - TypeScript, ESM, JSX out-of-box
  • Jest compatibility - Easy migration
  • Great DX - Watch mode, UI, debugging
  • Flexibility - From unit tests to browser testing

If you use Vite, Vitest is the obvious choice. Even in projects without Vite, it is worth considering Vitest for its speed and modern approach to testing.