React Testing Library Unit Testing Guide: From Setup to Confident UI Tests
A practical guide to unit testing React with Testing Library—setup, queries, user events, async, MSW, providers, and best practices.
Image used for representation purposes only.
Why React Testing Library?
React Testing Library (RTL) encourages testing your UI the way users actually use it. Instead of drilling into component internals, you query the DOM by accessible roles and names, fire real user interactions, and assert on visible outcomes. The result: resilient tests that survive refactors and promote accessibility by default.
Key ideas:
- Test behavior, not implementation details.
- Prefer queries that reflect accessibility (role, name, label).
- Simulate real interactions with
@testing-library/user-event. - Embrace async patterns (
findBy*,waitFor) for effects and data fetching.
Installation and Project Setup
You can use RTL with Jest or Vitest. Both run in a jsdom environment.
Install dependencies
# with npm
npm i -D @testing-library/react @testing-library/jest-dom @testing-library/user-event
# or with pnpm
devpnpm add -D @testing-library/react @testing-library/jest-dom @testing-library/user-event
# optional but recommended for fetch-based code
npm i -D whatwg-fetch
Vitest + Vite configuration
Add vitest config in your Vite config file and a setup file.
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: './src/setupTests.ts',
css: true,
},
})
// src/setupTests.ts
import '@testing-library/jest-dom'
import 'whatwg-fetch' // polyfills fetch in jsdom
Jest configuration
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
}
// src/setupTests.ts
import '@testing-library/jest-dom'
import 'whatwg-fetch'
Note: Cleanup is automatic in modern RTL + Jest/Vitest setups; you don’t need to call cleanup() manually.
Your First Test: Rendering and Querying by Role
Suppose we have a simple counter component.
// Counter.tsx
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<h2>Count: {count}</h2>
<button
type="button"
aria-label="increment"
onClick={() => setCount(c => c + 1)}
>
Increment
</button>
</div>
)
}
// Counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from './Counter'
it('increments the count when the button is clicked', async () => {
render(<Counter />)
const user = userEvent.setup()
// Query by role and accessible name
const button = screen.getByRole('button', { name: /increment/i })
expect(screen.getByRole('heading', { name: /count:/i })).toHaveTextContent('0')
await user.click(button)
expect(screen.getByRole('heading', { name: /count:/i })).toHaveTextContent('1')
})
Why this is good:
getByRole('button', { name: /increment/i })mirrors how assistive tech locates the control.- Assertions use
jest-dommatchers liketoHaveTextContentfor clarity.
Query Strategy (from most to least user-centric)
Prefer queries that resemble user interactions and a11y APIs:
getByRole/findByRolewithname(best default)getByLabelText(forms)getByPlaceholderTextorgetByText(content)getByDisplayValue(current input value)getByAltText(images)getByTitlegetByTestId(last resort when nothing else is stable)
Guidelines:
- Use
getBy*for synchronous presence; throws if not found. - Use
findBy*for async (awaits until timeout). - Use
queryBy*for asserting absence (returns null instead of throwing).
Interactions: Prefer user-event over fireEvent
@testing-library/user-event models real user behavior (typing delays, focus/blur, tabbing) and wraps necessary React act() calls.
import userEvent from '@testing-library/user-event'
it('types into an input and submits', async () => {
render(<LoginForm />)
const user = userEvent.setup()
await user.type(screen.getByLabelText(/email/i), 'dev@example.com')
await user.type(screen.getByLabelText(/password/i), 'supersecret')
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(await screen.findByText(/welcome/i)).toBeInTheDocument()
})
Asynchronous UI: findBy, waitFor, and friends
When components fetch data, show spinners, or debounce input, use async utilities.
import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'
it('loads and displays data', async () => {
render(<UsersPage />)
// Spinner appears immediately
expect(screen.getByRole('status')).toHaveAccessibleName(/loading/i)
// Wait for the spinner to disappear
await waitForElementToBeRemoved(() => screen.queryByRole('status'))
// Then the list should be visible
const rows = await screen.findAllByRole('row')
expect(rows.length).toBeGreaterThan(1)
})
it('retries until a condition is met', async () => {
render(<Search />)
await userEvent.type(screen.getByRole('searchbox'), 'alice')
await waitFor(() => expect(screen.getByText(/results for alice/i)).toBeVisible())
})
Tips:
findBy*is sugar forawait waitFor(() => getBy*()).- Keep
waitForexpectations inside the callback.
Mocking Network Requests with MSW
Mock Service Worker (MSW) intercepts requests at the network level, preserving behavior and letting you test failure paths. It works in Node (tests) and browsers (storybook/e2e).
// src/testServer.ts
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
export const server = setupServer(
http.get('/api/users', () => HttpResponse.json([{ id: 1, name: 'Alice' }]))
)
// src/setupTests.ts
import '@testing-library/jest-dom'
import 'whatwg-fetch'
import { server } from './testServer'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
// UsersPage.test.tsx
import { render, screen } from '@testing-library/react'
import { server } from '../testServer'
import { http, HttpResponse } from 'msw'
import UsersPage from './UsersPage'
it('renders users from the API', async () => {
render(<UsersPage />)
expect(await screen.findByText('Alice')).toBeInTheDocument()
})
it('handles server errors', async () => {
server.use(http.get('/api/users', () => HttpResponse.text('Oops', { status: 500 })))
render(<UsersPage />)
expect(await screen.findByRole('alert')).toHaveTextContent(/something went wrong/i)
})
Testing with Providers: Router, State, and Context
Real components often depend on routing, Redux, or custom context. Create a small helper to wrap render with providers.
Router (React Router)
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { render, screen } from '@testing-library/react'
function renderWithRouter(ui: React.ReactElement, { route = '/' } = {}) {
window.history.pushState({}, 'Test', route)
return render(
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route path="/" element={ui} />
<Route path="/profile" element={<div>Profile</div>} />
</Routes>
</MemoryRouter>
)
}
it('navigates to Profile', async () => {
renderWithRouter(<Nav />)
await userEvent.click(screen.getByRole('link', { name: /profile/i }))
expect(await screen.findByText(/profile/i)).toBeInTheDocument()
})
Redux (or any state library)
import { Provider } from 'react-redux'
import { configureStore } from '@reduxjs/toolkit'
import type { PreloadedState } from '@reduxjs/toolkit'
import { render } from '@testing-library/react'
import authReducer from './authSlice'
function renderWithProviders(ui: React.ReactElement, { preloadedState }: { preloadedState?: PreloadedState<any> } = {}) {
const store = configureStore({ reducer: { auth: authReducer }, preloadedState })
return render(<Provider store={store}>{ui}</Provider>)
}
Custom context
Wrap similarly in your test helper. Co-locate these helpers in test-utils.ts and re-export RTL APIs so tests can import from a single place.
Accessibility-First Assertions
Leverage jest-dom to express intent clearly and verify a11y:
expect(screen.getByRole('button', { name: /submit/i })).toBeEnabled()
expect(screen.getByRole('img', { name: /product photo/i })).toBeVisible()
expect(screen.getByRole('textbox', { name: /email/i })).toHaveValue('dev@example.com')
expect(screen.getByRole('dialog')).toHaveAccessibleName(/confirm delete/i)
Debugging a11y:
import { logRoles } from '@testing-library/dom'
const { container } = render(<MyDialog />)
logRoles(container) // prints available roles & names
Optional automated a11y check with jest-axe:
npm i -D jest-axe axe-core
import { axe, toHaveNoViolations } from 'jest-axe'
expect.extend(toHaveNoViolations)
it('has no obvious a11y violations', async () => {
const { container } = render(<MyPage />)
expect(await axe(container)).toHaveNoViolations()
})
Patterns to Embrace
- Use
screenover destructured query results; it standardizes tests and improves readability. - Keep tests focused: one behavior per test with clear Arrange–Act–Assert sections.
- Favor higher-level tests over micro-unit tests of internal hooks.
- Model realistic flows: typing, clicking, navigation, and network outcomes.
Common Anti‑Patterns (and Alternatives)
- Asserting internal state or calling component methods directly → Assert on DOM output and user-visible effects.
- Deep snapshots of entire trees → Prefer explicit assertions with
jest-domfor stable, meaningful checks. - Overusing
data-testid→ Prefer role/name/label; use test IDs only when nothing else is reliable. - Forcing
act()calls manually → Useuser-eventand async utilities which handle this for you.
Troubleshooting Guide
- “Unable to find role” → Inspect with
screen.debug()andlogRoles(container). Ensure elements have the correct ARIA role/name. - “Not wrapped in act(…)” → Await
userEventactions; usefindBy*/waitForfor async state. - Flaky async tests → Avoid arbitrary
setTimeout; assert on a stable end state withwaitFor/findBy. - Testing modals/portals → Query by
role="dialog"oralertdialog"; the element may render in a portal root, but RTL queries still see it. - Fake timers → If using
vi.useFakeTimers()/jest.useFakeTimers(), advance timers withrunOnlyPendingTimers()withinact()or rely on user-event’s built-in timing helpers.
Handy Snippets (Copy/Paste)
Render helpers:
// test-utils.ts
import { render } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
export * from '@testing-library/react'
export { default as userEvent } from '@testing-library/user-event'
export function renderWithRouter(ui: React.ReactElement, { route = '/' } = {}) {
return render(<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>)
}
Async checklist:
- If UI changes after an effect: use
await screen.findBy.... - If something should disappear:
await waitForElementToBeRemoved(...). - If you need to poll for a condition: wrap in
await waitFor(() => ...).
Form checklist:
- Associate inputs with
<label for>or<label><input /></label>. - Query inputs with
getByLabelText. - Assert values with
toHaveValueand errors withrole="alert".
Final Checklist for Confident Tests
- Queries use role/name/label where possible.
- Interactions use
userEventand are awaited. - Async flows rely on
findBy/waitFor(no arbitrary sleeps). - Network is mocked at the boundary (MSW), not inside components.
- Providers are handled via small
renderWith*helpers. - Assertions are explicit and user-centric with
jest-dom.
Conclusion
React Testing Library steers you toward tests that reflect real user behavior and accessible UI. With a clean setup (Jest or Vitest), user-centric queries, MSW-backed network mocks, and provider-aware render helpers, you’ll write fast, maintainable tests that stay trustworthy as your React code evolves.
Related Posts
Building an Accessible React Portal Modal with TypeScript
Build an accessible React portal modal with TypeScript: focus trap, Escape handling, scroll locking, SSR safety, and tests with RTL.
Build a Headless React UI Library: A Practical Tabs Tutorial
Build a headless React UI component library step by step—accessibility, state, TypeScript, testing, and packaging—using a fully featured Tabs example.
React useMemo and useCallback: A Practical Optimization Guide
Practical guide to React’s useMemo and useCallback: when to use them, pitfalls to avoid, patterns, and profiling tips for faster apps.