Testing React Components with Vitest: A Practical Walkthrough


I put off testing for way too long when I was learning web development. Not because I didn’t know it was important - every senior dev I talked to said “write tests” - but because the tooling felt heavy and the tutorials always showed trivial examples that didn’t reflect real components.

Then I tried Vitest and things clicked. It’s fast. Configuration is minimal if you’re already using Vite. And the developer experience - watch mode, inline snapshots, the UI dashboard - is genuinely enjoyable in a way Jest never was for me.

Here’s a practical walkthrough of testing real React components, not just sum(1, 2).

Setup

If you’re in a Vite-based React project, setup is minimal:

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

Add a test configuration to your vite.config.ts:

/// <reference types="vitest" />
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,
  },
})

Create the setup file:

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

Add a test script to package.json:

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

That’s it. No babel configuration. No jest.config.js. No transform mappings. If you’ve ever set up Jest in a non-CRA project, you’ll appreciate how much simpler this is.

Testing a Basic Component

Let’s start with something realistic. Here’s a SearchInput component:

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

interface SearchInputProps {
  onSearch: (query: string) => void
  placeholder?: string
}

export function SearchInput({ onSearch, placeholder = "Search..." }: SearchInputProps) {
  const [query, setQuery] = useState('')

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    if (query.trim()) {
      onSearch(query.trim())
    }
  }

  return (
    <form onSubmit={handleSubmit} role="search">
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder={placeholder}
        aria-label="Search"
      />
      <button type="submit">Search</button>
    </form>
  )
}

The test:

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

describe('SearchInput', () => {
  it('renders with default placeholder', () => {
    render(<SearchInput onSearch={vi.fn()} />)
    expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument()
  })

  it('renders with custom placeholder', () => {
    render(<SearchInput onSearch={vi.fn()} placeholder="Find articles..." />)
    expect(screen.getByPlaceholderText('Find articles...')).toBeInTheDocument()
  })

  it('calls onSearch with trimmed query on submit', async () => {
    const user = userEvent.setup()
    const onSearch = vi.fn()
    render(<SearchInput onSearch={onSearch} />)

    await user.type(screen.getByRole('textbox'), '  hello world  ')
    await user.click(screen.getByRole('button', { name: 'Search' }))

    expect(onSearch).toHaveBeenCalledWith('hello world')
  })

  it('does not call onSearch with empty query', async () => {
    const user = userEvent.setup()
    const onSearch = vi.fn()
    render(<SearchInput onSearch={onSearch} />)

    await user.click(screen.getByRole('button', { name: 'Search' }))

    expect(onSearch).not.toHaveBeenCalled()
  })

  it('does not call onSearch with whitespace-only query', async () => {
    const user = userEvent.setup()
    const onSearch = vi.fn()
    render(<SearchInput onSearch={onSearch} />)

    await user.type(screen.getByRole('textbox'), '   ')
    await user.click(screen.getByRole('button', { name: 'Search' }))

    expect(onSearch).not.toHaveBeenCalled()
  })
})

Note: vi.fn() is Vitest’s equivalent of jest.fn(). If you’ve set globals: true in the config, you can use it without importing. I prefer explicit imports for clarity.

Testing Async Components

Real components fetch data. Here’s a component that loads a user profile:

// src/components/UserProfile.tsx
import { useState, useEffect } from 'react'

interface User {
  name: string
  email: string
  bio: string
}

export function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    async function fetchUser() {
      try {
        const response = await fetch(`/api/users/${userId}`)
        if (!response.ok) throw new Error('Failed to load user')
        const data = await response.json()
        setUser(data)
      } catch (err) {
        setError(err instanceof Error ? err.message : 'Unknown error')
      } finally {
        setLoading(false)
      }
    }
    fetchUser()
  }, [userId])

  if (loading) return <div role="status">Loading profile...</div>
  if (error) return <div role="alert">{error}</div>
  if (!user) return null

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <p>{user.bio}</p>
    </div>
  )
}

Testing this requires mocking the fetch call:

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

const mockUser = {
  name: 'Murtaza Khan',
  email: '[email protected]',
  bio: 'Self-taught developer',
}

beforeEach(() => {
  vi.restoreAllMocks()
})

describe('UserProfile', () => {
  it('shows loading state initially', () => {
    vi.spyOn(global, 'fetch').mockImplementation(
      () => new Promise(() => {}) // Never resolves
    )
    render(<UserProfile userId="123" />)
    expect(screen.getByText('Loading profile...')).toBeInTheDocument()
  })

  it('renders user data after loading', async () => {
    vi.spyOn(global, 'fetch').mockResolvedValue({
      ok: true,
      json: async () => mockUser,
    } as Response)

    render(<UserProfile userId="123" />)

    await waitFor(() => {
      expect(screen.getByText('Murtaza Khan')).toBeInTheDocument()
    })
    expect(screen.getByText('[email protected]')).toBeInTheDocument()
    expect(screen.getByText('Self-taught developer')).toBeInTheDocument()
  })

  it('shows error on failed fetch', async () => {
    vi.spyOn(global, 'fetch').mockResolvedValue({
      ok: false,
      status: 500,
    } as Response)

    render(<UserProfile userId="123" />)

    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('Failed to load user')
    })
  })
})

What I Test and What I Don’t

After writing tests for a year, I’ve developed opinions about what’s worth testing in component tests.

Worth testing:

  • User interactions (clicks, form submissions, keyboard events)
  • Conditional rendering (does the error state show? does the empty state appear?)
  • Callback props (does clicking the button call the right function with the right arguments?)
  • Accessibility (are the right ARIA roles and labels present?)

Not worth testing in component tests:

  • CSS styling (use visual regression tests or manual review)
  • Implementation details (internal state values, which hooks are called)
  • Third-party library behaviour (if you’re testing that useState works, you’re testing React, not your code)

Vitest Watch Mode

Run npm test and Vitest starts in watch mode by default. It re-runs relevant tests when files change. The performance difference compared to Jest is noticeable - Vitest uses Vite’s transform pipeline, so hot module replacement applies to tests too. Re-runs are nearly instant.

The --ui flag opens a browser-based dashboard showing test results, coverage, and file trees. I find it useful for exploring test failures, especially when working on unfamiliar code.

Tips

Use userEvent over fireEvent. The @testing-library/user-event library simulates real user interactions more accurately than fireEvent. It handles focus, keyboard events, and text input the way a real user would. The API is slightly different (await user.click() instead of fireEvent.click()) but worth the switch.

Test the component’s contract, not its implementation. Don’t test that state was set to a specific value. Test that the UI changed in response to user action. This makes your tests resilient to refactoring.

Keep test files next to the components they test. SearchInput.tsx and SearchInput.test.tsx in the same directory. It’s easier to find tests, easier to see which components have tests, and easier to maintain.

Don’t aim for 100% coverage. Aim for confidence. Test the parts that are most likely to break and most important to get right. 80% coverage with meaningful tests beats 100% coverage with tests that just exercise code without asserting anything useful.

Vitest made testing enjoyable for me. If you’re in a Vite project and haven’t tried it yet, give it 30 minutes. That’s enough time to set it up and write your first real test.