Testing Fundamentals

Testing React Components

Test React components the way users interact with them — through rendered output and user events.

React Testing Library Philosophy

React Testing Library is built on one principle: test your components the way users interact with them.

Users don't care about component state. They care about what they see and what they can do. Testing Library encourages you to query the DOM the way a user would — by visible text, by role, by label — not by implementation details like state variables or internal methods.

Setup

bash
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event

Configure jsdom environment and extend matchers:

typescript
// vitest.config.ts
export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: './src/test/setup.ts',
  },
});

// src/test/setup.ts
import '@testing-library/jest-dom';

Rendering and Querying

typescript
import { render, screen } from '@testing-library/react';
import { Button } from './Button';

it('renders the button text', () => {
  render(<Button>Click me</Button>);

  // getByText — finds element containing text
  expect(screen.getByText('Click me')).toBeInTheDocument();
});

Query Priority

Use queries in this order (most to least semantic):

  1. getByRole — finds by ARIA role (button, heading, textbox, link). Preferred.
  2. getByLabelText — finds form inputs by their label. Second choice.
  3. getByText — finds by visible text content.
  4. getByPlaceholderText — finds input by placeholder.
  5. getByTestId — last resort, requires adding data-testid attributes.
typescript
// Preferred — semantic role queries
screen.getByRole('button', { name: 'Submit' })
screen.getByRole('heading', { name: 'Login' })
screen.getByRole('textbox', { name: 'Email' })

// Good — label-based
screen.getByLabelText('Email address')

// Avoid when semantic queries are possible
screen.getByTestId('submit-button')

User Interactions

Use userEvent from @testing-library/user-event for realistic user interactions:

typescript
import userEvent from '@testing-library/user-event';

it('submits the login form', async () => {
  const user = userEvent.setup();
  render(<LoginForm onSubmit={mockSubmit} />);

  await user.type(screen.getByLabelText('Email'), 'alice@example.com');
  await user.type(screen.getByLabelText('Password'), 'SecurePass1!');
  await user.click(screen.getByRole('button', { name: 'Sign In' }));

  expect(mockSubmit).toHaveBeenCalledWith({
    email: 'alice@example.com',
    password: 'SecurePass1!',
  });
});

Async Testing

Many components make async operations (fetches, timers). Use findBy queries (return promises) or waitFor:

typescript
it('shows user data after loading', async () => {
  render(<UserProfile userId="123" />);

  // Assert loading state
  expect(screen.getByText('Loading...')).toBeInTheDocument();

  // Wait for the async operation to complete
  const username = await screen.findByText('Alice');
  expect(username).toBeInTheDocument();

  // Loading spinner should be gone
  expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});

Complete Component Test Example

typescript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { TodoItem } from './TodoItem';

const mockTodo = { id: '1', text: 'Buy groceries', completed: false };

describe('TodoItem', () => {
  it('renders the todo text', () => {
    render(<TodoItem todo={mockTodo} onToggle={vi.fn()} onDelete={vi.fn()} />);
    expect(screen.getByText('Buy groceries')).toBeInTheDocument();
  });

  it('calls onToggle when checkbox is clicked', async () => {
    const user = userEvent.setup();
    const onToggle = vi.fn();
    render(<TodoItem todo={mockTodo} onToggle={onToggle} onDelete={vi.fn()} />);

    await user.click(screen.getByRole('checkbox'));
    expect(onToggle).toHaveBeenCalledWith('1');
  });

  it('shows completed style when todo is done', () => {
    const completedTodo = { ...mockTodo, completed: true };
    render(<TodoItem todo={completedTodo} onToggle={vi.fn()} onDelete={vi.fn()} />);

    const text = screen.getByText('Buy groceries');
    expect(text).toHaveClass('line-through');
  });
});

Key Takeaways

  • Test what users see and do — not component state or implementation details
  • Query priority: getByRole > getByLabelText > getByText > getByTestId
  • Use userEvent for interactions — it simulates real browser events more accurately than fireEvent
  • Use findBy queries for async content — they return promises and wait for the element to appear
  • React's JSX auto-escapes HTML in renders — your test environment (jsdom) simulates the browser DOM

Example

tsx
// Complete test for a LoginForm component
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
  it('renders email and password fields', () => {
    render(<LoginForm onSubmit={vi.fn()} />);
    expect(screen.getByLabelText('Email')).toBeInTheDocument();
    expect(screen.getByLabelText('Password')).toBeInTheDocument();
  });

  it('submits with entered credentials', async () => {
    const user = userEvent.setup();
    const onSubmit = vi.fn();
    render(<LoginForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText('Email'), 'alice@example.com');
    await user.type(screen.getByLabelText('Password'), 'password123');
    await user.click(screen.getByRole('button', { name: 'Sign In' }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: 'alice@example.com',
      password: 'password123',
    });
  });

  it('shows error for empty submission', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={vi.fn()} />);

    await user.click(screen.getByRole('button', { name: 'Sign In' }));

    expect(screen.getByText('Email is required')).toBeInTheDocument();
  });
});
Try it yourself — TSX