MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

React Testing Library: Testing Best Practices

Write better tests with RTL: user-centric queries, async testing, and mocking strategies.

ReactTestingTesting LibraryFrontend

By MinhVo

Introduction

React Testing Library (RTL) has become the de facto standard for testing React components. Created by Kent C. Dodds, RTL is built on a fundamental philosophy: test your components the way users interact with them. Instead of testing implementation details like state values or component methods, RTL encourages you to test what the user sees and does—clicking buttons, filling forms, and reading text on the screen.

Testing Best Practices

This philosophy leads to tests that are resilient to refactoring, catch real bugs, and serve as living documentation of your application's behavior. In this comprehensive guide, we'll explore every aspect of testing with React Testing Library—from basic queries to advanced patterns for async operations, mocking, and accessibility testing. You'll learn the priority of queries, when to use each type, and how to write tests that give you confidence without being brittle.

Understanding React Testing Library: Core Concepts

The Guiding Principles

React Testing Library is built on several key principles that should guide every test you write:

  1. Test behavior, not implementation: Don't test that useState was called with false. Test that clicking a button hides an element.

  2. Use accessible queries: Prefer queries that reflect how users find elements—by role, label text, or placeholder text. Avoid testing by class name or data-testid unless no accessible query works.

  3. Resist testing implementation details: If you refactor a component from class to hooks, your tests should still pass without changes.

  4. Write tests that resemble user interactions: Use userEvent instead of fireEvent for realistic interactions that fire all relevant events.

The Priority of Queries

RTL provides many query methods. The priority order helps you choose the right one:

// Priority 1: Queries accessible to everyone
getByRole        // Best for most elements (buttons, links, headings)
getByLabelText   // Best for form fields
getByPlaceholderText // When label isn't available
getByText        // For non-interactive elements with visible text
getByDisplayValue // For pre-filled form inputs
getByAltText     // For images
getByTitle       // For elements with title attribute
 
// Priority 2: Semantic queries
getByTestId      // Last resort - for elements with no accessible query
 
// Variants:
// getBy* - throws if no match or multiple matches
// getAllBy* - throws if no match, returns array
// queryBy* - returns null if no match (for asserting absence)
// queryAllBy* - returns empty array if no match
// findBy* - async, waits for element (for async rendering)
// findAllBy* - async, returns array

Testing Query Priority

Why getByRole is King

getByRole is the most versatile and accessible query. It matches elements by their ARIA role, which reflects how assistive technologies see your page.

// Instead of getByText('Submit') - fragile if text changes
// Use getByRole('button', { name: 'Submit' }) - resilient
 
render(<button onClick={handleSubmit}>Submit Order</button>)
 
// âś… Best
screen.getByRole('button', { name: 'Submit Order' })
 
// âś… Good
screen.getByText('Submit Order')
 
// ❌ Avoid - implementation detail
screen.getByTestId('submit-button')

Architecture and Design Patterns

Test Structure with Arrange-Act-Assert

describe('TodoList', () => {
  test('adds a new todo when form is submitted', async () => {
    // Arrange
    const user = userEvent.setup()
    render(<TodoList />)
 
    // Act
    const input = screen.getByRole('textbox', { name: /add todo/i })
    await user.type(input, 'Buy groceries')
    await user.click(screen.getByRole('button', { name: /add/i }))
 
    // Assert
    expect(screen.getByText('Buy groceries')).toBeInTheDocument()
  })
})

Testing with Custom Render Functions

Create custom render functions that wrap components with necessary providers:

import { render, RenderOptions } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AuthProvider } from './auth-context'
 
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
  initialRoute?: string
  user?: User | null
}
 
function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: { retry: false, gcTime: 0 },
      mutations: { retry: false },
    },
  })
}
 
function customRender(
  ui: React.ReactElement,
  options: CustomRenderOptions = {}
) {
  const { initialRoute = '/', user = null, ...renderOptions } = options
 
  window.history.pushState({}, '', initialRoute)
 
  const queryClient = createTestQueryClient()
 
  function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <BrowserRouter>
          <AuthProvider initialUser={user}>
            {children}
          </AuthProvider>
        </BrowserRouter>
      </QueryClientProvider>
    )
  }
 
  return {
    ...render(ui, { wrapper: Wrapper, ...renderOptions }),
    queryClient,
  }
}
 
// Re-export everything
export * from '@testing-library/react'
export { customRender as render }

Mock Service Worker for API Mocking

MSW intercepts network requests at the network level, providing more realistic mocks than jest.mock('fetch').

// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
 
export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'John Doe', email: 'john@example.com' },
      { id: 2, name: 'Jane Smith', email: 'jane@example.com' },
    ])
  }),
 
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params
    return HttpResponse.json({
      id: Number(id),
      name: 'John Doe',
      email: 'john@example.com',
    })
  }),
 
  http.post('/api/users', async ({ request }) => {
    const body = await request.json()
    return HttpResponse.json(
      { id: 3, ...body },
      { status: 201 }
    )
  }),
]
 
// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
 
export const server = setupServer(...handlers)
 
// setupTests.ts
import { server } from './src/mocks/server'
 
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

Step-by-Step Implementation

Testing Forms

import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
 
test('submits form with validation', async () => {
  const user = userEvent.setup()
  const onSubmit = jest.fn()
  render(<LoginForm onSubmit={onSubmit} />)
 
  // Type email
  await user.type(
    screen.getByRole('textbox', { name: /email/i }),
    'test@example.com'
  )
 
  // Type password
  await user.type(
    screen.getByLabelText(/password/i),
    'secretpassword'
  )
 
  // Submit
  await user.click(screen.getByRole('button', { name: /sign in/i }))
 
  // Assert
  await waitFor(() => {
    expect(onSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'secretpassword',
    })
  })
})
 
test('shows validation errors for empty fields', async () => {
  const user = userEvent.setup()
  render(<LoginForm onSubmit={jest.fn()} />)
 
  await user.click(screen.getByRole('button', { name: /sign in/i }))
 
  expect(screen.getByText(/email is required/i)).toBeInTheDocument()
  expect(screen.getByText(/password is required/i)).toBeInTheDocument()
})

Testing Async Operations

test('loads and displays user data', async () => {
  server.use(
    http.get('/api/users/1', () => {
      return HttpResponse.json({
        id: 1,
        name: 'John Doe',
        email: 'john@example.com',
        role: 'Admin',
      })
    })
  )
 
  render(<UserProfile userId="1" />)
 
  // Shows loading state initially
  expect(screen.getByText(/loading/i)).toBeInTheDocument()
 
  // Wait for data to load
  expect(await screen.findByText('John Doe')).toBeInTheDocument()
  expect(screen.getByText('john@example.com')).toBeInTheDocument()
  expect(screen.getByText('Admin')).toBeInTheDocument()
 
  // Loading state should be gone
  expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
})
 
test('handles API errors gracefully', async () => {
  server.use(
    http.get('/api/users/1', () => {
      return HttpResponse.json(
        { message: 'User not found' },
        { status: 404 }
      )
    })
  )
 
  render(<UserProfile userId="1" />)
 
  expect(await screen.findByText(/user not found/i)).toBeInTheDocument()
  expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument()
})

Testing User Interactions

test('toggles todo completion', async () => {
  const user = userEvent.setup()
  render(<TodoItem todo={{ id: 1, text: 'Learn RTL', completed: false }} />)
 
  const checkbox = screen.getByRole('checkbox', { name: /learn rtl/i })
 
  expect(checkbox).not.toBeChecked()
 
  await user.click(checkbox)
  expect(checkbox).toBeChecked()
 
  await user.click(checkbox)
  expect(checkbox).not.toBeChecked()
})
 
test('supports keyboard navigation', async () => {
  const user = userEvent.setup()
  render(
    <nav>
      <a href="/home">Home</a>
      <a href="/about">About</a>
      <a href="/contact">Contact</a>
    </nav>
  )
 
  // Tab to first link
  await user.tab()
  expect(screen.getByRole('link', { name: 'Home' })).toHaveFocus()
 
  // Tab to second link
  await user.tab()
  expect(screen.getByRole('link', { name: 'About' })).toHaveFocus()
 
  // Tab to third link
  await user.tab()
  expect(screen.getByRole('link', { name: 'Contact' })).toHaveFocus()
})

Testing Workflow

Testing with React Query

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
 
test('useUser hook fetches user data', async () => {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  })
 
  const wrapper = ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
 
  const { result } = renderHook(() => useUser('1'), { wrapper })
 
  expect(result.current.isLoading).toBe(true)
 
  await waitFor(() => {
    expect(result.current.isSuccess).toBe(true)
  })
 
  expect(result.current.data).toEqual({
    id: 1,
    name: 'John Doe',
    email: 'john@example.com',
  })
})

Real-World Use Cases

Use Case 1: Testing a Shopping Cart Flow

describe('Shopping Cart', () => {
  test('complete purchase flow', async () => {
    const user = userEvent.setup()
    render(<App />, { initialRoute: '/products' })
 
    // Browse products
    expect(await screen.findByText(/featured products/i)).toBeInTheDocument()
 
    // Add item to cart
    const product = screen.getByRole('heading', { name: /wireless headphones/i })
    await user.click(
      screen.getByRole('button', {
        name: /add wireless headphones to cart/i,
      })
    )
 
    // Navigate to cart
    await user.click(screen.getByRole('link', { name: /cart \(1\)/i }))
 
    // Verify cart contents
    expect(await screen.findByText('Wireless Headphones')).toBeInTheDocument()
    expect(screen.getByText('$99.99')).toBeInTheDocument()
 
    // Proceed to checkout
    await user.click(screen.getByRole('button', { name: /checkout/i }))
 
    // Fill checkout form
    await user.type(screen.getByLabelText(/card number/i), '4242424242424242')
    await user.type(screen.getByLabelText(/expiry/i), '12/25')
    await user.type(screen.getByLabelText(/cvc/i), '123')
 
    // Submit order
    await user.click(screen.getByRole('button', { name: /place order/i }))
 
    // Verify success
    expect(
      await screen.findByText(/order confirmed/i)
    ).toBeInTheDocument()
    expect(screen.getByText(/thank you for your purchase/i)).toBeInTheDocument()
  })
})

Use Case 2: Testing Accessible Components

describe('Accessibility', () => {
  test('modal traps focus and returns focus on close', async () => {
    const user = userEvent.setup()
    render(<ModalExample />)
 
    // Open modal
    const trigger = screen.getByRole('button', { name: /open settings/i })
    await user.click(trigger)
 
    // Modal should be focused
    const modal = screen.getByRole('dialog')
    expect(modal).toHaveFocus()
 
    // Tab should cycle within modal
    await user.tab()
    expect(screen.getByRole('textbox', { name: /name/i })).toHaveFocus()
 
    // Close with Escape
    await user.keyboard('{Escape}')
 
    // Focus should return to trigger
    expect(trigger).toHaveFocus()
    expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
  })
 
  test('form announces errors to screen readers', async () => {
    const user = userEvent.setup()
    render(<RegistrationForm />)
 
    await user.click(screen.getByRole('button', { name: /register/i }))
 
    // Error messages should be associated with inputs
    const emailInput = screen.getByRole('textbox', { name: /email/i })
    const emailError = screen.getByText(/email is required/i)
 
    expect(emailInput).toHaveAttribute('aria-describedby', emailError.id)
    expect(emailInput).toHaveAttribute('aria-invalid', 'true')
  })
})

Use Case 3: Testing Complex State Transitions

describe('Multi-Step Form', () => {
  test('progresses through steps with validation', async () => {
    const user = userEvent.setup()
    render(<MultiStepForm />)
 
    // Step 1: Personal Info
    expect(screen.getByText(/step 1.*personal info/i)).toBeInTheDocument()
 
    await user.type(screen.getByLabelText(/first name/i), 'John')
    await user.type(screen.getByLabelText(/last name/i), 'Doe')
    await user.click(screen.getByRole('button', { name: /next/i }))
 
    // Step 2: Address
    expect(await screen.findByText(/step 2.*address/i)).toBeInTheDocument()
 
    await user.type(screen.getByLabelText(/street/i), '123 Main St')
    await user.type(screen.getByLabelText(/city/i), 'Springfield')
    await user.click(screen.getByRole('button', { name: /next/i }))
 
    // Step 3: Review
    expect(screen.getByText(/step 3.*review/i)).toBeInTheDocument()
    expect(screen.getByText('John Doe')).toBeInTheDocument()
    expect(screen.getByText('123 Main St')).toBeInTheDocument()
 
    // Can go back
    await user.click(screen.getByRole('button', { name: /back/i }))
    expect(await screen.findByText(/step 2.*address/i)).toBeInTheDocument()
  })
})

Best Practices for Production

  1. Use userEvent over fireEvent: userEvent simulates real user interactions with all relevant events (focus, blur, keydown, keyup, input, change). fireEvent only dispatches a single event, missing intermediate steps.

  2. Follow the query priority: Always try getByRole first, then getByLabelText, getByPlaceholderText, getByText, and only use getByTestId as a last resort.

  3. Test what users see: Assert on visible text, enabled/disabled states, and element presence. Don't assert on state values, props, or internal methods.

  4. Use findBy* for async assertions: When you need to wait for an element to appear (after an API call or animation), use findBy* which returns a promise that resolves when the element is found.

  5. Clean up in afterEach: RTL automatically cleans up after each test, but if you're using global state or side effects, add explicit cleanup.

  6. Mock at the network level: Use MSW to mock API calls at the network level. This keeps your tests closer to real behavior and lets you test error scenarios easily.

  7. Write integration tests over unit tests: Test complete features rather than isolated components. A test that renders a page, fills a form, and verifies the result catches more bugs than testing individual components in isolation.

  8. Use screen for all queries: screen automatically uses the document body and provides better error messages. Avoid destructuring from render().

Common Pitfalls and Solutions

PitfallImpactSolution
Using getByTestId as defaultTests miss accessibility issuesFollow the query priority: role > label > text > testid
Using fireEvent instead of userEventMissing event sequences, false positivesAlways use userEvent.setup() for interactions
Not awaiting async operationsFlaky tests, false positivesUse findBy* or waitFor for async assertions
Testing implementation detailsBrittle tests that break on refactorsTest user-visible behavior, not internal state
Over-mockingTests pass but code fails in productionMock only external dependencies, not internal modules
Snapshot testingBrittle, provides false confidencePrefer explicit assertions on specific elements

Performance Optimization

// Use screen queries for better error messages
// ❌ Bad
const { getByText } = render(<Component />)
expect(getByText('Hello')).toBeInTheDocument()
 
// âś… Good
render(<Component />)
expect(screen.getByText('Hello')).toBeInTheDocument()
 
// Batch async assertions to reduce flakiness
await waitFor(() => {
  expect(screen.getByText('Loaded')).toBeInTheDocument()
  expect(screen.getByRole('button', { name: /save/i })).toBeEnabled()
})
 
// Use findBy for single async elements (cleaner than waitFor + getBy)
expect(await screen.findByRole('alert')).toHaveTextContent(/error/i)
 
// Optimize test setup with beforeAll/beforeEach
beforeAll(() => server.listen())
afterEach(() => {
  server.resetHandlers()
  queryClient.clear()
})
afterAll(() => server.close())

Comparison with Alternatives

FeatureReact Testing LibraryEnzymeCypressPlaywright
Testing philosophyUser-centricImplementationE2EE2E
SpeedFastFastSlowSlow
BrowserJSDOMJSDOMRealReal
API mockingMSWJestNetworkNetwork
Component testingYesYesYesYes
AccessibilityBuilt-inNoVia pluginsBuilt-in
MaintenanceActiveDeprecatedActiveActive
Learning curveLowMediumMediumMedium

When to choose each:

  • RTL: Component and integration tests, fast feedback, CI/CD pipelines
  • Cypress/Playwright: Full E2E tests, cross-browser testing, visual regression
  • Avoid Enzyme: It's deprecated and encourages testing implementation details

Advanced Patterns and Techniques

Testing Custom Hooks

import { renderHook, act } from '@testing-library/react'
 
test('useCounter hook', () => {
  const { result } = renderHook(() => useCounter(0))
 
  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)
})
 
// With providers
test('useAuth with context', () => {
  const wrapper = ({ children }: { children: React.ReactNode }) => (
    <AuthProvider>{children}</AuthProvider>
  )
 
  const { result } = renderHook(() => useAuth(), { wrapper })
 
  expect(result.current.user).toBeNull()
 
  act(() => {
    result.current.login({ email: 'test@test.com', password: 'pass' })
  })
 
  expect(result.current.user).not.toBeNull()
})

Testing Drag and Drop

test('reorders items via drag and drop', async () => {
  const user = userEvent.setup()
  render(<DraggableList items={['A', 'B', 'C']} />)
 
  const items = screen.getAllByRole('listitem')
 
  // Simulate drag from first to third position
  await user.pointer([
    { keys: '[MouseLeft>]', target: items[0] },
    { target: items[2] },
    { keys: '[/MouseLeft]', target: items[2] },
  ])
 
  // Verify new order
  const reordered = screen.getAllByRole('listitem')
  expect(reordered[0]).toHaveTextContent('B')
  expect(reordered[1]).toHaveTextContent('C')
  expect(reordered[2]).toHaveTextContent('A')
})

Testing with Context Providers

function renderWithProviders(
  ui: React.ReactElement,
  {
    theme = 'light',
    locale = 'en',
    user = null,
    ...options
  } = {}
) {
  function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <ThemeProvider defaultTheme={theme}>
        <LocaleProvider locale={locale}>
          <AuthProvider initialUser={user}>
            {children}
          </AuthProvider>
        </LocaleProvider>
      </ThemeProvider>
    )
  }
 
  return render(ui, { wrapper: Wrapper, ...options })
}

Testing Strategies

The Testing Trophy

Kent C. Dodds advocates the "Testing Trophy"—a balance of different test types:

  1. Static Analysis (TypeScript, ESLint): Catches typos and type errors
  2. Unit Tests: Test pure functions and utilities in isolation
  3. Integration Tests (RTL sweet spot): Test components working together
  4. E2E Tests: Test critical user flows through the entire application

Focus your effort on integration tests—they provide the best confidence-to-effort ratio.

// Integration test: tests the feature, not the component
test('user can add item to cart and see updated total', async () => {
  const user = userEvent.setup()
  render(<ProductPage productId="123" />)
 
  // Wait for product to load
  await screen.findByText(/wireless headphones/i)
 
  // Add to cart
  await user.click(screen.getByRole('button', { name: /add to cart/i }))
 
  // Verify cart updated
  expect(screen.getByRole('status')).toHaveTextContent('Cart: $99.99')
 
  // Navigate to cart
  await user.click(screen.getByRole('link', { name: /view cart/i }))
 
  // Verify cart contents
  expect(await screen.findByText('Wireless Headboxes')).toBeInTheDocument()
  expect(screen.getByText('$99.99')).toBeInTheDocument()
})

Future Outlook

React Testing Library continues to evolve with React's concurrent features. The library has added support for act warnings, concurrent rendering, and Suspense testing. As React Server Components become more prevalent, testing patterns will shift—server components may be tested through their rendered output, while client components continue to be tested with RTL.

The trend toward user-centric testing is accelerating. Tools like Playwright and Cypress are adding component testing capabilities, while RTL remains the standard for fast, reliable component and integration tests. Understanding both RTL for fast feedback and E2E tools for critical flows gives you comprehensive test coverage.

Conclusion

React Testing Library is more than a testing utility—it's a philosophy that puts the user first. By testing components the way users interact with them, you build confidence that your application works correctly while maintaining tests that survive refactoring.

Key takeaways:

  1. Follow the query priority: getByRole > getByLabelText > getByText > getByTestId
  2. Use userEvent for realistic user interactions that fire all relevant events
  3. Use findBy* for async assertions instead of mixing waitFor and getBy*
  4. Mock at the network level with MSW for realistic API testing
  5. Write integration tests that test complete features, not isolated components
  6. Test accessibility by using accessible queries and verifying ARIA attributes

Start by converting your most brittle tests to use RTL's query priority and userEvent. You'll find that tests become more readable, more resilient, and catch real bugs instead of false positives. The investment in learning RTL properly will pay dividends throughout your React development career.