- Authors
- Written by :
- Name
- Akash Srivastava
Introduction to react testing library
- Published on
- Published On:
React Testing Library (RTL) is a lightweight testing utility for React applications, designed to help developers write tests that focus on how components are used by the end user. Instead of testing implementation details, RTL emphasizes testing UI behavior by interacting with the DOM in a way similar to how users would—using accessible queries like text, labels, and roles. This approach promotes better test coverage and ensures that your application behaves as expected in real-world scenarios.
The Problem
The problem statement revolves around challenges developers face when testing React applications effectively:
- Testing Implementation Details: Many traditional testing tools and approaches encourage testing the internal implementation of components (e.g., methods, state, and lifecycle hooks). This often leads to brittle tests that break with refactoring, even if the application behavior remains unchanged.
- Complex Setup: Some testing libraries require extensive configuration or rely on mocking frameworks that make tests harder to maintain and understand.
- Unrealistic Interactions: Traditional tools may simulate user interactions in ways that don’t mimic real-world behavior, leading to tests that pass in isolation but fail in production.
- Poor Accessibility Coverage: Accessibility (a11y) is often overlooked in tests. Developers might miss issues like missing ARIA roles, labels, or improper DOM structure.
- Unclear Focus: Tests can often be cluttered with implementation-specific logic, which distracts from ensuring the application works as intended from an end user's perspective.
How React Testing Library Solves these challenges
- Avoiding Implementation Details:
RTL encourages testing your React components from the user's perspective by interacting with the DOM as a user would. Instead of targeting internal component methods or state, tests focus on rendered output, like text, buttons, and input fields. This leads to more resilient tests that remain valid through refactors or internal changes, as long as the user experience stays consistent. - Simplified Setup:
RTL provides sensible defaults and requires minimal configuration. The library is designed to work out of the box with modern React projects. For example, it handles rendering components into a mock DOM and cleaning up after tests automatically. - Realistic Interactions:
RTL simulates real-world user interactions, such as clicking buttons, typing into inputs, or navigating via keyboard. It emphasizes accessibility best practices by using queries likegetByRole
andgetByLabelText
, ensuring that interactions mimic those of an actual user (including assistive technologies). - Improved Accessibility Testing:
By using semantic queries (getByRole
,getByText
, etc.), RTL inherently promotes accessibility. These queries encourage developers to structure their components with proper labels, ARIA roles, and semantic HTML. As a result, RTL helps catch accessibility issues early in development. - User-Centric Focus:
RTL’s guiding philosophy is "The more your tests resemble the way your software is used, the more confidence they can give you." This user-centric approach shifts focus from how a component is built to how it behaves, ensuring the tests are aligned with real-world scenarios and business requirements.
By addressing these common pain points, React Testing Library makes it easier for developers to write tests that are robust, maintainable, and meaningful. It enables teams to focus on building high-quality, user-friendly applications without worrying about test fragility or unnecessary complexity.
What Approach should we follow to test applications using RTL
1. Focus on Behavior, Not Implementation
- What to Test: Test the user-facing behavior of components, not their internal implementation (e.g., state, methods, or lifecycle hooks).
- Why: Tests should not break when you refactor the internal structure of a component, as long as its behavior remains the same.
- Example: Instead of testing a component’s state directly, simulate user interactions and verify the expected DOM updates.
2. Test from the User's Perspective
- Use queries like
getByRole
,getByLabelText
,getByPlaceholderText
, orgetByText
to locate elements, as these simulate how real users interact with the application (e.g., via screen readers or visually). - Avoid using queries like
getByTestId
unless absolutely necessary, as they don’t align with real-world user behavior. - Tip: Prioritize accessibility-based selectors (
getByRole
, etc.) to ensure good semantic HTML and accessibility.
3. Write Minimal and Clear Tests
- Write tests that clearly express intent without overloading unnecessary details or logic.
- Avoid testing irrelevant aspects of a component (e.g., styles, unless they impact functionality).
- Example: If testing a form submission, check if the expected API call is made and the UI updates correctly, but don’t test intermediate steps unless crucial.
4. Simulate Real User Interactions
- Use RTL utilities like
fireEvent
oruserEvent
to simulate user actions such as clicks, typing, and key presses. - Prefer
userEvent
for more realistic simulations (e.g., typing into inputs or triggering native browser behaviors). - Example: Instead of manually setting an input’s value, simulate typing via
userEvent.type(input, 'value')
.
5. Avoid Mocking Unless Necessary
- Avoid excessive mocking of dependencies, especially libraries or child components, unless testing in isolation.
- Mocking can obscure bugs that arise from integration issues. Focus on end-to-end flows where possible.
- When Mocking is Okay: For external APIs, databases, or third-party integrations where you don't want to rely on external factors.
6. Keep Tests Resilient and Maintainable
- Avoid overly specific tests tied to implementation details, like exact CSS classes or inline styles.
- Use robust queries that won’t break with minor UI changes, like
getByRole
instead ofgetByText
for buttons. - Example: Instead of testing for
getByText('Submit')
, usegetByRole('button', { name: 'Submit' })
.
7. Ensure Accessibility
- Test for accessible UI using
getByRole
,getByLabelText
, and other a11y queries. - Use tools like
axe
orjest-axe
to check for accessibility violations. - Why: Accessible components are better for all users, including those using assistive technologies.
8. Write Tests that Are Independent
- Each test should start with a fresh state. Use
beforeEach
or render components separately in each test to avoid state bleed. - Clean up after tests using utilities like
cleanup()
(though RTL does this automatically in most cases).
9. Handle Async Code Properly
- Use
findBy
queries when waiting for asynchronous changes (e.g., API responses). - Leverage
waitFor
for assertions that depend on async operations.
10. Test Edge Cases and Error States
- Test both happy paths and edge cases, like invalid inputs, API failures, or empty states.
- Simulate real-world scenarios where users may provide unexpected input or network calls may fail.
- Example: Test how a form component behaves when required fields are empty or a submission fails.
11. Structure Tests Well
- Follow the Arrange-Act-Assert (AAA) pattern for clear test structure:
- Arrange: Render the component and prepare the environment.
- Act: Simulate user interactions or events.
- Assert: Check if the DOM behaves as expected.
Essential Methods in React Testing Library :
1. Rendering Components
These methods are used to render your React components in a test environment.
render
: Renders a React component for testing.const { getByText } = render(<MyComponent />)
2. Querying Elements
These methods help you select elements in the DOM. They are divided into different types based on their behavior.
a. Preferred Queries (User-Focused and Accessible):
These queries should be your first choice because they encourage accessible DOM structure.
getByRole
: Selects elements by their ARIA role (e.g., buttons, headings).const button = screen.getByRole('button', { name: /submit/i })
getByLabelText
: Finds elements associated with<label>
tags.const input = screen.getByLabelText(/username/i)
getByPlaceholderText
: Finds elements by placeholder text.const input = screen.getByPlaceholderText('Enter your name')
getByText
: Finds elements by visible text.const heading = screen.getByText(/welcome/i)
getByAltText
: Finds elements with specificalt
text (e.g., images).const image = screen.getByAltText('Profile picture')
getByTitle
: Finds elements by theirtitle
attribute.const tooltip = screen.getByTitle('Tooltip text')
b. Non-Preferred Query:
getByTestId
: Selects elements by thedata-testid
attribute. Use sparingly, only when no better queries are available.const element = screen.getByTestId('custom-element')
c. Variants of Queries:
getBy*
: Throws an error if no matching element is found (synchronous).queryBy*
: Returnsnull
if no matching element is found (useful for assertions about non-existence).findBy*
: Waits for an asynchronous element to appear (returns aPromise
). Example:const asyncButton = await screen.findByRole('button', { name: /submit/i })
3. Simulating User Interactions
These methods are used to simulate user behavior like clicks, typing, and more.
a. FireEvent (Basic Interactions):
fireEvent.click
(element)
: Simulates a click event.fireEvent.change(element, { target: { value: 'new value' } })
: Simulates typing into an input field. Example:fireEvent.change(input, { target: { value: 'John Doe' } })
b. UserEvent (More Realistic Interactions):
UserEvent is a higher-level API for simulating interactions closer to how users actually interact with the DOM.
userEvent.type(element, 'text')
: Simulates typing.userEvent.click
(element)
: Simulates a click.userEvent.hover(element)
: Simulates hovering.userEvent.tab
()
: Simulates tab navigation. Example:import userEvent from '@testing-library/user-event' userEvent.type(input, 'Hello, World!') userEvent.click(button)
4. Assertions
Assertions are performed using Jest matchers, which are commonly paired with RTL.
expect(element).toBeInTheDocument()
: Checks if an element exists in the DOM.expect(element).toHaveTextContent('text')
: Checks if an element contains specific text.expect(element).toHaveAttribute('attribute', 'value')
: Verifies an attribute's value.expect(element).toBeDisabled()
: Asserts if an element is disabled.expect(element).toBeVisible()
: Checks if an element is visible.
5. Waiting for Elements (Async Code)
Use these methods to wait for elements that load asynchronously.
waitFor
: Waits for specific assertions to pass.await waitFor(() => expect(mockFunction).toHaveBeenCalled())
findBy*
: Waits for an element to appear asynchronously.const message = await screen.findByText(/loaded/i)
6. Debugging Tests
These methods are helpful for debugging when tests fail.
screen.debug()
: Logs the current DOM structure to the console.logRoles(container)
: Lists all ARIA roles in the given container for accessibility debugging.import { logRoles } from '@testing-library/dom' logRoles(container)
7. Utility Functions
Additional helpers for specific needs.
cleanup()
: Cleans up the DOM between tests (usually done automatically).within
: Queries a specific section of the DOM.const { getByText } = within(tableRow)
act
: Ensures all updates are applied when interacting with components.
act(() => {
fireEvent.click(button)
})
How to Mock Hooks with React Testing Library
1. Mocking React's Built-in Hooks
useState
a. Mocking You can mock the useState
function to control state behavior in your tests.
Example:
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
// Component using useState
function Counter() {
const [count, setCount] = React.useState(0)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
)
}
// Test
test('mocks useState in Counter component', () => {
const setState = jest.fn() // Mock setState function
jest.spyOn(React, 'useState').mockImplementation((init) => [init, setState])
render(<Counter />)
const button = screen.getByText(/increment/i)
fireEvent.click(button)
expect(setState).toHaveBeenCalledWith(1) // Ensure state updates
})
useEffect
b. Mocking To mock useEffect
, you can control its behavior using jest.spyOn
.
Example:
import React from 'react'
import { render, screen } from '@testing-library/react'
function EffectComponent() {
React.useEffect(() => {
console.log('useEffect called')
}, [])
return <div>Effect Component</div>
}
test('mocks useEffect', () => {
const useEffectSpy = jest.spyOn(React, 'useEffect')
render(<EffectComponent />)
expect(useEffectSpy).toHaveBeenCalled() // Verify useEffect was called
})
useNavigate
(React Router Example)
c. Mocking If you're using useNavigate
from react-router-dom
, you can mock it.
Example:
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { useNavigate } from 'react-router-dom'
function NavigationButton() {
const navigate = useNavigate()
return <button onClick={() => navigate('/home')}>Go Home</button>
}
jest.mock('react-router-dom', () => ({
useNavigate: jest.fn(),
}))
test('mocks useNavigate', () => {
const mockNavigate = jest.fn()
useNavigate.mockReturnValue(mockNavigate)
const { getByText } = render(<NavigationButton />)
fireEvent.click(getByText(/go home/i))
expect(mockNavigate).toHaveBeenCalledWith('/home') // Verify navigation
})
2. Mocking Custom Hooks
Mocking custom hooks involves replacing their implementation with mocked data or behavior.
a. Mocking a Custom Hook
Suppose you have a custom hook, useFetchData
, which fetches data from an API.
Example:
// useFetchData.js
export function useFetchData() {
const [data, setData] = React.useState([])
React.useEffect(() => {
fetch('/api/data')
.then((res) => res.json())
.then((data) => setData(data))
}, [])
return data
}
// Component.js
import React from 'react'
import { useFetchData } from './useFetchData'
function DataDisplay() {
const data = useFetchData()
return (
<div>
{data.map((item) => (
<p key={item.id}>{item.name}</p>
))}
</div>
)
}
export default DataDisplay
// Test
import { render, screen } from '@testing-library/react'
import DataDisplay from './Component'
import { useFetchData } from './useFetchData'
jest.mock('./useFetchData') // Mock the custom hook
test('mocks useFetchData custom hook', () => {
useFetchData.mockReturnValue([
{ id: 1, name: 'John' },
{ id: 2, name: 'Doe' },
])
render(<DataDisplay />)
expect(screen.getByText('John')).toBeInTheDocument()
expect(screen.getByText('Doe')).toBeInTheDocument()
})
b. Mocking Custom Hooks with Dependencies
If the hook accepts dependencies, mock its behavior accordingly.
Example:
// useCounter.js
export function useCounter(initialValue) {
const [count, setCount] = React.useState(initialValue)
const increment = () => setCount((prev) => prev + 1)
const decrement = () => setCount((prev) => prev - 1)
return { count, increment, decrement }
}
// CounterComponent.js
import React from 'react'
import { useCounter } from './useCounter'
function CounterComponent() {
const { count, increment, decrement } = useCounter(0)
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
</div>
)
}
export default CounterComponent
// Test
import { render, screen, fireEvent } from '@testing-library/react'
import CounterComponent from './CounterComponent'
import { useCounter } from './useCounter'
jest.mock('./useCounter')
test('mocks useCounter custom hook', () => {
const mockIncrement = jest.fn()
const mockDecrement = jest.fn()
useCounter.mockReturnValue({ count: 5, increment: mockIncrement, decrement: mockDecrement })
render(<CounterComponent />)
expect(screen.getByText('Count: 5')).toBeInTheDocument()
fireEvent.click(screen.getByText(/increment/i))
expect(mockIncrement).toHaveBeenCalled()
fireEvent.click(screen.getByText(/decrement/i))
expect(mockDecrement).toHaveBeenCalled()
})
3. Mocking Third-Party Hooks
react-query
's useQuery
Mocking Example:
import React from 'react'
import { render, screen } from '@testing-library/react'
import { useQuery } from 'react-query'
function DataFetcher() {
const { data } = useQuery('fetchData', () => Promise.resolve('Hello World'))
return <div>{data}</div>
}
jest.mock('react-query', () => ({
useQuery: jest.fn(),
}))
test('mocks useQuery from react-query', () => {
useQuery.mockReturnValue({ data: 'Mocked Data', isLoading: false })
render(<DataFetcher />)
expect(screen.getByText('Mocked Data')).toBeInTheDocument()
})
Best Practices for Mocking Hooks
- Mock selectively: Only mock hooks when absolutely necessary, such as when testing components in isolation or handling external dependencies.
- Use spies judiciously: For React's built-in hooks,
jest.spyOn
is a great option. - Mock return values: Replace the actual hook implementation with mocked return values using
jest.fn()
orjest.mock
.
Using MSW with React Testing library to mock API calls
MSW is a powerful tool for mocking API requests in your applications. It is especially useful in scenarios where your components or applications depend heavily on external APIs, but you want to test or develop them without relying on actual backend services.
When to Use MSW
Testing Components in Isolation
- When testing components that make network requests, MSW allows you to simulate API responses without relying on real servers.
- Example: Testing a component that fetches data from an external API.
Handling Edge Cases
- MSW makes it easy to simulate scenarios like network errors, slow responses, or unexpected server errors that are hard to reproduce with real APIs.
- Example: Mocking a 500 Internal Server Error or a delayed response.
Here’s how to use MSW in your app
Create a file named handlers.js
where you define your mock API handlers:
handlers.js
import { rest } from 'msw'
export const handlers = [
// Mock a GET request
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json([
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Doe' },
])
)
}),
// Mock a POST request
rest.post('/api/users', (req, res, ctx) => {
const { name } = req.body
return res(ctx.status(201), ctx.json({ id: 3, name }))
}),
]
2. Using MSW in Your Tests
a. Component that Fetches Data
Here’s a simple component that fetches and displays user data from an API:
UserList.js
import React, { useEffect, useState } from 'react'
function UserList() {
const [users, setUsers] = useState([])
useEffect(() => {
fetch('/api/users')
.then((res) => res.json())
.then((data) => setUsers(data))
}, [])
return (
<div>
<h1>User List</h1>
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
)
}
export default UserList
b. Test for the Component
Write a test that mocks the API response using MSW.
UserList.test.js
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
import UserList from './UserList'
// Set up MSW server
const server = setupServer(...handlers)
// Enable API mocking before tests
beforeAll(() => server.listen())
// Reset handlers after each test (to avoid test interference)
afterEach(() => server.resetHandlers())
// Disable API mocking after the tests are done
afterAll(() => server.close())
test('displays a list of users fetched from the API', async () => {
render(<UserList />)
// Verify loading state
expect(screen.getByText(/user list/i)).toBeInTheDocument()
// Wait for and verify user data
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('Jane Doe')).toBeInTheDocument()
})
})
3. Overriding Mock Handlers in Specific Tests
You can override the handlers to test error scenarios or different responses.
Example: Testing an Error Response
test('handles API errors gracefully', async () => {
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.status(500))
})
)
render(<UserList />)
// Check that an error message or fallback UI is displayed
await waitFor(() => {
expect(screen.getByText(/failed to fetch users/i)).toBeInTheDocument()
})
})
4. Mocking a POST Request
Here’s an example of testing a component that sends data to the server.
a. Component with a POST Request
UserForm.js
import React, { useState } from 'react'
function UserForm() {
const [name, setName] = useState('')
const [success, setSuccess] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
if (response.ok) {
setSuccess(true)
}
}
return (
<div>
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Enter name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<button type="submit">Add User</button>
</form>
{success && <p>User added successfully!</p>}
</div>
)
}
export default UserForm
b. Test for the Component
UserForm.test.js
import React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
import UserForm from './UserForm'
// Set up MSW server
const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
test('submits a new user and shows success message', async () => {
render(<UserForm />)
// Simulate user input
fireEvent.change(screen.getByPlaceholderText(/enter name/i), {
target: { value: 'Alice' },
})
fireEvent.click(screen.getByText(/add user/i))
// Wait for success message
await waitFor(() => {
expect(screen.getByText(/user added successfully!/i)).toBeInTheDocument()
})
})
5. Testing with Delayed Responses
MSW allows you to simulate delays in API responses.
Example:
test('handles delayed API response', async () => {
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.delay(2000), ctx.json([{ id: 1, name: 'Delayed User' }]))
})
)
render(<UserList />)
// Verify loading state (if any)
expect(screen.getByText(/user list/i)).toBeInTheDocument()
// Wait for delayed data
await waitFor(() => {
expect(screen.getByText('Delayed User')).toBeInTheDocument()
})
})
Conclusion
React Testing Library (RTL) is a game-changing tool for testing React applications with a focus on user-centric practices. By emphasizing interactions and real-world behavior rather than implementation details, RTL ensures that your tests remain robust, maintainable, and reflective of how users actually engage with your application.
Key takeaways include:
- Writing tests that mimic user actions like clicking buttons, entering text, or navigating the UI.
- Avoiding testing internal implementation details, which can lead to brittle tests.
- Leveraging utilities like
render
,screen
, andfireEvent
oruserEvent
for effective testing. - Mocking hooks, APIs, or components seamlessly to isolate and test specific scenarios.
Testing with RTL fosters confidence in your codebase by ensuring your components function as expected from the end-user’s perspective. Combined with tools like Jest and Mock Service Worker (MSW), RTL offers a comprehensive solution for building highly reliable React applications.
By embracing React Testing Library's philosophy of testing behavior over implementation, you'll not only write better tests but also create more accessible, user-friendly, and maintainable applications. With RTL, your application is truly tested as a user would experience it.