Testing React Components with Jest and React Testing Library

testingreactjestrtlquality

background

After adopting TypeScript at Dentira, we focused on improving our test coverage. Writing comprehensive tests with Jest and React Testing Library (RTL) helped us catch bugs early and refactor with confidence. Here's what I've learned about testing React components effectively.

Why Test?

Before diving into implementation, it's important to understand why testing matters:

  • Catch Bugs Early: Find issues before they reach production
  • Refactor Safely: Tests give confidence when restructuring code
  • Document Behavior: Tests serve as living documentation
  • Prevent Regressions: Ensure new changes don't break existing functionality

Setting Up Jest and RTL

Installation

npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event jest

Configuration

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
};
// src/setupTests.ts
import '@testing-library/jest-dom';

Testing Philosophy

React Testing Library follows the principle: Test behavior, not implementation.

Focus on what users see and interact with, not internal component details.

Common Testing Patterns

1. Rendering Components

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

test('renders product name', () => {
  const product = { id: '1', name: 'Test Product', price: 99.99 };
  render(<ProductCard product={product} />);
  
  expect(screen.getByText('Test Product')).toBeInTheDocument();
});

2. User Interactions

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

test('calls onAddToCart when button is clicked', async () => {
  const onAddToCart = jest.fn();
  const product = { id: '1', name: 'Test Product' };
  
  render(<ProductCard product={product} onAddToCart={onAddToCart} />);
  
  const button = screen.getByRole('button', { name: /add to cart/i });
  await userEvent.click(button);
  
  expect(onAddToCart).toHaveBeenCalledWith('1');
});

3. Testing Async Behavior

test('displays loading state', async () => {
  render(<ProductList />);
  
  expect(screen.getByText(/loading/i)).toBeInTheDocument();
  
  await waitFor(() => {
    expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
  });
});

Best Practices

1. Use Semantic Queries

Prefer queries that mirror how users interact:

// Good
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText(/email address/i);

// Avoid if possible
screen.getByTestId('submit-button');

2. Test User Flows

Test complete user interactions, not isolated functions:

test('user can add item to cart', async () => {
  const user = userEvent.setup();
  
  render(<ShoppingFlow />);
  
  // Find and click product
  await user.click(screen.getByText('Product Name'));
  
  // Add to cart
  await user.click(screen.getByRole('button', { name: /add to cart/i }));
  
  // Verify cart updates
  expect(screen.getByText(/cart \(1\)/i)).toBeInTheDocument();
});

3. Mock External Dependencies

Mock API calls and external services:

jest.mock('../services/api', () => ({
  fetchProducts: jest.fn(() => Promise.resolve([
    { id: '1', name: 'Product 1' }
  ]))
}));

4. Clean Up After Tests

Ensure tests don't affect each other:

afterEach(() => {
  cleanup();
});

Common Patterns

Testing Forms

test('submits form with valid data', async () => {
  const onSubmit = jest.fn();
  const user = userEvent.setup();
  
  render(<LoginForm onSubmit={onSubmit} />);
  
  await user.type(screen.getByLabelText(/email/i), 'test@example.com');
  await user.type(screen.getByLabelText(/password/i), 'password123');
  await user.click(screen.getByRole('button', { name: /submit/i }));
  
  await waitFor(() => {
    expect(onSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123'
    });
  });
});

Testing Conditional Rendering

test('shows error message when API fails', async () => {
  jest.spyOn(api, 'fetchData').mockRejectedValue(new Error('API Error'));
  
  render(<DataComponent />);
  
  await waitFor(() => {
    expect(screen.getByText(/error loading data/i)).toBeInTheDocument();
  });
});

Coverage Goals

At Dentira, we aim for:

  • 80%+ code coverage for critical paths
  • 100% coverage for utility functions
  • Focus on behavior coverage rather than just line coverage
// package.json
{
  "scripts": {
    "test:coverage": "jest --coverage"
  }
}

Debugging Tests

Use screen.debug() to see what's rendered:

test('debug rendering', () => {
  render(<MyComponent />);
  screen.debug(); // Prints the rendered HTML
});

Tips for Success

  1. Write Tests Early: Test as you develop, not after
  2. Keep Tests Simple: One assertion per test when possible
  3. Use Descriptive Names: Test names should describe what they verify
  4. Refactor Tests: Don't let test code rot - refactor as needed
  5. Test Edge Cases: Don't just test the happy path

Real-World Impact

At Dentira, implementing comprehensive testing:

  • Reduced production bugs by 60%
  • Increased deployment confidence
  • Faster onboarding - tests document expected behavior
  • Easier refactoring - tests catch breaking changes

Testing is an investment that pays dividends over time. Start with critical paths and gradually expand coverage. The key is consistency and making testing part of your development workflow.