React: Unit Testing (Vitest + React Testing Library)

Last updated: 2026-08-26

While refactoring an old component, Tom “accidentally” changed the internal state logic, causing UI issues on three pages that depended on that component. Because there were no unit tests, this issue wasn’t discovered until QA testing, wasting the entire team’s iteration time. Tom decided to introduce Vitest and React Testing Library to the project to use automated testing to ensure that every change wouldn’t break existing functionality.


1. What You'll Learn



2. Conceptual Diagrams

The following diagram illustrates the role and relationships of unit tests in React component development:

100%
flowchart LR
    A[Creating Components] --> B[Writing Tests]
    B --> C{Run Test}

    C -->|Through| D[Submit Code]
    C -->|Failure| E[Positioning Bug]

    E --> F{Error Type}
    F -->|Rendering Issues| G[getByText / getByRole]
    F -->|Interaction Issues| H[userEvent.click]
    F -->|Asynchronous Issues| I[findByText / waitFor]
    F -->|External Dependencies| J[vi.mock / vi.fn]

    G --> A
    H --> A
    I --> A
    J --> A

    style B fill:#e3f2fd,stroke:#1565c0
    style D fill:#e8f5e9,stroke:#2e7d32
    style E fill:#fff3e0,stroke:#e65100


3. A Real-Life Scenario

Tom's team has a UserCard component that displays user information. It accepts a user object and a onFollow callback, and changes the button style based on the isFollowing state.

During a refactoring, Tom changed the initial values of the internal state, causing isFollowing to default to true—as a result, all user cards were set to display "Following" by default. This bug wasn't discovered until the product manager's acceptance testing.

If tests had been in place at the time, such a regression issue would have been detected within seconds during the npm test phase. Tom decided to add unit tests for all core components.


(1) Environment Setup — Setting Up the Testing Infrastructure

First, install all dependencies:

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

Configure Vitest (add the test field to vite.config.ts):

TS
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    // Usage jsdom Simulate a browser environment
    environment: 'jsdom',
    // Global Registration describe / test / expect,No manual import required
    globals: true,
    // Configuration files that run before the test starts
    setupFiles: './src/test/setup.ts',
  },
})

Create a setup file:

TS
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
// This line allows toBeInTheDocument()、toHaveTextContent() Assertions are available

Add a test script to package.json:

JSON
{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest --coverage"
  }
}

(2) Three Core APIs

React Testing Library’s testing philosophy: Don’t test implementation details; test only what users can see and interact with.

API Purpose Features
render(component) Rendering Components to the Virtual DOM Return Container References and Helper Methods
screen Entry point for global element lookup Provides three types of methods: getBy, findBy, and queryBy
userEvent Simulate user actions More realistic than fireEvent

Core Principle: Use getByRole first (semantic), getByText second, and getByTestId last.

▶ Example 1: Testing the rendering and interactivity of the Counter component

Output:

TEXT 📖 Display only
Test: render(<Counter />) → screen.getByText("Count: 0") → fireEvent.click(button) → "Count: 1". All assertions pass

First, let's write a simple Counter component:

TSX
// Counter.tsx
import { useState } from 'react'

interface CounterProps {
  initialCount?: number
  step?: number
  label?: string
}

export function Counter({
  initialCount = 0,
  step = 1,
  label = 'Count',
}: CounterProps) {
  const [count, setCount] = useState(initialCount)

  return (
    <div>
      <p>
        {label}:{count}
      </p>
      <button onClick={() => setCount(c => c + step)}>+{step}</button>
      <button onClick={() => setCount(c => c - step)} disabled={count <= 0}>
        -{step}
      </button>
      {count >= 10 && (
        <p role="alert">Maximum Value Reached Alert</p>
      )}
    </div>
  )
}

Writing Tests:

TSX
// Counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from './Counter'

describe('Counter Components', () => {
  // Test 1:Initial Rendering
  test('Display the initial counter value', () => {
    render(<Counter initialCount={5} />)
    // getByText — Search by text content
    expect(screen.getByText('Count:5')).toBeInTheDocument()
  })

  // Test 2:Click the "Add" button
  test('Click +1 The count increases after the button is pressed', async () => {
    const user = userEvent.setup()
    render(<Counter initialCount={0} step={1} />)

    const incrementBtn = screen.getByRole('button', { name: '+1' })
    await user.click(incrementBtn)

    expect(screen.getByText('Count:1')).toBeInTheDocument()
  })

  // Test 3:The count is 0 "Time Decrease" Button Disabled
  test('The count is 0 "Time Decrease" Button Disabled', () => {
    render(<Counter initialCount={0} />)

    const decrementBtn = screen.getByRole('button', { name: '-1' })
    expect(decrementBtn).toBeDisabled()
  })

  // Test 4:Display a reminder when the threshold is reached
  test('Count reached 10 Display reminders', async () => {
    const user = userEvent.setup()
    render(<Counter initialCount={9} step={1} />)

    // No reminder at the beginning
    expect(screen.queryByRole('alert')).not.toBeInTheDocument()

    // Click to add 1
    await user.click(screen.getByRole('button', { name: '+1' }))

    // Now there's a reminder
    expect(screen.getByRole('alert')).toHaveTextContent('Maximum Value Reached Alert')
  })

  // Test 5:Custom label and  step
  test('Supports customization label and  step', async () => {
    const user = userEvent.setup()
    render(<Counter initialCount={0} step={5} label="Number of steps" />)

    expect(screen.getByText('Number of steps:0')).toBeInTheDocument()

    await user.click(screen.getByRole('button', { name: '+5' }))
    expect(screen.getByText('Number of steps:5')).toBeInTheDocument()
  })
})

This test demonstrates four modes:

  1. getByText — Find elements that contain the specified text (the simplest and most direct method)
  2. getByRole — Search by ARIA role; the name option provides an exact match for the button text
  3. queryByRole — Searches for an element that may not exist, returning null instead of throwing an exception
  4. toBeDisabled() / toHaveTextContent() — Semantic assertions provided by jest-dom

(3) Testing Props and Event Callbacks

Components typically receive data and callback functions through props. The goal of testing is to verify that the callbacks are called correctly and that the parameters are correct.

▶ Example 2: Testing the Props and Events of the TodoItem Component

Output:

TEXT 📖 Display only
Todo list: add items, toggle completion, delete items. Unit test: renders correctly, handles interactions, matches expected output
TSX
// TodoItem.tsx
interface Todo {
  id: number
  text: string
  completed: boolean
}

interface TodoItemProps {
  todo: Todo
  onToggle: (id: number) => void
  onDelete: (id: number) => void
}

export function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        gap: 12,
        padding: '8px 12px',
        background: todo.completed ? '#f6ffed' : '#fff',
        borderRadius: 6,
        border: '1px solid #f0f0f0',
      }}
    >
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
        aria-label={`Mark ${todo.text}`}
      />
      <span
        style={{
          flex: 1,
          textDecoration: todo.completed ? 'line-through' : 'none',
          color: todo.completed ? '#999' : '#333',
        }}
      >
        {todo.text}
      </span>
      <button
        onClick={() => onDelete(todo.id)}
        aria-label={`Delete ${todo.text}`}
        style={{
          border: 'none',
          background: '#ff4d4f',
          color: '#fff',
          borderRadius: 4,
          padding: '2px 8px',
          cursor: 'pointer',
          fontSize: 12,
        }}
      >
        Delete
      </button>
    </div>
  )
}

Output:

TEXT 📖 Display only
TypeScript props: interface ButtonProps { text: string; onClick: () => void; color?: string }. Editor auto-completes, compile-time errors on misuse.
TSX
// TodoItem.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TodoItem } from './TodoItem'

describe('TodoItem Components', () => {
  const mockTodo = {
    id: 42,
    text: 'Study React Test',
    completed: false,
  }

  test('Format the to-do list text', () => {
    render(
      <TodoItem
        todo={mockTodo}
        onToggle={vi.fn()}
        onDelete={vi.fn()}
      />
    )

    expect(screen.getByText('Study React Test')).toBeInTheDocument()
  })

  test('Completed items are displayed with a strikethrough', () => {
    render(
      <TodoItem
        todo={{ ...mockTodo, completed: true }}
        onToggle={vi.fn()}
        onDelete={vi.fn()}
      />
    )

    const text = screen.getByText('Study React Test')
    expect(text).toHaveStyle('text-decoration: line-through')
  })

  test('Triggered by clicking the checkbox onToggle', async () => {
    const onToggle = vi.fn()
    const user = userEvent.setup()

    render(
      <TodoItem
        todo={mockTodo}
        onToggle={onToggle}
        onDelete={vi.fn()}
      />
    )

    await user.click(screen.getByRole('checkbox'))
    expect(onToggle).toHaveBeenCalledTimes(1)
    expect(onToggle).toHaveBeenCalledWith(42)  // The validation parameters are todo.id
  })

  test('Triggered by clicking the Delete button onDelete', async () => {
    const onDelete = vi.fn()
    const user = userEvent.setup()

    render(
      <TodoItem
        todo={mockTodo}
        onToggle={vi.fn()}
        onDelete={onDelete}
      />
    )

    await user.click(screen.getByRole('button', { name: /Delete/ }))
    expect(onDelete).toHaveBeenCalledWith(42)
  })

  test('Unfinished items are not struck through', () => {
    render(
      <TodoItem
        todo={mockTodo}
        onToggle={vi.fn()}
        onDelete={vi.fn()}
      />
    )

    const text = screen.getByText('Study React Test')
    // Note:Inline styles text-decoration as  'none',rather than not having this property
    expect(text).not.toHaveStyle('text-decoration: line-through')
  })
})

Key Uses of the Mock Function vi.fn():

API Function
vi.fn() Create an empty mock function
toHaveBeenCalledTimes(n) Verified n times
toHaveBeenCalledWith(...) Verify the parameters during the call
vi.fn().mockResolvedValue(x) Mock asynchronous success response
vi.fn().mockRejectedValue(e) Mocking Asynchronous Failure Responses

(4) Testing Asynchronous Components

Many components first display "Loading..." while loading, and then display the content once the data arrives. You need to use findBy (asynchronous wait) to test these types of scenarios.

▶ Example 3: Testing the Asynchronous Data Loading Component

Output:

TEXT 📖 Display only
Displays "{user.name}". state: user, loading, error. uses useeffect
TSX
// UserProfile.tsx
interface UserProfileProps {
  userId: number
}

interface UserData {
  id: number
  name: string
  email: string
}

// Simulation API Call
async function fetchUser(id: number): Promise<UserData> {
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) throw new Error('Failed to load')
  return res.json()
}

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

  useEffect(() => {
    let cancelled = false

    async function load() {
      setLoading(true)
      setError(null)
      try {
        const data = await fetchUser(userId)
        if (!cancelled) setUser(data)
      } catch (err) {
        if (!cancelled) {
          setError(err instanceof Error ? err.message : 'Unknown error')
        }
      } finally {
        if (!cancelled) setLoading(false)
      }
    }

    load()
    return () => { cancelled = true }
  }, [userId])

  if (loading) return <div aria-label="Loading...">Loading......</div>
  if (error) return <div role="alert">Error:{error}</div>
  if (!user) return <div>No data</div>

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

Output:

TEXT 📖 Display only
Data fetching: ⏳ "Loading..." → ✅ data displayed → or ❌ "Error: Network request failed" with retry button
TSX
// UserProfile.test.tsx
import { render, screen } from '@testing-library/react'
import { UserProfile } from './UserProfile'

// Mock out fetchUser module
vi.mock('./UserProfile', async (importOriginal) => {
  const actual = await importOriginal()
  return {
    ...actual,
    // Rewrite fetchUser Implementation
    fetchUser: vi.fn(),
  }
})

// A Better Approach:Separately mock API Module
// vi.mock('../api', () => ({
//   fetchUser: vi.fn()
// }))

describe('UserProfile Asynchronous Components', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  test('Display "Loading" while loading', () => {
    // let  fetch All along pending
    vi.spyOn(global, 'fetch').mockImplementation(
      () => new Promise(() => {})  // Never resolve
    )

    render(<UserProfile userId={1} />)
    expect(screen.getByLabelText('Loading...')).toBeInTheDocument()
  })

  test('Display user information after successful loading', async () => {
    const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' }

    // Mock fetch Return successful data
    vi.spyOn(global, 'fetch').mockResolvedValue({
      ok: true,
      json: async () => mockUser,
    } as Response)

    render(<UserProfile userId={1} />)

    // findByText — Waiting Asynchronously for an Element to Appear(Default Timeout 1000ms)
    expect(await screen.findByText('Alice')).toBeInTheDocument()
    expect(screen.getByText('alice@example.com')).toBeInTheDocument()
  })

  test('Display an error message when loading fails', async () => {
    // Mock fetch Back 500
    vi.spyOn(global, 'fetch').mockResolvedValue({
      ok: false,
      status: 500,
      statusText: 'Internal Server Error',
    } as Response)

    render(<UserProfile userId={1} />)

    // Wait for an error message to appear
    expect(await screen.findByRole('alert')).toHaveTextContent('Error:')
  })

  test('The state is not updated when the component is unmounted(Preventing Memory Leaks)', async () => {
    const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' }
    let resolvePromise!: (value: any) => void

    vi.spyOn(global, 'fetch').mockReturnValue(
      new Promise((resolve) => {
        resolvePromise = resolve
      })
    )

    const { unmount } = render(<UserProfile userId={1} />)
    // in  fetch Uninstall Components Before Completion
    unmount()

    // At this moment resolve,But the component has been uninstalled,Probably not. setState
    resolvePromise({
      ok: true,
      json: async () => mockUser,
    } as Response)

    // I didn't make a mistake = Test Passed
  })
})

A Comparison of the Three Core Methods of Asynchronous Testing:

Method Synchronous/Asynchronous When the element does not exist Default timeout Use case
getByText Synchronous Throw an error immediately - The element must exist
queryByText Synchronize Back to null - Element does not exist
findByText Asynchronous (Promise) Throw an error after timeout 1000ms Wait for asynchronous rendering

(5) Best Practices for Mocking External Dependencies

In real-world projects, components often depend on APIs, routing, and state management. Mocking these dependencies is key to testing.

Method 1: Mock the global fetch

TS
// Replace the global variable before each test fetch
beforeEach(() => {
  vi.spyOn(global, 'fetch').mockResolvedValue({
    ok: true,
    json: async () => ({ data: 'mock' }),
  } as Response)
})

afterEach(() => {
  vi.restoreAllMocks()  // Restore to Original fetch
})

Method 2: The Mock Module

TS
// api.ts — Real Module
export async function fetchUsers() {
  const res = await fetch('/api/users')
  return res.json()
}

// Testing... Mock
vi.mock('../api', () => ({
  fetchUsers: vi.fn().mockResolvedValue([
    { id: 1, name: 'Mock User' },
  ])
}))

Method 3: Mock React Router

TSX
import { MemoryRouter } from 'react-router-dom'

test('Rendering in the route context', () => {
  render(
    <MemoryRouter initialEntries={['/users/1']}>
      <UserDetailPage />
    </MemoryRouter>
  )
})

▶ Example 4: Testing a Custom Hook

Output:

TEXT 📖 Display only
Subheading: "{user.name}". Displays: "(null)  const [loading, setLoading] = useState(true)  const ". State: user (setter: setUser), loading (setter: setLoading), error (setter: setError). useEffect manages side effects. Async data fetching/loading states
JSX
import { renderHook, act } from '@testing-library/react'
import { useState, useCallback } from 'react'

function useCounter(initial = 0) {
  const [count, setCount] = useState(initial)
  const increment = useCallback(() => setCount(c => c + 1), [])
  const decrement = useCallback(() => setCount(c => c - 1), [])
  const reset = useCallback(() => setCount(initial), [initial])
  return { count, increment, decrement, reset }
}

describe('useCounter', () => {
  test('initializes with default value', () => {
    const { result } = renderHook(() => useCounter())
    expect(result.current.count).toBe(0)
  })

  test('initializes with custom value', () => {
    const { result } = renderHook(() => useCounter(10))
    expect(result.current.count).toBe(10)
  })

  test('increments counter', () => {
    const { result } = renderHook(() => useCounter())
    act(() => result.current.increment())
    expect(result.current.count).toBe(1)
  })

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

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

Output:

TEXT 📖 Display only
Test: render(<Button text="Click" />) → screen.getByText("Click") → expect(element).toBeInTheDocument(). Pass ✓

▶ Example 5: Integration Testing—Form Submission Process

Output:

TEXT 📖 Display only
State: count (setter: setCount). useCallback memoizes handler
JSX
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')

  async function handleSubmit(e) {
    e.preventDefault()
    if (!email.includes('@')) { setError('Invalid email'); return }
    if (password.length < 6) { setError('Password too short'); return }
    setError('')
    await onSubmit({ email, password })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" data-testid="email" />
      <input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Password" data-testid="password" />
      {error && <p data-testid="error">{error}</p>}
      <button type="submit">Login</button>
    </form>
  )
}

describe('LoginForm integration', () => {
  test('shows error for invalid email', async () => {
    render(<LoginForm onSubmit={jest.fn()} />)
    await userEvent.type(screen.getByTestId('email'), 'invalid')
    await userEvent.type(screen.getByTestId('password'), 'password123')
    await userEvent.click(screen.getByRole('button', { name: /login/i }))
    expect(screen.getByTestId('error')).toHaveTextContent('Invalid email')
  })

  test('calls onSubmit with valid data', async () => {
    const onSubmit = jest.fn().mockResolvedValue(undefined)
    render(<LoginForm onSubmit={onSubmit} />)
    await userEvent.type(screen.getByTestId('email'), 'alice@test.com')
    await userEvent.type(screen.getByTestId('password'), 'secure123')
    await userEvent.click(screen.getByRole('button', { name: /login/i }))
    await waitFor(() => {
      expect(onSubmit).toHaveBeenCalledWith({ email: 'alice@test.com', password: 'secure123' })
    })
  })
})

Output:

TEXT 📖 Display only
Login form with email/password. Invalid email → red border + "The email address format is incorrect." Password <6 → "Password must be at least 6 chars". Submit → console.log({email, password})

❓ FAQ

Q What exactly is the difference between getBy, findBy, and queryBy?
A A simple rule of thumb: Use getBy when the element is guaranteed to exist (an error is thrown if it’s not found); use queryBy when the element may not exist (returns null); use findBy when the element is rendered asynchronously (returns a Promise; an error is thrown after the timeout). Each method has corresponding Role/Text/TestId variants, such as getByRole, findByText, and queryByTestId.
Q Which should I use, userEvent or fireEvent?
A Use userEvent whenever possible. fireEvent is a low-level API that directly triggers DOM events. userEvent simulates a complete sequence of user actions on top of fireEvent (for example, a "click" includes mousedown → mouseup → click), which more closely matches actual browser behavior. Only for operations not supported by userEvent should you fall back to fireEvent.
Q Should you mock child components in tests?
A The philosophy of React Testing Library is "do not mock child components," because tests should simulate the user's perspective—what the user sees is the complete component tree. You should only consider mocking child components when they have significant side effects (such as complex animation libraries or third-party chart components). Simply replace them with vi.mock('./ExpensiveChart', () => () => <div>Mock Chart</div>).
Q Should I clean up the test environment in beforeEach?
A Yes. After each test, you should clean up the rendered DOM and mocks. We recommend using afterEach(() => { vi.clearAllMocks() }). Vitest automatically unloads components after each render, but you must manually clean up the mock state. If you’re using a global fetch mock, use vi.restoreAllMocks() to restore the original implementation.
Q What level of test coverage is sufficient?
A There’s no one-size-fits-all standard, but industry benchmarks suggest: 80%+ for core business logic, 90%+ for generic utility functions, and 60%+ for UI components. Don’t aim for 100% coverage—some code (such as CSS styles and simple prop passing) has a very low testing ROI. The key is to cover the code paths that would have the “greatest impact if a bug were to occur.” Vitest’s --coverage parameter can generate an Istanbul coverage report.

📖 Summary


📝 Exercises

  1. Write comprehensive tests for a Button component: Verify that clicking it triggers onClick and disabled, that it becomes unclickable, that the correct text is displayed, and that the custom className takes effect. Cover at least 4 test cases.
  2. Write tests for a UserProfile component: The test should verify that the loading state displays "loading," the success state displays user information (with asynchronous handling), and the failure state displays an error message. Use vi.spyOn to mock the fetch API.
  3. Write integration tests for a TodoApp component (including TodoList, TodoItem, and the AddTodo form): Test the three functions—adding a new to-do, marking a to-do as completed, and deleting a to-do—to verify that the list UI updates correctly.
Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏