🔬 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":
const HelloWorld = () => {
return <div>Hello World</div>
}
export default HelloWorld
Test file:
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:
const HelloWorld = () => {
return <div data-testid="hello-world">Hello World</div>
}
export default HelloWorld
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.
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:
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.
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:
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:
const Foobar = ({ done }) => (
<div>
<button onClick={done}>Call DONE</button>
</div>
)
export default Foobar
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.
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:
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:
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:
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.
const Button = ({ text, onClick }) => (
<button onClick={onClick} className="button">
{text}
</button>
)
export default Button
Test:
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()
orjest.mock()
to mock modules or API calls. - Test error boundaries: Simulate errors and assert fallback UI.
- Use coverage reports: Run
vitest --coverage
orjest --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: