Vitest with React: Unit Testing Guide with Examples (2026)

Vitest with React: Unit Testing Guide with Examples (2026)

Vitest is a Vite-native test runner that replaces Jest for React projects. Set up with npm install -D vitest @testing-library/react @testing-library/user-event jsdom. Configure vitest.config.ts with environment: 'jsdom' and globals: true. Then write tests with render(), screen, and userEvent from React Testing Library — the API is nearly identical to Jest+RTL.

Key Takeaways

Vitest is a drop-in Jest replacement with much faster startup. If you're using Jest with React Testing Library, migrating to Vitest requires minimal changes — same test syntax, same assertions, same RTL API.

Set environment: 'jsdom' in vitest.config.ts. React components need a browser-like DOM environment. Without this, document and window are undefined.

Use @testing-library/react with Vitest. React Testing Library works identically whether your test runner is Jest or Vitest. render(), screen, fireEvent, waitFor all work the same.

@testing-library/user-event is more realistic than fireEvent. userEvent.click() simulates real browser events (mousedown, mouseup, click). Use userEvent.setup() for async interactions.

Vitest's vi object replaces Jest's jest object. vi.fn(), vi.spyOn(), vi.mock(), vi.useFakeTimers() — same patterns, different namespace.

Installation

# With Vite project (recommended)
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom

<span class="hljs-comment"># Or with Yarn
yarn add -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom

Configuration

vitest.config.ts

import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',          // Browser-like DOM
    globals: true,                  // No need to import describe/it/expect
    setupFiles: './src/test-setup.ts',  // Global test setup
    css: true,                      // Parse CSS (for CSS Modules)
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
    },
  },
})

Or extend vite.config.ts

If you have an existing vite.config.ts:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

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

test-setup.ts

// src/test-setup.ts
import '@testing-library/jest-dom'
// Adds custom matchers: toBeInTheDocument, toHaveTextContent, etc.

package.json scripts

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui"
  }
}
  • vitest — watch mode (re-runs on file change)
  • vitest run — single run (for CI)
  • vitest --ui — opens browser-based test UI

Writing Your First React Component Test

Simple Component

// src/components/Button.tsx
interface ButtonProps {
  label: string
  onClick: () => void
  disabled?: boolean
}

export function Button({ label, onClick, disabled = false }: ButtonProps) {
  return (
    <button onClick={onClick} disabled={disabled} className="btn">
      {label}
    </button>
  )
}
// src/components/Button.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from './Button'

describe('Button', () => {
  it('renders with label', () => {
    render(<Button label="Save" onClick={vi.fn()} />)
    expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
  })

  it('calls onClick when clicked', async () => {
    const handleClick = vi.fn()
    render(<Button label="Save" onClick={handleClick} />)

    await userEvent.click(screen.getByRole('button'))
    expect(handleClick).toHaveBeenCalledOnce()
  })

  it('does not call onClick when disabled', async () => {
    const handleClick = vi.fn()
    render(<Button label="Save" onClick={handleClick} disabled />)

    await userEvent.click(screen.getByRole('button'))
    expect(handleClick).not.toHaveBeenCalled()
  })
})

React Testing Library API

render() and screen

import { render, screen } from '@testing-library/react'

// render() mounts the component
const { container, rerender, unmount } = render(<MyComponent prop="value" />)

// screen exposes query methods
screen.getByText('Hello World')           // Throws if not found
screen.queryByText('Hello World')         // Returns null if not found
screen.findByText('Hello World')          // Returns Promise (waits for element)
screen.getAllByRole('button')             // Returns array, throws if empty
screen.queryAllByText('item')             // Returns array, empty if none
screen.findAllByRole('listitem')          // Returns Promise array

Query Priority (RTL Recommendation)

Use queries in this order (most to least accessible):

// 1. Role (best — matches what users/screen readers see)
screen.getByRole('button', { name: 'Submit' })
screen.getByRole('textbox', { name: 'Email' })
screen.getByRole('checkbox', { name: 'Remember me' })

// 2. Label text
screen.getByLabelText('Email address')

// 3. Placeholder
screen.getByPlaceholderText('Enter email')

// 4. Text content
screen.getByText('Welcome back')

// 5. Display value (for select/input)
screen.getByDisplayValue('Option 1')

// 6. Alt text (images)
screen.getByAltText('User profile photo')

// 7. Title
screen.getByTitle('Close dialog')

// 8. Test ID (last resort)
screen.getByTestId('submit-button')

userEvent vs fireEvent

import userEvent from '@testing-library/user-event'
import { fireEvent } from '@testing-library/react'

// ✅ userEvent — simulates real browser interaction
const user = userEvent.setup()

await user.click(button)
await user.type(input, 'hello@example.com')
await user.keyboard('{Enter}')
await user.selectOptions(select, 'Option 2')
await user.clear(input)
await user.tab() // Tab to next element

// ⚠️ fireEvent — fires DOM events directly (less realistic)
fireEvent.click(button)
fireEvent.change(input, { target: { value: 'text' } })

Use userEvent for realistic interaction. Use fireEvent only when userEvent isn't available for a specific event type.

Form Testing

// src/components/LoginForm.tsx
export function LoginForm({ onSubmit }: { onSubmit: (data: { email: string; password: string }) => void }) {
  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
    }
    onSubmit({ email, password })
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Email
        <input type="email" value={email} onChange={e => setEmail(e.target.value)} />
      </label>
      <label>
        Password
        <input type="password" value={password} onChange={e => setPassword(e.target.value)} />
      </label>
      {error && <div role="alert">{error}</div>}
      <button type="submit">Sign In</button>
    </form>
  )
}
// src/components/LoginForm.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'

describe('LoginForm', () => {
  it('submits with valid credentials', async () => {
    const handleSubmit = vi.fn()
    render(<LoginForm onSubmit={handleSubmit} />)

    await userEvent.type(screen.getByLabelText('Email'), 'user@example.com')
    await userEvent.type(screen.getByLabelText('Password'), 'password123')
    await userEvent.click(screen.getByRole('button', { name: 'Sign In' }))

    expect(handleSubmit).toHaveBeenCalledWith({
      email: 'user@example.com',
      password: 'password123',
    })
  })

  it('shows error when fields are empty', async () => {
    render(<LoginForm onSubmit={vi.fn()} />)

    await userEvent.click(screen.getByRole('button', { name: 'Sign In' }))

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

  it('does not submit when fields are empty', async () => {
    const handleSubmit = vi.fn()
    render(<LoginForm onSubmit={handleSubmit} />)

    await userEvent.click(screen.getByRole('button', { name: 'Sign In' }))

    expect(handleSubmit).not.toHaveBeenCalled()
  })
})

Async Components and API Calls

// src/components/UserProfile.tsx
export function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    fetchUser(userId).then(data => {
      setUser(data)
      setLoading(false)
    })
  }, [userId])

  if (loading) return <div>Loading...</div>
  if (!user) return <div>User not found</div>

  return <div><h1>{user.name}</h1><p>{user.email}</p></div>
}
// src/components/UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { UserProfile } from './UserProfile'
import { fetchUser } from '../api/users'

vi.mock('../api/users')

const mockFetchUser = vi.mocked(fetchUser)

describe('UserProfile', () => {
  it('shows loading state initially', () => {
    mockFetchUser.mockReturnValue(new Promise(() => {})) // Never resolves
    render(<UserProfile userId="123" />)
    expect(screen.getByText('Loading...')).toBeInTheDocument()
  })

  it('displays user data after fetch', async () => {
    mockFetchUser.mockResolvedValue({ id: '123', name: 'Jane Doe', email: 'jane@example.com' })
    render(<UserProfile userId="123" />)

    await waitFor(() => {
      expect(screen.getByText('Jane Doe')).toBeInTheDocument()
    })
    expect(screen.getByText('jane@example.com')).toBeInTheDocument()
  })

  it('shows not found when fetch fails', async () => {
    mockFetchUser.mockRejectedValue(new Error('Not found'))
    render(<UserProfile userId="999" />)

    await waitFor(() => {
      expect(screen.getByText('User not found')).toBeInTheDocument()
    })
  })
})

Mocking in Vitest

Mock Functions

const fn = vi.fn()
fn('arg1', 'arg2')

expect(fn).toHaveBeenCalledOnce()
expect(fn).toHaveBeenCalledWith('arg1', 'arg2')
expect(fn).toHaveReturnedWith(undefined)

// With return value
const add = vi.fn().mockReturnValue(42)
const asyncFn = vi.fn().mockResolvedValue({ data: 'result' })
const errorFn = vi.fn().mockRejectedValue(new Error('Failed'))

// Different return on each call
vi.fn()
  .mockReturnValueOnce('first')
  .mockReturnValueOnce('second')
  .mockReturnValue('default')

Module Mocking

// Mock entire module
vi.mock('../api/users', () => ({
  fetchUser: vi.fn().mockResolvedValue({ id: '1', name: 'Test User' }),
  updateUser: vi.fn().mockResolvedValue({ success: true }),
}))

// Auto-mock (vitest creates automatic mocks)
vi.mock('../api/users')
const { fetchUser } = await import('../api/users')
vi.mocked(fetchUser).mockResolvedValue({ id: '1', name: 'Test User' })

// Partial mock
vi.mock('../utils/format', async (importOriginal) => {
  const original = await importOriginal<typeof import('../utils/format')>()
  return {
    ...original,
    formatDate: vi.fn().mockReturnValue('Jan 1, 2026'),
  }
})

Spy on Methods

const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

// Test code that calls console.error
renderWithError()

expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Error:'))
consoleSpy.mockRestore()

Fake Timers

it('shows timeout message after 5 seconds', async () => {
  vi.useFakeTimers()

  render(<PaymentProcessor />)
  expect(screen.queryByText('Taking longer than expected')).not.toBeInTheDocument()

  vi.advanceTimersByTime(5000)

  await waitFor(() => {
    expect(screen.getByText('Taking longer than expected')).toBeInTheDocument()
  })

  vi.useRealTimers()
})

Testing Custom Hooks

// src/hooks/useCounter.ts
export function useCounter(initial = 0) {
  const [count, setCount] = useState(initial)
  return {
    count,
    increment: () => setCount(c => c + 1),
    decrement: () => setCount(c => c - 1),
    reset: () => setCount(initial),
  }
}
// src/hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('initializes with 0 by default', () => {
    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 count', () => {
    const { result } = renderHook(() => useCounter())
    act(() => result.current.increment())
    expect(result.current.count).toBe(1)
  })

  it('decrements count', () => {
    const { result } = renderHook(() => useCounter(5))
    act(() => result.current.decrement())
    expect(result.current.count).toBe(4)
  })

  it('resets to initial value', () => {
    const { result } = renderHook(() => useCounter(5))
    act(() => result.current.increment())
    act(() => result.current.reset())
    expect(result.current.count).toBe(5)
  })
})

Rendering with Providers

When components need React Router, Redux, or other context providers:

// src/test-utils.tsx
import { render as rtlRender, RenderOptions } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { Provider } from 'react-redux'
import { createStore } from './store'

function AllProviders({ children }: { children: React.ReactNode }) {
  return (
    <Provider store={createStore()}>
      <MemoryRouter>
        {children}
      </MemoryRouter>
    </Provider>
  )
}

export function render(ui: React.ReactElement, options?: RenderOptions) {
  return rtlRender(ui, { wrapper: AllProviders, ...options })
}

export * from '@testing-library/react'
// Use custom render in tests
import { render, screen } from '../test-utils' // Not from @testing-library/react

it('shows dashboard for logged-in user', async () => {
  render(<Dashboard />)
  expect(screen.getByText('Welcome')).toBeInTheDocument()
})

Snapshot Testing

import { render } from '@testing-library/react'
import { Button } from './Button'

it('matches snapshot', () => {
  const { container } = render(<Button label="Submit" onClick={vi.fn()} />)
  expect(container.firstChild).toMatchSnapshot()
})

Update snapshots when component changes intentionally:

vitest run --update-snapshots

CI Configuration

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run test:run
      - run: npm run test:coverage

Vitest vs Jest for React

Feature Vitest Jest
Startup time ~100ms ~3s
Config file vitest.config.ts (Vite-based) jest.config.ts
Watch mode ✅ Fast HMR-based ✅ Slower polling
ESM support ✅ Native ⚠️ Requires transform
TypeScript ✅ No extra config ⚠️ Requires ts-jest or babel
Coverage @vitest/coverage-v8 jest-coverage
Mocking API vi.fn(), vi.mock() jest.fn(), jest.mock()
Browser testing ✅ Vitest Browser Mode ❌ Separate tool needed
Migration effort Low (same RTL API)

Migration from Jest

Most Jest tests work in Vitest with these changes:

// Replace jest.* with vi.*
jest.fn()          → vi.fn()
jest.spyOn()       → vi.spyOn()
jest.mock()        → vi.mock()
jest.clearAllMocks → vi.clearAllMocks
jest.useFakeTimers → vi.useFakeTimers

// Optional: enable globals so you don't need to import these
// jest: available globally by default
// vitest: set globals: true in config, then they work the same

What Vitest Doesn't Cover: E2E Testing

Vitest + React Testing Library tests components in isolation. They don't cover:

  • Real browser rendering (CSS, layout)
  • User flows across multiple pages
  • Third-party integrations (payment forms, OAuth)
  • Network conditions and loading states
  • Mobile touch interactions

For end-to-end coverage, combine Vitest with HelpMeTest:

# Vitest handles: unit tests, component tests
# HelpMeTest handles: full user journeys in real browsers

Test: User can complete checkout
  Go to https://myapp.com/products
  Add "Blue Widget" to cart
  Click Checkout
  Fill in shipping address
  Complete payment
  Verify order confirmation shows order number

HelpMeTest runs in real Chrome — no jsdom, no mocking, no Selenium setup.

Summary

Vitest + React Testing Library setup:

npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
// vitest.config.ts
export default defineConfig({
  test: { environment: 'jsdom', globals: true, setupFiles: './src/test-setup.ts' }
})
// src/test-setup.ts
import '@testing-library/jest-dom'
// Component test
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

it('submits form', async () => {
  render(<MyForm onSubmit={vi.fn()} />)
  await userEvent.type(screen.getByLabelText('Email'), 'test@example.com')
  await userEvent.click(screen.getByRole('button', { name: 'Submit' }))
  expect(onSubmit).toHaveBeenCalledWith({ email: 'test@example.com' })
})

Read more