EmpireUI
Get Pro
← Blog7 min read#vitest#react-testing#unit-testing

Testing React with Vitest: Fast Unit Tests for Component Logic

Vitest runs your React component tests blazing fast — here's how to set it up, write meaningful unit tests, and avoid the traps that slow teams down.

Developer writing unit tests on a laptop with a code editor open showing test output

Why Vitest Is Replacing Jest for React Projects

Honestly, if you're still running Jest on a Vite-based React project in 2026, you're leaving a lot of speed on the table. Vitest 2.x integrates directly with your existing Vite config — same transforms, same aliases, same environment. No separate Babel pipeline to maintain.

Jest isn't broken. But it was built for a CommonJS world, and shimming it to work with ESM modules and modern TypeScript is friction you just don't need anymore. Vitest 2.1.4 ships with native ESM support, first-class TypeScript, and a watch mode that is genuinely fast — we're talking sub-100ms reruns on most component test suites.

The migration story is also surprisingly painless. Vitest's API is intentionally Jest-compatible: describe, it, expect, beforeEach — all the same. You swap the import source and adjust the config. That's mostly it.

Setting Up Vitest in a React + Vite Project

Install the core packages first. You'll need Vitest itself, the jsdom environment for simulating the browser, and React Testing Library.

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

Then open your vite.config.ts and add the test block. You don't need a separate config file — this lives right alongside your build config:

// 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'],
    css: true, // parse CSS imports in tests
  },
})

Create your setup file at src/test/setup.ts and import the jest-dom matchers. This gives you assertions like toBeInTheDocument() and toHaveClass() which make test output readable.

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

Writing Your First Component Unit Test

Let's test something real — a Button component that accepts a variant prop and calls an onClick handler. This is the bread-and-butter of UI component testing and where Vitest shines.

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

describe('Button', () => {
  it('renders with the correct label', () => {
    render(<Button variant="primary">Save changes</Button>)
    expect(screen.getByRole('button', { name: 'Save changes' })).toBeInTheDocument()
  })

  it('calls onClick when clicked', async () => {
    const user = userEvent.setup()
    const handleClick = vi.fn()

    render(<Button variant="primary" onClick={handleClick}>Save changes</Button>)
    await user.click(screen.getByRole('button'))

    expect(handleClick).toHaveBeenCalledOnce()
  })

  it('applies disabled styles when disabled prop is set', () => {
    render(<Button variant="primary" disabled>Save changes</Button>)
    expect(screen.getByRole('button')).toBeDisabled()
  })
})

Notice we're using userEvent.setup() instead of fireEvent. This matters because userEvent simulates real browser interactions — pointer events, focus, blur — rather than dispatching synthetic events. If you're only using fireEvent, you're probably missing bugs.

If you're working with toast notifications in React, this same pattern applies: render the trigger, fire the user action, assert the toast appears in the DOM.

Testing Hooks with renderHook

Custom hooks are where a lot of logic actually lives — especially in component libraries like Empire UI. Testing them in isolation is faster and less fragile than testing them through a full component tree.

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

describe('useTheme', () => {
  it('returns dark as default theme', () => {
    const { result } = renderHook(() => useTheme())
    expect(result.current.theme).toBe('dark')
  })

  it('toggles theme on toggle() call', () => {
    const { result } = renderHook(() => useTheme())

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

    expect(result.current.theme).toBe('light')
  })
})

The act() wrapper is required whenever your hook causes state updates. Skipping it gives you a warning in the console and potentially flaky tests that pass locally but fail in CI. Don't skip it.

This is also relevant if you're building a theme toggle in React — the hook logic (reading from localStorage, applying the class to document.documentElement) is trivially testable with renderHook plus a mocked localStorage.

Mocking Modules and API Calls in Vitest

Most real components talk to the outside world — they fetch data, call analytics, import utilities. Vitest's vi.mock() handles this cleanly. The key difference from Jest: you call vi.mock() at the top level, not inside beforeEach.

// src/components/UserCard.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'

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

import { fetchUser } from '../api/users'
import { UserCard } from './UserCard'

describe('UserCard', () => {
  beforeEach(() => {
    vi.mocked(fetchUser).mockResolvedValue({
      id: '42',
      name: 'Lena Hoffmann',
      role: 'admin',
    })
  })

  it('displays user name after loading', async () => {
    render(<UserCard userId="42" />)
    await waitFor(() => {
      expect(screen.getByText('Lena Hoffmann')).toBeInTheDocument()
    })
  })
})

One thing that trips people up: vi.mocked() is just a TypeScript helper that casts the mock to the right type. It doesn't change runtime behavior. If your mock isn't resolving as expected, double-check you're calling mockResolvedValue and not mockReturnValue for async functions.

What about global browser APIs like fetch or localStorage? Vitest running in jsdom gives you a localStorage shim out of the box. For fetch, add global.fetch = vi.fn() in your setup file or use a library like msw for proper HTTP interception.

Snapshot Testing: When to Use It and When to Skip It

Snapshot tests get a bad reputation — and honestly, most of it is deserved. Blindly snapshotting an entire component tree creates a maintenance tax with very little safety benefit. Every style tweak, every copy change, breaks the snapshot.

That said, snapshots do have a place: serializing small, stable outputs. If you have a function that generates a Tailwind class string based on props, or a utility that transforms an object to CSS variables, a snapshot is a perfectly reasonable assertion.

// Good use of snapshots — stable, small output
it('generates correct class string for glassmorphism variant', () => {
  const classes = buildVariantClasses('glassmorphism', { blur: 12, opacity: 0.15 })
  // output: 'backdrop-blur-xl bg-white/15 border border-white/20'
  expect(classes).toMatchInlineSnapshot(`"backdrop-blur-xl bg-white/15 border border-white/20"`)
})

Use inline snapshots (toMatchInlineSnapshot) instead of file snapshots whenever possible. The value lives right next to the assertion, reviewers can see exactly what changed in a diff, and you don't end up with a __snapshots__ folder full of stale files nobody maintains. If you're curious how glassmorphism styles get generated at the CSS level, what is glassmorphism covers the math behind backdrop-filter and rgba(255,255,255,0.15) values.

Running Tests Fast: Coverage, Watch Mode, and CI Setup

Vitest's watch mode is genuinely pleasant to use. Run npx vitest and it starts watching, filtering to only the files related to your current changes using Vite's module graph. No more waiting for 300 tests when you changed one component.

For coverage, Vitest 2.x uses V8 coverage by default — no extra dependencies needed. Add this to your vite.config.ts:

test: {
  coverage: {
    provider: 'v8',
    reporter: ['text', 'lcov', 'html'],
    exclude: ['src/test/**', '**/*.d.ts', 'src/main.tsx'],
    thresholds: {
      lines: 80,
      functions: 80,
    },
  },
}

In CI, run vitest run --coverage — the run flag disables watch mode and exits after a single pass. If you're optimizing a bigger React app beyond just tests, the React performance guide covers bundle splitting and runtime optimization that complements a solid testing setup.

Common Mistakes That Make Tests Flaky or Useless

Testing implementation details instead of behavior is the number one mistake. If your test asserts that a component's internal state is true after clicking, you're testing how it works, not what it does. When you refactor — even without changing behavior — the test breaks. Write assertions from the user's perspective: what appears on screen, what gets called, what's in the DOM.

The second mistake is overusing waitFor. It's there for async operations, not as a sleep() call to paper over timing issues. If you find yourself wrapping synchronous assertions in waitFor, something else is wrong — usually you forgot await user.click() or there's an unresolved promise leaking between tests.

Finally: don't forget to clean up. If your component sets up event listeners, timers, or observers, make sure they're torn down. Vitest runs tests in the same process by default, and leaked timers from one test will corrupt timing assertions in the next. The afterEach(() => vi.clearAllTimers()) pattern is your friend. Also isolate tests using vi.isolateModules() when you need truly fresh module state between test cases.

FAQ

Can I use Vitest with Create React App instead of Vite?

Technically yes, but it's awkward. Vitest is designed to use your existing Vite config. If you're on CRA, you'd need to add a separate vite.config.ts just for tests. At that point you're maintaining two build tool configs. Most teams on CRA who want Vitest migrate to Vite first — it's usually a 2-3 hour job and worth it.

What's the difference between vi.fn() and vi.spyOn()?

vi.fn() creates a standalone mock function from scratch. vi.spyOn() wraps an existing method on an object, letting you assert on calls while keeping (or replacing) the original implementation. Use vi.fn() for injected dependencies and vi.spyOn() when you need to intercept a method on an imported module or class.

Do I need @testing-library/user-event or can I just use fireEvent?

You can use fireEvent, but user-event 14.x is more accurate. It simulates the full event sequence a real user would trigger — pointerdown, mousedown, click, pointerup, etc. fireEvent dispatches a single synthetic event. For simple click tests the difference is minor, but for drag-and-drop, keyboard navigation, or input fields it matters a lot.

How do I test a component that uses React Context?

Wrap the component in the context provider inside your render call. Create a helper like renderWithProviders() that wraps render() with all your providers — theme context, auth context, query client, whatever. Put it in src/test/utils.tsx and import from there instead of @testing-library/react. Testing Library's documentation has a good example of this pattern.

Vitest says my test file can't find the module, but it works in the app — why?

Almost always a path alias issue. Your tsconfig.json might have @/ mapped to src/, but if you haven't added the same alias to your vite.config.ts test config (or if Vitest isn't picking up the main config), it can't resolve it. Add resolve.alias to your vite.config.ts and make sure it matches what's in tsconfig.json compilerOptions.paths.

What's a realistic coverage target for a component library?

80% line/function coverage is a solid floor, but coverage numbers alone are misleading. A component with 95% coverage can still have critical bugs if the assertions are shallow. Focus on covering the branching logic — disabled states, error states, empty states — rather than chasing a percentage. Aim for 80% with meaningful assertions over 100% with trivial ones.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Vitest + React Testing Library: Full Setup From ZeroReact Testing in 2026: Vitest, Testing Library and What to Actually TestComponent Testing in 2027: Visual, Unit, Interaction ComparedStorybook Interaction Testing: Click, Type, Assert in Stories