🔬 React Testing - How to Test React Components?

Unit testing is an essential part of any software development process. It allows developers to test individual components of the application and catch any bugs before they make it into production. React.js, being one of the most popular front-end frameworks, has a lot of resources available for unit testing.


Why You Should Write Unit Tests

Unit testing is a crucial step in the software development process, where developers meticulously examine the smallest functional components, known as units, to ensure their proper functioning. This process involves thorough testing conducted by software developers, and occasionally by QA personnel, as an integral part of the development lifecycle. Unit testing helps us have confidence that our software works well, even in unusual use cases. While it's difficult to cover every scenario before deployment, unit testing helps ensure that independent units of our software work correctly and are robust over time.

Benefits of unit testing:

  • Catches bugs early, reducing the cost of fixing them later.
  • Encourages modular, maintainable code.
  • Provides documentation for how components are expected to behave.
  • Enables safe refactoring and upgrades.
  • Increases developer confidence and speeds up development.

What Should You Test

As the name suggests, you should be testing a unit—no more, no less. If it is a function, test only that function and not its dependencies. If it's a React component, test only that component.

What to Test

Generally, your tests should cover:

  • Whether a component renders with or without props
  • How a component renders with state changes
  • How a component reacts to user interactions (clicks, typing, etc.)
  • Conditional rendering and edge cases
  • Output based on different prop values
  • Integration with callbacks or context

What Not to Test

You do not need to test:

  • The actual implementation details of a functionality—just test if the component behaves correctly.
  • Third-party libraries (like Material UI)—these are already tested by their maintainers.
  • Browser APIs or framework internals.

Tools for React Unit Testing

React Testing Library

The React Testing Library provides utilities to test UI components in a user-centric way, focusing on how users interact with your app. It encourages best practices by querying elements the way users do (by text, label, role, etc.).

Vitest

Vitest is a fast, Vite-powered testing framework. It is compatible with Jest APIs and integrates well with modern frontend tooling. See the Comparisons section for details on how Vitest differs from other tools.

Jest

Jest is a widely used JavaScript testing framework, often used with React. It provides a rich API for assertions, mocking, and snapshot testing.


How to Write Unit Tests

This section covers the general strategy for writing unit tests, not setup or configuration.

Test Rendering Components

Example: Test that a component renders "Hello World":

HelloWorld.jsx
const HelloWorld = () => {
  return <div>Hello World</div>
}
export default HelloWorld

Test file:

HelloWorld.test.jsx
import { render } from '@testing-library/react'
import { expect, it } from 'vitest'
import HelloWorld from './HelloWorld'
 
it('should render "Hello World" text', () => {
  const { getByText } = render(<HelloWorld />)
  const helloWorldElement = getByText('Hello World')
  expect(helloWorldElement).toBeInTheDocument()
})

You can use data-testid for more precise selection:

HelloWorld.jsx
const HelloWorld = () => {
  return <div data-testid="hello-world">Hello World</div>
}
export default HelloWorld
HelloWorld.test.jsx
import { render } from '@testing-library/react'
import { expect, it } from 'vitest'
import HelloWorld from './HelloWorld'
 
it('should render "Hello World" text', () => {
  const { getByTestId } = render(<HelloWorld />)
  const helloWorldElement = getByTestId('hello-world')
  expect(helloWorldElement).toBeInTheDocument()
  expect(helloWorldElement.textContent).toBe('Hello World')
})

Test Firing Events

Example: Counter component that updates on button click.

Counter.jsx
import { useState } from 'react'
const Counter = () => {
  const [count, setCount] = useState(0)
  const handleIncrement = () => setCount(count + 1)
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleIncrement}>Increment</button>
    </div>
  )
}
export default Counter

Test:

Counter.test.jsx
import { render, fireEvent } from '@testing-library/react'
import { expect, it } from 'vitest'
import Counter from './Counter'
 
it('should increment count on button click', () => {
  const { getByText } = render(<Counter />)
  const countElement = getByText('Count: 0')
  const buttonElement = getByText('Increment')
  fireEvent.click(buttonElement)
  expect(getByText('Count: 1')).toBeInTheDocument()
})

State and Props of the Components

Test how components behave with different props and state changes.

Counter.jsx
import { useState } from 'react'
const Counter = ({ initialCount = 0 }) => {
  const [count, setCount] = useState(initialCount)
  const increment = () => setCount(count + 1)
  return (
    <div>
      <p>
        Count: <span data-testid="count">{count}</span>
      </p>
      <button data-testid="button" onClick={increment}>
        Increment
      </button>
    </div>
  )
}
export default Counter

Test:

Counter.test.jsx
import { render, fireEvent } from '@testing-library/react'
import { expect, it } from 'vitest'
import Counter from './Counter'
 
it('should render initial count and increment count on button click', () => {
  const { getByTestId } = render(<Counter initialCount={3} />)
  const countElement = getByTestId('count')
  const buttonElement = getByTestId('button')
  expect(countElement.textContent).toBe('3')
  fireEvent.click(buttonElement)
  expect(countElement.textContent).toBe('4')
})

Mocking Function Calls

Mock functions to test if callbacks are called:

Foobar.jsx
const Foobar = ({ done }) => (
  <div>
    <button onClick={done}>Call DONE</button>
  </div>
)
export default Foobar
Foobar.test.jsx
import { render, fireEvent } from '@testing-library/react'
import { expect, it, vi } from 'vitest'
import Foobar from './Foobar'
 
it('calls the done callback when button is clicked', () => {
  const fn = vi.fn()
  const { getByText } = render(<Foobar done={fn} />)
  const button = getByText('Call DONE')
  fireEvent.click(button)
  expect(fn).toHaveBeenCalledTimes(1)
  fireEvent.click(button)
  expect(fn).toHaveBeenCalledTimes(2)
})

Testing React Hooks

Use @testing-library/react-hooks or built-in utilities to test custom hooks.

useCounter.js
import { useState } from 'react'
const useCounter = () => {
  const [count, setCount] = useState(0)
  const increment = () => setCount((prev) => prev + 1)
  const decrement = () => setCount((prev) => prev - 1)
  return { count, increment, decrement }
}
export default useCounter

Test:

useCounter.test.js
import { renderHook, act } from '@testing-library/react-hooks'
import { expect, it } from 'vitest'
import useCounter from './useCounter'
 
it('should increment and decrement counter correctly', () => {
  const { result } = renderHook(() => useCounter())
  expect(result.current.count).toBe(0)
  act(() => {
    result.current.increment()
  })
  expect(result.current.count).toBe(1)
  act(() => {
    result.current.decrement()
  })
  expect(result.current.count).toBe(0)
})

Testing Asynchronous Operations

Mock async calls to avoid real network requests:

AsyncComponent.jsx
import { useState, useEffect } from 'react'
const AsyncComponent = () => {
  const [data, setData] = useState(null)
  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch('https://api.example.com/data')
      const result = await response.json()
      setData(result)
    }
    fetchData()
  }, [])
  return <div>{data ? data.message : 'Loading...'}</div>
}
export default AsyncComponent

Test:

AsyncComponent.test.js
import { render, waitFor } from '@testing-library/react'
import { expect, it, vi } from 'vitest'
import AsyncComponent from './AsyncComponent'
 
it('should render fetched data after async call', async () => {
  const mockData = { message: 'Test Message' }
  vi.stubGlobal('fetch', () =>
    Promise.resolve({
      json: () => Promise.resolve(mockData),
    }),
  )
  const { getByText } = render(<AsyncComponent />)
  expect(getByText('Loading...')).toBeInTheDocument()
  await waitFor(() => {
    expect(getByText(mockData.message)).toBeInTheDocument()
  })
  vi.unstubAllGlobals()
})

You can also use waitForNextUpdate for data fetching in hooks.


Snapshot Testing

Snapshot testing compares the rendered output to a saved snapshot.

Button.jsx
const Button = ({ text, onClick }) => (
  <button onClick={onClick} className="button">
    {text}
  </button>
)
export default Button

Test:

Button.test.jsx
import { render } from '@testing-library/react'
import { expect, it } from 'vitest'
import Button from './Button'
 
it('should match the snapshot', () => {
  const { asFragment } = render(<Button text="Click me" onClick={() => {}} />)
  expect(asFragment()).toMatchSnapshot()
})

Update the snapshot file if you intentionally change the output:

vitest -u

Advanced Tips

  • Test accessibility: Use queries like getByRole, getByLabelText, and axe for accessibility checks.
  • Test context and providers: Wrap components with context providers in tests if needed.
  • Mock modules: Use vi.mock() or jest.mock() to mock modules or API calls.
  • Test error boundaries: Simulate errors and assert fallback UI.
  • Use coverage reports: Run vitest --coverage or jest --coverage to see which lines are tested.

Summary

The most important thing is writing testable code. This allows you to write better unit tests, faster. Key points for testable code:

  • Separate UI and logic
  • Pass dependencies to functions (dependency injection)
  • Test a single unit of code at a time
  • Prefer user-centric queries and avoid testing implementation details
  • Keep tests fast, isolated, and maintainable

Further Reading: