React Testing with Vitest and React Testing Library — Complete Guide

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Introduction

Why This Matters

Tests prevent regressions and give you confidence to refactor. The testing pyramid for React: unit tests for pure functions, component tests with React Testing Library for user-facing behavior, and E2E tests (Playwright or Cypress) for critical user flows. React Testing Library enforces testing behavior, not implementation — making tests resilient to refactoring.

Setup

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

Your First Component Test

// components/badge.tsx
type BadgeProps = { count: number; max?: number }
 
export function Badge({ count, max = 99 }: BadgeProps) {
  const display = count > max ? `${max}+` : count
  return (
    <span data-testid="badge" className="bg-red-500 text-white text-xs rounded-full px-2 py-0.5">
      {display}
    </span>
  )
}
// components/badge.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { Badge } from './badge'
 
describe('Badge', () => {
  it('displays the count when below max', () => {
    render(<Badge count={5} />)
    expect(screen.getByTestId('badge')).toHaveTextContent('5')
  })
 
  it('displays max+ when count exceeds max', () => {
    render(<Badge count={150} max={99} />)
    expect(screen.getByTestId('badge')).toHaveTextContent('99+')
  })
 
  it('uses 99 as default max', () => {
    render(<Badge count={100} />)
    expect(screen.getByTestId('badge')).toHaveTextContent('99+')
  })
})

Testing User Interactions

Always use @testing-library/user-event for user interactions instead of fireEvent — it simulates real browser behavior including focus, keyboard events, and pointer interactions.

// components/counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect } from 'vitest'
import { Counter } from './counter'
 
describe('Counter', () => {
  it('increments count on button click', async () => {
    const user = userEvent.setup()
    render(<Counter initialCount={0} />)
 
    const incrementBtn = screen.getByRole('button', { name: /increment/i })
    await user.click(incrementBtn)
    await user.click(incrementBtn)
 
    expect(screen.getByText('2')).toBeInTheDocument()
  })
 
  it('cannot go below zero when min is 0', async () => {
    const user = userEvent.setup()
    render(<Counter initialCount={0} min={0} />)
 
    const decrementBtn = screen.getByRole('button', { name: /decrement/i })
    await user.click(decrementBtn)
 
    expect(screen.getByText('0')).toBeInTheDocument()
  })
})

Testing Forms with Mocked Actions

// components/login-form.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { LoginForm } from './login-form'
 
describe('LoginForm', () => {
  it('calls onSubmit with email and password', async () => {
    const user = userEvent.setup()
    const mockSubmit = vi.fn().mockResolvedValue({ success: true })
    render(<LoginForm onSubmit={mockSubmit} />)
 
    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: /sign in/i }))
 
    expect(mockSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123'
    })
  })
 
  it('shows error message on failed login', async () => {
    const user = userEvent.setup()
    const mockSubmit = vi.fn().mockResolvedValue({ error: 'Invalid credentials' })
    render(<LoginForm onSubmit={mockSubmit} />)
 
    await user.type(screen.getByLabelText(/email/i), 'bad@example.com')
    await user.type(screen.getByLabelText(/password/i), 'wrongpass')
    await user.click(screen.getByRole('button', { name: /sign in/i }))
 
    expect(await screen.findByText(/invalid credentials/i)).toBeInTheDocument()
  })
})

Mocking fetch and API Calls

// components/user-profile.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { UserProfile } from './user-profile'
 
describe('UserProfile', () => {
  beforeEach(() => {
    vi.restoreAllMocks()
  })
 
  it('renders user data after loading', async () => {
    vi.spyOn(global, 'fetch').mockResolvedValue({
      ok: true,
      json: async () => ({ id: '1', name: 'Alice Johnson', email: 'alice@example.com' })
    } as Response)
 
    render(<UserProfile userId="1" />)
 
    expect(screen.getByText(/loading/i)).toBeInTheDocument()
 
    await waitFor(() => {
      expect(screen.getByText('Alice Johnson')).toBeInTheDocument()
    })
    expect(screen.getByText('alice@example.com')).toBeInTheDocument()
  })
 
  it('shows error state when fetch fails', async () => {
    vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'))
 
    render(<UserProfile userId="1" />)
 
    await waitFor(() => {
      expect(screen.getByText(/something went wrong/i)).toBeInTheDocument()
    })
  })
})

Common Mistakes

  • Testing implementation details (internal state, method calls) instead of user-visible behavior
  • Using getByTestId everywhere — prefer getByRole, getByLabelText, and getByText which reflect accessibility
  • Not awaiting async assertions — use findBy* queries or waitFor for async state updates
  • Testing the entire component tree when a unit test of a small component is sufficient

Best Practices

  • Write tests that resemble how users actually use your app — click buttons, type in inputs, read text
  • Use getByRole with semantic roles for accessible test selectors: getByRole('button', { name: /submit/i })
  • Co-locate test files next to their source files: button.tsx and button.test.tsx in the same folder
  • Use MSW (Mock Service Worker) for integration tests that cover multiple components communicating via API

Key Takeaways

  • React Testing Library enforces testing behavior, not implementation — tests survive refactoring
  • @testing-library/user-event simulates real browser interactions more accurately than fireEvent
  • getByRole is the preferred query because it tests accessibility alongside functionality
  • Use findBy* for elements that appear asynchronously — it retries until the element appears or times out
  • vi.spyOn(global, 'fetch') intercepts fetch calls in Vitest; restore with vi.restoreAllMocks() in beforeEach
  • Aim for high coverage of critical user flows, not 100% line coverage of every component
  • Co-locate test files with source files to make them easy to find and maintain
  • MSW is the industry standard for mocking API responses in integration and E2E tests

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading