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

Testing React Components: React Testing Library Guide

Write better React tests: query methods, user events, async testing, and testing philosophy.

TestingReactReact Testing LibraryFrontend

By MinhVo

Introduction

React Testing Library (RTL) has fundamentally shifted how developers approach testing React applications. Created by Kent C. Dodds, RTL embodies a simple philosophy: test your components the way users interact with them. Instead of testing implementation details like component state, internal methods, or lifecycle hooks, RTL encourages tests that find elements by their accessible roles, text content, or labels—and then interact with them as a real user would.

This user-centric approach produces tests that are resilient to refactoring, meaningful to non-technical stakeholders, and aligned with how your application actually delivers value. When you rename internal state variables, restructure component hierarchies, or migrate from class components to hooks, RTL tests continue passing because they verify observable behavior rather than internal structure. This guide covers every aspect of testing React components with RTL, from basic queries to complex async scenarios.

React Testing

Understanding React Testing Library: Core Concepts

RTL provides a lightweight utility layer on top of @testing-library/dom that encourages good testing practices for React applications. The library's API is deliberately minimal—there are only a handful of query methods, and each one prioritizes accessibility over convenience. This design choice ensures that when tests fail due to query issues, it often indicates real accessibility problems in the application.

The core principle is simple: the more your tests resemble the way your software is used, the more confidence they can give you. This means avoiding queries that rely on component structure (like CSS classes or component names) and instead querying for elements by their role in the user interface: buttons, headings, text inputs, links, and other semantic elements.

Query Priority

RTL provides three categories of queries, each serving different testing needs. Understanding when to use each category is essential for writing effective, maintainable tests that catch real bugs without creating false positives.

Priority 1: Queries everyone can use — These queries work for all users regardless of assistive technology. getByRole is the most recommended query because it queries by ARIA role, which matches how screen readers and other assistive technologies interpret the page. getByText finds elements by their visible text content, and getByLabelText connects form inputs to their labels.

Priority 2: Semantic queries — getByAltText for images and getByTitle for elements with title attributes provide semantic meaning when priority 1 queries aren't applicable.

Priority 3: Test IDs — getByTestId queries by data-testid attributes. This escape hatch should be reserved for cases where semantic queries can't reach the element, such as dynamically generated content or complex composite widgets.

import { render, screen } from '@testing-library/react';
 
// Priority 1: Preferred queries
screen.getByRole('button', { name: 'Submit' });
screen.getByText('Welcome back');
screen.getByLabelText('Email address');
screen.getByPlaceholderText('Search...');
 
// Priority 2: Semantic queries
screen.getByAltText('Company logo');
screen.getByTitle('Close dialog');
 
// Priority 3: Last resort
screen.getByTestId('loading-spinner');

Architecture and Design Patterns

Effective RTL test architecture co-locates test files with their components and uses custom render functions to reduce boilerplate. The render function accepts component JSX and optional configuration like providers for context, routing, and state management. Wrapping render in a custom utility that includes common providers prevents every test from repeating the same setup.

// test-utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '../contexts/AuthContext';
 
const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: false } },
});
 
function AllProviders({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <AuthProvider>
        <BrowserRouter>
          {children}
        </BrowserRouter>
      </AuthProvider>
    </QueryClientProvider>
  );
}
 
function customRender(ui: React.ReactElement, options?: RenderOptions) {
  return render(ui, { wrapper: AllProviders, ...options });
}
 
export { customRender as render, screen };

Testing Component Behavior

RTL tests focus on what the component does in response to user interactions, not how it achieves that behavior internally. This means testing that clicking a button triggers an action, that submitting a form validates input, or that loading data displays results—without caring whether the component uses useState, useReducer, or external state management.

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
 
describe('Counter', () => {
  it('should increment count when increment button is clicked', async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={0} />);
 
    expect(screen.getByText('Count: 0')).toBeInTheDocument();
 
    await user.click(screen.getByRole('button', { name: 'Increment' }));
 
    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });
 
  it('should decrement count when decrement button is clicked', async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={5} />);
 
    await user.click(screen.getByRole('button', { name: 'Decrement' }));
 
    expect(screen.getByText('Count: 4')).toBeInTheDocument();
  });
 
  it('should not decrement below zero', async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={0} />);
 
    await user.click(screen.getByRole('button', { name: 'Decrement' }));
 
    expect(screen.getByText('Count: 0')).toBeInTheDocument();
  });
});

Step-by-Step Implementation

Setting up RTL for a React project requires installing the library along with Jest DOM matchers that extend Jest's assertion API with DOM-specific assertions like toBeInTheDocument(), toHaveTextContent(), and toBeDisabled(). The user-event library simulates realistic user interactions including typing, clicking, hovering, and keyboard navigation.

# Install React Testing Library and utilities
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
 
# For projects using Vitest
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event vitest @testing-library/jest-dom

The setup file imports and configures Jest DOM matchers, making DOM assertions available globally across all test files:

// setupTests.ts
import '@testing-library/jest-dom';

Testing form components demonstrates RTL's query hierarchy and user interaction simulation. Forms involve multiple input types, validation logic, submission handling, and error display—each requiring specific query strategies and interaction patterns.

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RegistrationForm } from './RegistrationForm';
 
describe('RegistrationForm', () => {
  it('should submit form with valid data', async () => {
    const onSubmit = jest.fn();
    const user = userEvent.setup();
    render(<RegistrationForm onSubmit={onSubmit} />);
 
    await user.type(screen.getByLabelText('Full Name'), 'Alice Johnson');
    await user.type(screen.getByLabelText('Email'), 'alice@example.com');
    await user.type(screen.getByLabelText('Password'), 'SecurePass123!');
    await user.click(screen.getByRole('checkbox', { name: 'I agree to terms' }));
    await user.click(screen.getByRole('button', { name: 'Create Account' }));
 
    await waitFor(() => {
      expect(onSubmit).toHaveBeenCalledWith({
        fullName: 'Alice Johnson',
        email: 'alice@example.com',
        password: 'SecurePass123!',
        acceptedTerms: true,
      });
    });
  });
 
  it('should display validation error for invalid email', async () => {
    const user = userEvent.setup();
    render(<RegistrationForm onSubmit={jest.fn()} />);
 
    await user.type(screen.getByLabelText('Email'), 'invalid-email');
    await user.click(screen.getByRole('button', { name: 'Create Account' }));
 
    expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument();
  });
 
  it('should disable submit button while submitting', async () => {
    const slowSubmit = () => new Promise(resolve => setTimeout(resolve, 1000));
    const user = userEvent.setup();
    render(<RegistrationForm onSubmit={slowSubmit} />);
 
    await user.type(screen.getByLabelText('Full Name'), 'Alice');
    await user.type(screen.getByLabelText('Email'), 'alice@example.com');
    await user.type(screen.getByLabelText('Password'), 'SecurePass123!');
    await user.click(screen.getByRole('button', { name: 'Create Account' }));
 
    expect(screen.getByRole('button', { name: 'Creating Account...' })).toBeDisabled();
  });
});

Testing async data loading requires handling React Query, SWR, or custom fetch hooks that trigger loading states, error states, and success states in sequence. RTL's waitFor utility polls the DOM until assertions pass or timeout, accommodating async state transitions without arbitrary delays.

import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { UserProfile } from './UserProfile';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
 
function renderWithQuery(ui: React.ReactElement) {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });
  return render(
    <QueryClientProvider client={queryClient}>
      {ui}
    </QueryClientProvider>
  );
}
 
describe('UserProfile', () => {
  it('should display loading state initially', () => {
    renderWithQuery(<UserProfile userId="123" />);
 
    expect(screen.getByRole('status')).toBeInTheDocument();
    expect(screen.getByText('Loading profile...')).toBeInTheDocument();
  });
 
  it('should display user data after loading', async () => {
    renderWithQuery(<UserProfile userId="123" />);
 
    await waitFor(() => {
      expect(screen.getByText('Alice Johnson')).toBeInTheDocument();
      expect(screen.getByText('alice@example.com')).toBeInTheDocument();
      expect(screen.getByRole('img', { name: 'Alice Johnson' })).toHaveAttribute(
        'src',
        expect.stringContaining('avatar')
      );
    });
  });
 
  it('should display error state when fetch fails', async () => {
    server.use(
      http.get('/api/users/123', () => {
        return new HttpResponse(null, { status: 500 });
      })
    );
 
    renderWithQuery(<UserProfile userId="123" />);
 
    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('Failed to load profile');
      expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
    });
  });
});

Real-World Use Cases

Use Case 1: Testing Complex Dropdowns and Autocomplete

Autocomplete components involve keyboard navigation, debounced filtering, loading states, and selection handling. RTL's userEvent.keyboard method simulates arrow key navigation through options, Enter key selection, and Escape key dismissal. Testing these interactions ensures the component works correctly for keyboard-dependent users and screen reader users alike.

Use Case 2: Testing Multi-Step Wizards

Multi-step form wizards require testing step navigation, data persistence between steps, validation at each step, and final submission with accumulated data. RTL's query and interaction APIs verify that forward/backward navigation preserves form values, validation messages appear at the correct steps, and the final submission contains all collected data.

Use Case 3: Testing Context-Dependent Rendering

Components that render differently based on authentication state, feature flags, or user permissions require testing multiple rendering paths. Custom render functions with different provider configurations enable testing each state without modifying the component, verifying that unauthorized users see access denied messages while admins see management controls.

Best Practices for Production

  1. Prefer getByRole Over Other Queries: ARIA roles match how assistive technologies interpret the UI. Using getByRole ensures your tests verify accessibility and catch real user-facing issues rather than implementation details.

  2. Use userEvent Over fireEvent: The user-event library simulates realistic user interactions including focus changes, keyboard events, and input sequencing. The older fireEvent dispatches synthetic events that skip browser-native behavior.

  3. Avoid Testing Implementation Details: Don't assert on component state, internal method calls, or hook return values. Test what the user sees and does—visible text, enabled/disabled states, and navigation flows.

  4. Use screen for Queries: The screen object automatically scoped to the rendered component and provides better error messages that list available elements when queries fail.

  5. Test Accessibility by Default: RTL's query hierarchy naturally encourages accessible markup. If getByRole can't find your button, it likely means the button lacks proper semantic markup that screen readers depend on.

  6. Clean Up Side Effects: RTL auto-cleans the DOM after each test, but side effects like timers, event listeners, and API calls require manual cleanup. Use afterEach or cleanup to prevent memory leaks across test runs.

  7. Use waitFor for Async Assertions: Replace arbitrary setTimeout delays with waitFor blocks that poll the DOM until conditions are met. This approach is faster and more reliable than fixed delays.

  8. Test Error States Explicitly: Don't just test the happy path. Verify error messages, retry buttons, and degraded states appear correctly when API calls fail, validation fails, or external dependencies are unavailable.

Common Pitfalls and Solutions

PitfallImpactSolution
Using getByTestId as default queryTests don't verify accessibility; markup issues hiddenStart with getByRole, escalate to getByText, reserve getByTestId for edge cases
Not awaiting async operationsTests pass with stale DOM assertionsWrap async assertions in waitFor or use findBy queries that auto-wait
Testing component state directlyTests break on every refactorAssert on rendered output and user-visible behavior instead
Using fireEvent instead of userEventMissing real browser event sequences like focus, input, blurUse userEvent.setup() for all user interactions
Snapshot testing large componentsHuge diffs reviewed without attention; false sense of securityKeep snapshots small and focused; prefer explicit assertions for critical content
Not wrapping with required providersComponents crash due to missing contextCreate custom render function that includes all necessary providers

Performance Optimization

RTL tests run in jsdom, a JavaScript implementation of the DOM that's slower than a real browser. For large test suites, the overhead of rendering hundreds of components can accumulate. The cleanup function unmounts components after each test, but shared module caching and efficient mock setup reduce per-test overhead.

Using within for scoped queries reduces DOM traversal time when testing components rendered within complex layouts. Instead of searching the entire document for a button, within(screen.getByTestId('sidebar')) limits the search scope, improving both performance and query accuracy.

import { within } from '@testing-library/react';
 
it('should show correct items in the sidebar', () => {
  render(<Dashboard />);
 
  const sidebar = screen.getByRole('navigation', { name: 'Main navigation' });
  const sidebarQueries = within(sidebar);
 
  expect(sidebarQueries.getByText('Dashboard')).toBeInTheDocument();
  expect(sidebarQueries.getByText('Settings')).toBeInTheDocument();
  expect(sidebarQueries.getByRole('link', { name: 'Profile' })).toHaveAttribute(
    'href',
    '/profile'
  );
});

Comparison with Alternatives

FeatureReact Testing LibraryEnzymeCypress ComponentVitest + Vue Test Utils
Testing PhilosophyUser-centricImplementation-centricBrowser-basedUser-centric
SpeedFast (jsdom)Fast (jsdom)Slow (real browser)Fast (jsdom/happy-dom)
Component Shallow RenderingNot supportedYes (shallow())Not applicableNot supported
Accessible QueriesBuilt-in (by role)ManualBuilt-inBuilt-in
Snapshot SupportYes (DOM snapshots)Yes (component snapshots)Visual snapshotsYes
Async TestingwaitFor, findByManual async handlingBuilt-in retrywaitFor, findBy
Framework SupportReactReact (legacy)React, Vue, SvelteVue

Advanced Patterns

Custom queries extend RTL's built-in methods for domain-specific testing patterns. When your application has complex custom components that don't map to standard ARIA roles, custom queries provide semantic access while maintaining the library's user-centric philosophy.

// Custom query for a complex data grid
import { buildQueries, within } from '@testing-library/react';
 
function queryAllByDataCell(container: HTMLElement, value: string) {
  return within(container).getAllByRole('cell').filter(
    cell => cell.textContent?.includes(value)
  );
}
 
const [getByDataCell, getAllByDataCell, queryByDataCell, queryAllByDataCell, findByDataCell] =
  buildQueries(queryAllByDataCell, (_, value) => `Found multiple cells with "${value}"`, (_, value) => `Unable to find cell with "${value}"`);
 
// Using the custom query
screen.getByDataCell('Alice Johnson');

Testing portals and modals requires understanding that React portals render children into a different DOM node than their parent component. RTL's screen queries search the entire document including portal-rendered elements, but you should assert that focus management works correctly—modal focus trap, focus restoration on close, and keyboard dismissal.

Testing Strategies

The Testing Trophy applies directly to React component testing. Integration tests that render multiple components together and verify their interactions provide the highest confidence per test written. Testing a form component with its validation logic, error display, and submission handler together catches more bugs than testing each piece in isolation.

For React applications, the recommended testing mix allocates approximately 10% of tests to unit tests for complex utility functions, 70% to component integration tests using RTL, and 20% to end-to-end tests with Playwright or Cypress that exercise complete user flows through the application.

Future Outlook

React Testing Library continues evolving alongside React's concurrent features and server components. The React 19 use() hook and Server Components present new testing challenges that RTL is addressing through improved async testing utilities and server-side rendering test support.

The broader testing library ecosystem, including Testing Library implementations for Vue, Svelte, Angular, and Preact, ensures that user-centric testing principles remain consistent across the JavaScript framework landscape. As the web platform adds new semantic elements and ARIA attributes, RTL's query methods automatically support them, keeping tests aligned with accessibility standards.

Conclusion

React Testing Library's user-centric philosophy has transformed how teams test React applications, producing tests that catch real bugs, survive refactoring, and document expected behavior. By testing components the way users interact with them, teams build confidence that their applications work correctly for everyone, including users of assistive technologies.

Key takeaways for effective RTL adoption:

  1. Query by role first — getByRole verifies both functionality and accessibility simultaneously
  2. Simulate real interactions — userEvent produces realistic browser behavior that fireEvent misses
  3. Test async with waitFor — replace arbitrary delays with polling assertions for reliable async testing
  4. Create custom render wrappers — centralize provider setup to reduce boilerplate across test files
  5. Test error states alongside happy paths — production failures are inevitable; verify your UI handles them gracefully

Testing React components effectively requires a mindset shift from implementation-focused to user-focused testing. This shift takes practice, but the resulting test suite provides genuine confidence that your application delivers value to real users under real conditions. Start with your most critical user flows, establish patterns that your team finds natural, and expand coverage systematically.

For deeper exploration, consult the Testing Library documentation, Kent C. Dodds' Common Testing Mistakes, and the Testing Library guiding principles.