Testing & TDD

Testing React Components

35 min Lesson 11 of 35

Introduction to React Component Testing

Testing React components ensures that your UI behaves correctly under different conditions. React Testing Library (RTL) is the most popular testing solution because it encourages testing components the way users interact with them, rather than testing implementation details.

Why React Testing Library?

React Testing Library follows these guiding principles:

  • User-centric testing: Test components as users would interact with them
  • Implementation agnostic: Focus on what components do, not how they do it
  • Accessible by default: Encourages writing accessible components
  • Maintainable tests: Tests are less likely to break with refactoring
Note: React Testing Library works with any testing framework (Jest, Vitest, etc.). Most examples use Jest, but the concepts apply universally.

Setting Up React Testing Library

Install the necessary packages:

# For Create React App (already included) npm test # For custom setup npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event # For TypeScript npm install --save-dev @types/jest

Configure Jest with a setup file (setupTests.js):

// src/setupTests.js import '@testing-library/jest-dom'; // Custom matchers for better assertions // expect(element).toBeInTheDocument() // expect(element).toHaveTextContent() // expect(element).toBeVisible()

Basic Component Rendering

The render() function is the foundation of React Testing Library. It renders a component into a virtual DOM for testing:

import { render, screen } from '@testing-library/react'; import Button from './Button'; test('renders button with text', () => { render(<Button>Click Me</Button>); // Query the rendered component const button = screen.getByRole('button', { name: /click me/i }); // Assert it exists expect(button).toBeInTheDocument(); });
Tip: Use screen.debug() to print the current DOM structure to the console. This is invaluable for debugging tests.

Queries: Finding Elements

React Testing Library provides several query methods. Choose based on priority:

1. Accessible Queries (Preferred)

// getByRole - Most preferred, tests accessibility const button = screen.getByRole('button', { name: /submit/i }); const heading = screen.getByRole('heading', { level: 1 }); const textbox = screen.getByRole('textbox', { name: /username/i }); // getByLabelText - Great for form inputs const input = screen.getByLabelText(/email address/i); // getByPlaceholderText - When label isn't visible const search = screen.getByPlaceholderText(/search.../i); // getByText - Find by visible text content const error = screen.getByText(/invalid email/i);

2. Semantic Queries

// getByAltText - Images with alt text const logo = screen.getByAltText(/company logo/i); // getByTitle - Elements with title attribute const tooltip = screen.getByTitle(/help/i);

3. Test ID Queries (Last Resort)

// getByTestId - Only when other queries don't work const custom = screen.getByTestId('custom-element'); // In component: <div data-testid="custom-element">Content</div>
Warning: Avoid getByTestId when possible. It doesn't test how users interact with your app and couples tests to implementation.

Query Variants

Each query comes in three variants:

// getBy* - Throws error if not found (synchronous) const button = screen.getByRole('button'); // queryBy* - Returns null if not found (for asserting non-existence) const error = screen.queryByText(/error/i); expect(error).not.toBeInTheDocument(); // findBy* - Returns promise, waits for element (async) const async = await screen.findByText(/loading complete/i);

Testing User Interactions

Use @testing-library/user-event to simulate user actions:

import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import LoginForm from './LoginForm'; test('submits form with user credentials', async () => { const user = userEvent.setup(); const onSubmit = jest.fn(); render(<LoginForm onSubmit={onSubmit} />); // Type into inputs const emailInput = screen.getByLabelText(/email/i); const passwordInput = screen.getByLabelText(/password/i); await user.type(emailInput, 'user@example.com'); await user.type(passwordInput, 'password123'); // Click submit button const submitButton = screen.getByRole('button', { name: /login/i }); await user.click(submitButton); // Assert form was submitted expect(onSubmit).toHaveBeenCalledWith({ email: 'user@example.com', password: 'password123' }); });
Note: Always use await with user-event actions. They return promises to properly simulate asynchronous user behavior.

Common User Events

const user = userEvent.setup(); // Typing await user.type(input, 'Hello World'); await user.clear(input); // Clear input value // Clicking await user.click(button); await user.dblClick(element); await user.tripleClick(element); // Hovering await user.hover(element); await user.unhover(element); // Keyboard await user.keyboard('{Enter}'); await user.keyboard('{Shift>}A{/Shift}'); // Shift+A // Selection await user.selectOptions(select, ['option1', 'option2']); await user.deselectOptions(select, 'option1'); // File upload const file = new File(['content'], 'test.png', { type: 'image/png' }); await user.upload(fileInput, file);

Testing Asynchronous Behavior

Use waitFor and findBy* queries for async operations:

import { render, screen, waitFor } from '@testing-library/react'; import UserProfile from './UserProfile'; test('loads and displays user data', async () => { render(<UserProfile userId="123" />); // Initially shows loading state expect(screen.getByText(/loading.../i)).toBeInTheDocument(); // Wait for user data to appear (findBy returns promise) const userName = await screen.findByText(/john doe/i); expect(userName).toBeInTheDocument(); // Loading indicator should be gone expect(screen.queryByText(/loading.../i)).not.toBeInTheDocument(); }); test('handles API errors', async () => { // Mock API to return error jest.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('API Error')); render(<UserProfile userId="123" />); // Wait for error message await waitFor(() => { expect(screen.getByText(/failed to load user/i)).toBeInTheDocument(); }); });
Tip: Prefer findBy* queries over getBy* + waitFor when possible. It's more concise and readable.

Testing Accessibility

React Testing Library encourages accessible components by prioritizing role-based queries:

test('form is accessible', () => { render(<ContactForm />); // All inputs should have labels expect(screen.getByLabelText(/name/i)).toBeInTheDocument(); expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); expect(screen.getByLabelText(/message/i)).toBeInTheDocument(); // Button should have accessible name expect(screen.getByRole('button', { name: /send/i })).toBeInTheDocument(); // Check ARIA attributes const emailInput = screen.getByLabelText(/email/i); expect(emailInput).toHaveAttribute('type', 'email'); expect(emailInput).toHaveAttribute('required'); }); test('error messages are announced to screen readers', async () => { const user = userEvent.setup(); render(<ContactForm />); const submitButton = screen.getByRole('button', { name: /send/i }); await user.click(submitButton); // Error should have role="alert" for screen readers const error = screen.getByRole('alert'); expect(error).toHaveTextContent(/please fill in all fields/i); });

Mocking Props and Functions

Use Jest mocks to test component behavior in isolation:

test('calls onDelete when delete button is clicked', async () => { const user = userEvent.setup(); const onDelete = jest.fn(); render(<TaskItem task={{ id: 1, title: 'Test' }} onDelete={onDelete} />); const deleteButton = screen.getByRole('button', { name: /delete/i }); await user.click(deleteButton); expect(onDelete).toHaveBeenCalledTimes(1); expect(onDelete).toHaveBeenCalledWith(1); }); test('disables button when loading', () => { render(<SubmitButton loading={true} />); const button = screen.getByRole('button'); expect(button).toBeDisabled(); expect(button).toHaveTextContent(/loading.../i); });

Testing Conditional Rendering

test('shows success message after submission', async () => { const user = userEvent.setup(); render(<NewsletterForm />); // Initially no success message expect(screen.queryByText(/thank you/i)).not.toBeInTheDocument(); // Submit form const emailInput = screen.getByLabelText(/email/i); await user.type(emailInput, 'test@example.com'); await user.click(screen.getByRole('button', { name: /subscribe/i })); // Success message appears expect(await screen.findByText(/thank you/i)).toBeInTheDocument(); // Form is hidden expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument(); });

Debugging Tests

React Testing Library provides helpful debugging utilities:

import { render, screen } from '@testing-library/react'; test('debug example', () => { render(<MyComponent />); // Print entire DOM screen.debug(); // Print specific element const button = screen.getByRole('button'); screen.debug(button); // Print with options screen.debug(undefined, 20000); // Increase character limit // Log accessible roles screen.logTestingPlaygroundURL(); });
Exercise:

Create a SearchFilter component and write comprehensive tests:

  1. Test that the search input is rendered and accessible
  2. Test that typing updates the input value
  3. Test that clicking the search button calls an onSearch callback with the query
  4. Test that pressing Enter also triggers the search
  5. Test that the clear button resets the input and calls onSearch with empty string
  6. Test that the search button is disabled when input is empty
  7. Test error state when search query is too short (<3 characters)

Focus on accessibility: use proper labels, ARIA attributes, and role-based queries.

Best Practices

  • Query priority: getByRole > getByLabelText > getByPlaceholderText > getByText > getByTestId
  • Use user-event: Prefer userEvent over fireEvent for realistic interactions
  • Test behavior, not implementation: Don't test state or props directly
  • Avoid waitFor when possible: Use findBy* queries instead
  • Use realistic data: Test with data that matches production scenarios
  • One assertion per test: Keep tests focused and easy to debug
  • Descriptive test names: Test names should explain what and why, not how

Common Mistakes to Avoid

Warning:
  • Don't query by class names or IDs - use accessible queries
  • Don't test implementation details like component state
  • Don't use container.querySelector() - use screen queries
  • Don't forget await with async queries and user events
  • Don't use waitFor for everything - prefer findBy* queries

In the next lesson, we'll explore testing React Hooks and Context, which require special testing techniques.