Skip to main content

Command Palette

Search for a command to run...

React Testing Library — The Right Way to Test Components

Day 32 of 40 — React System Design Series

Updated
7 min read
 React Testing Library — The Right Way to Test Components
R

Hi, I’m Richa — a Senior Frontend Engineer with 5+ years of experience building scalable, production-grade web interfaces for enterprise and consumer applications. I work primarily with React, TypeScript, and modern frontend architectures, focusing on component systems, performance, and maintainability. Most of my experience comes from building real-world products in regulated domains like banking and insurance, where clarity, reliability, and long-term ownership matter more than quick demos. Through this blog, I write about frontend engineering fundamentals, scalable UI design, problem-solving, and the lessons I’ve learned working on large codebases. My goal is to share practical insights — not shortcuts — for developers who want to grow strong engineering foundations. I also mentor early-career developers and strongly believe that curiosity, asking the right questions, and understanding why something works are more important than memorizing tools. If you’re serious about improving as an engineer, you’re in the right place.


Hey everyone, Richa here! 👋

rtl gif

Day 32. Yesterday we covered the mindset. Today: the mechanics.

React Testing Library (RTL) is built around one core idea from yesterday: test what the user sees, not how the component is implemented.

The API enforces this. There are no methods to read internal state. No way to call a component method. Everything goes through the DOM — exactly as a user would interact with it.

Let me show you what that looks like in practice.


The query hierarchy — how to find elements

RTL gives you several ways to find elements. Some are better than others:

Priority order (use higher = better):

Query When to use
getByRole Almost always — matches semantic HTML
getByLabelText Form inputs with labels
getByPlaceholderText Inputs without visible labels (last resort)
getByText Non-interactive elements with text content
getByDisplayValue Current value of input/select
getByAltText Images
getByTitle Elements with a title attribute
getByTestId Last resort — add data-testid only when nothing else works
// ❌ Avoid — brittle, implementation-dependent
const button = container.querySelector('.submit-button');
const input = wrapper.find('input[name="email"]');

// ❌ Avoid — data-testid as first resort
screen.getByTestId('submit-button');

// ✅ Use role — how assistive tech identifies it
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { name: /welcome/i });

// ✅ Use label — for form inputs
screen.getByLabelText(/email address/i);
screen.getByLabelText(/password/i);

getByRole is the gold standard. It finds elements by their ARIA role — the same way screen readers navigate. Using it in tests forces you to write accessible HTML. That's a beautiful side effect.


get vs query vs find

Each query comes in three flavours:

Prefix Returns Throws if missing Use when
get Element ✅ Yes Element should be in DOM right now
query Element or null ❌ No Asserting element is NOT in DOM
find Promise<Element> ✅ Yes (after timeout) Element appears asynchronously
// ✅ getBy — element is in DOM synchronously
const button = screen.getByRole('button', { name: /submit/i });

// ✅ queryBy — checking element is absent
expect(screen.queryByText('Error message')).not.toBeInTheDocument();

// ✅ findBy — element appears after async action
const successMessage = await screen.findByText('Profile saved!');

The most common mistake: using getBy for async content (throws immediately before it renders) or findBy for synchronous content (unnecessarily async).


A real component test — the full workflow

Let's test a login form:

// components/LoginForm.tsx
type LoginFormProps = {
  onSuccess: (user: User) => void;
};

export function LoginForm({ onSuccess }: LoginFormProps) {
  const [error, setError] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const form = e.currentTarget;
    const email = (form.elements.namedItem('email') as HTMLInputElement).value;
    const password = (form.elements.namedItem('password') as HTMLInputElement).value;

    setIsLoading(true);
    setError(null);

    try {
      const user = await authService.login({ email, password });
      onSuccess(user);
    } catch {
      setError('Invalid email or password');
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email address</label>
      <input id="email" name="email" type="email" required />

      <label htmlFor="password">Password</label>
      <input id="password" name="password" type="password" required />

      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Signing in...' : 'Sign in'}
      </button>

      {error && <p role="alert">{error}</p>}
    </form>
  );
}

Now the tests:

// components/LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { LoginForm } from './LoginForm';
import { authService } from '@/services/authService';

// Mock the auth service
vi.mock('@/services/authService');

const mockUser = { id: '1', name: 'Richa', email: 'richa@example.com' };

describe('LoginForm', () => {

  // Test 1 — happy path
  it('calls onSuccess with user after successful login', async () => {
    const onSuccess = vi.fn();
    vi.mocked(authService.login).mockResolvedValue(mockUser);

    render(<LoginForm onSuccess={onSuccess} />);

    await userEvent.type(screen.getByLabelText(/email address/i), 'richa@example.com');
    await userEvent.type(screen.getByLabelText(/password/i), 'password123');
    await userEvent.click(screen.getByRole('button', { name: /sign in/i }));

    expect(onSuccess).toHaveBeenCalledWith(mockUser);
  });

  // Test 2 — error state
  it('shows error message when login fails', async () => {
    vi.mocked(authService.login).mockRejectedValue(new Error('Invalid credentials'));

    render(<LoginForm onSuccess={vi.fn()} />);

    await userEvent.type(screen.getByLabelText(/email address/i), 'wrong@example.com');
    await userEvent.type(screen.getByLabelText(/password/i), 'wrongpassword');
    await userEvent.click(screen.getByRole('button', { name: /sign in/i }));

    expect(await screen.findByRole('alert')).toHaveTextContent('Invalid email or password');
  });

  // Test 3 — loading state
  it('disables button and shows loading text while submitting', async () => {
    // Never resolves — keeps us in loading state
    vi.mocked(authService.login).mockImplementation(() => new Promise(() => {}));

    render(<LoginForm onSuccess={vi.fn()} />);

    await userEvent.type(screen.getByLabelText(/email address/i), 'richa@example.com');
    await userEvent.type(screen.getByLabelText(/password/i), 'password123');
    await userEvent.click(screen.getByRole('button', { name: /sign in/i }));

    const button = screen.getByRole('button');
    expect(button).toBeDisabled();
    expect(button).toHaveTextContent('Signing in...');
  });

});

Notice what we're testing:

  • What the user sees (error message, loading text)
  • What happens as a result of interactions (onSuccess called)
  • Accessible queries throughout (getByLabelText, getByRole)

Not tested:

  • Internal state (isLoading, error)
  • Which service method was called internally (except as a way to set up mock behaviour)

userEvent vs fireEvent

Always prefer userEvent over fireEvent:

// ❌ fireEvent — fires individual DOM events, doesn't simulate real interaction
fireEvent.change(input, { target: { value: 'hello' } });
fireEvent.click(button);

// ✅ userEvent — simulates real user behaviour (focus, type each character, blur, etc.)
await userEvent.type(input, 'hello');
await userEvent.click(button);

userEvent.type fires keydown, keypress, keyup, input, and change events for each character — just like a real user. It also handles focus and blur. fireEvent.change just fires a single change event, skipping everything else. For most tests this matters.


Wrapping with providers

Real components need providers (React Query, Router, Auth). Create a custom render helper:

// src/test/test-utils.tsx
import { render, type RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { AuthProvider } from '@/context/AuthContext';

function TestProviders({ children }: { children: ReactNode }) {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },  // don't retry in tests
      mutations: { retry: false },
    },
  });

  return (
    <QueryClientProvider client={queryClient}>
      <MemoryRouter>
        <AuthProvider>
          {children}
        </AuthProvider>
      </MemoryRouter>
    </QueryClientProvider>
  );
}

// Override render with providers
function customRender(ui: ReactElement, options?: RenderOptions) {
  return render(ui, { wrapper: TestProviders, ...options });
}

export * from '@testing-library/react';
export { customRender as render };
// In tests — import from test-utils, not @testing-library/react
import { render, screen } from '@/test/test-utils';

it('renders product list', async () => {
  render(<ProductsPage />);  // has access to QueryClient, Router, Auth
  // ...
});

Common assertions

// In the DOM
expect(element).toBeInTheDocument();

// Not in the DOM
expect(screen.queryByText('Error')).not.toBeInTheDocument();

// Visible (not hidden by CSS or aria-hidden)
expect(element).toBeVisible();

// Disabled
expect(button).toBeDisabled();
expect(button).not.toBeDisabled();

// Text content
expect(element).toHaveTextContent('Welcome, Richa');

// Attribute
expect(input).toHaveAttribute('type', 'email');
expect(input).toHaveValue('richa@example.com');

// ARIA
expect(element).toHaveAccessibleName('Email address');
expect(checkbox).toBeChecked();

Today's takeaway

React Testing Library's API steers you toward good tests by design:

  • No internal state access → you can't test implementation details
  • Role-based queries → you're forced to write accessible HTML
  • userEvent over fireEvent → more realistic interaction simulation
  • findBy for async → tests wait for async rendering naturally

The pattern: render → interact → assert on what the user sees. That's it. That pattern, consistently applied, gives you a test suite that's genuinely valuable.

test pass gif

See you tomorrow for Day 33 — Testing custom hooks with renderHook.


Day 31 — Testing Philosophy Day 33 — Testing Custom Hooks


Part of the React System Design Series — 40 days, one topic per day.

1 views