DailyDevDiet

logo - dailydevdiet

Learn. Build. Innovate. Elevate your coding skills with dailydevdiet!

Chapter 22: Testing React Components

Testing React Components

Introduction

Testing React components is crucial for building reliable and maintainable applications. This chapter focuses on component-specific testing strategies, including unit testing individual components, integration testing component interactions, and testing component behavior with different props and states.

What Makes React Component Testing Unique?

React components have specific characteristics that require specialized testing approaches:

  • JSX Rendering: Components return JSX that needs to be rendered and tested
  • Props and State: Components have dynamic data that affects rendering
  • Event Handling: User interactions need to be simulated and tested
  • Lifecycle Methods: Component lifecycle behavior needs verification
  • Context and Hooks: Modern React features require specific testing patterns

Testing Philosophy for React Components

The Testing Pyramid for React

  1. Unit Tests (70%): Test individual components in isolation
  2. Integration Tests (20%): Test component interactions
  3. End-to-End Tests (10%): Test complete user workflows

What to Test

Test:

  • Component renders correctly with different props
  • State changes work as expected
  • Event handlers are called correctly
  • Conditional rendering logic
  • Component integration with context/hooks

Don’t Test:

  • Implementation details (internal state variables)
  • Third-party library functionality
  • Styling details (unless critical for functionality)

Setting Up React Testing Library

React Testing Library is the recommended testing utility for React components, focusing on testing behavior rather than implementation.

Installation

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

Basic Setup

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

// src/test-utils.js
import React from 'react';
import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';

// Custom render function for components that need Router
const customRender = (ui, options) => {
  const Wrapper = ({ children }) => (
    <BrowserRouter>{children}</BrowserRouter>
  );
 
  return render(ui, { wrapper: Wrapper, ...options });
};

export * from '@testing-library/react';
export { customRender as render };

Testing Basic Components

Simple Component Test

// src/components/Greeting.js
import React from 'react';

const Greeting = ({ name, isLoggedIn }) => {
  if (!isLoggedIn) {
    return <div>Please log in</div>;
  }
 
  return <h1>Hello, {name}!</h1>;
};

export default Greeting;
// src/components/Greeting.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';

describe('Greeting Component', () => {
  test('renders greeting message when logged in', () => {
    render(<Greeting name="John" isLoggedIn={true} />);
   
    expect(screen.getByText('Hello, John!')).toBeInTheDocument();
    expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
  });

  test('renders login message when not logged in', () => {
    render(<Greeting name="John" isLoggedIn={false} />);
   
    expect(screen.getByText('Please log in')).toBeInTheDocument();
    expect(screen.queryByText('Hello, John!')).not.toBeInTheDocument();
  });

  test('handles missing name gracefully', () => {
    render(<Greeting isLoggedIn={true} />);
   
    expect(screen.getByText('Hello, !')).toBeInTheDocument();
  });
});

Testing Components with State

Counter Component Example

// src/components/Counter.js
import React, { useState } from 'react';

const Counter = ({ initialValue = 0, step = 1 }) => {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount(count + step);
  const decrement = () => setCount(count - step);
  const reset = () => setCount(initialValue);

  return (
    <div>
      <p data-testid="count-display">Count: {count}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
};

export default Counter;
// src/components/Counter.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';

describe('Counter Component', () => {
  test('renders with initial value', () => {
    render(<Counter initialValue={5} />);
   
    expect(screen.getByTestId('count-display')).toHaveTextContent('Count: 5');
  });

  test('increments count when increment button is clicked', async () => {
    const user = userEvent.setup();
    render(<Counter />);
   
    const incrementButton = screen.getByText('Increment');
    await user.click(incrementButton);
   
    expect(screen.getByTestId('count-display')).toHaveTextContent('Count: 1');
  });

  test('decrements count when decrement button is clicked', async () => {
    const user = userEvent.setup();
    render(<Counter initialValue={5} />);
   
    const decrementButton = screen.getByText('Decrement');
    await user.click(decrementButton);
   
    expect(screen.getByTestId('count-display')).toHaveTextContent('Count: 4');
  });

  test('resets count when reset button is clicked', async () => {
    const user = userEvent.setup();
    render(<Counter initialValue={10} />);
   
    // Change the count first
    await user.click(screen.getByText('Increment'));
    expect(screen.getByTestId('count-display')).toHaveTextContent('Count: 11');
   
    // Then reset
    await user.click(screen.getByText('Reset'));
    expect(screen.getByTestId('count-display')).toHaveTextContent('Count: 10');
  });

  test('uses custom step value', async () => {
    const user = userEvent.setup();
    render(<Counter step={5} />);
   
    await user.click(screen.getByText('Increment'));
    expect(screen.getByTestId('count-display')).toHaveTextContent('Count: 5');
  });
});

Testing Event Handlers and User Interactions

Form Component Testing

// src/components/LoginForm.js
import React, { useState } from 'react';

const LoginForm = ({ onSubmit, isLoading }) => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState({});

  const validateForm = () => {
    const newErrors = {};
   
    if (!email) newErrors.email = 'Email is required';
    if (!email.includes('@')) newErrors.email = 'Invalid email format';
    if (!password) newErrors.password = 'Password is required';
    if (password.length < 6) newErrors.password = 'Password must be at least 6 characters';
   
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleSubmit = (e) => {
    e.preventDefault();
   
    if (validateForm()) {
      onSubmit({ email, password });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email:</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          aria-describedby={errors.email ? 'email-error' : undefined}
        />
        {errors.email && (
          <span id="email-error" role="alert">
            {errors.email}
          </span>
        )}
      </div>
     
      <div>
        <label htmlFor="password">Password:</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          aria-describedby={errors.password ? 'password-error' : undefined}
        />
        {errors.password && (
          <span id="password-error" role="alert">
            {errors.password}
          </span>
        )}
      </div>
     
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Logging in...' : 'Login'}
      </button>
    </form>
  );
};

export default LoginForm;
// src/components/LoginForm.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

describe('LoginForm Component', () => {
  const mockOnSubmit = jest.fn();

  beforeEach(() => {
    mockOnSubmit.mockClear();
  });

  test('renders form fields', () => {
    render(<LoginForm onSubmit={mockOnSubmit} />);
   
    expect(screen.getByLabelText('Email:')).toBeInTheDocument();
    expect(screen.getByLabelText('Password:')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument();
  });

  test('updates input values when user types', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={mockOnSubmit} />);
   
    const emailInput = screen.getByLabelText('Email:');
    const passwordInput = screen.getByLabelText('Password:');
   
    await user.type(emailInput, 'test@example.com');
    await user.type(passwordInput, 'password123');
   
    expect(emailInput).toHaveValue('test@example.com');
    expect(passwordInput).toHaveValue('password123');
  });

  test('shows validation errors for invalid input', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={mockOnSubmit} />);
   
    const submitButton = screen.getByRole('button', { name: 'Login' });
    await user.click(submitButton);
   
    expect(screen.getByText('Email is required')).toBeInTheDocument();
    expect(screen.getByText('Password is required')).toBeInTheDocument();
    expect(mockOnSubmit).not.toHaveBeenCalled();
  });

  test('validates email format', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={mockOnSubmit} />);
   
    const emailInput = screen.getByLabelText('Email:');
    const submitButton = screen.getByRole('button', { name: 'Login' });
   
    await user.type(emailInput, 'invalid-email');
    await user.click(submitButton);
   
    expect(screen.getByText('Invalid email format')).toBeInTheDocument();
  });

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

  test('shows loading state', () => {
    render(<LoginForm onSubmit={mockOnSubmit} isLoading={true} />);
   
    const submitButton = screen.getByRole('button');
    expect(submitButton).toHaveTextContent('Logging in...');
    expect(submitButton).toBeDisabled();
  });
});

Testing Components with Hooks

Custom Hook Testing

// src/hooks/useCounter.js
import { useState, useCallback } from 'react';

export const useCounter = (initialValue = 0) => {
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => {
    setCount(prev => prev + 1);
  }, []);

  const decrement = useCallback(() => {
    setCount(prev => prev - 1);
  }, []);

  const reset = useCallback(() => {
    setCount(initialValue);
  }, [initialValue]);

  return { count, increment, decrement, reset };
};
// src/hooks/useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter Hook', () => {
  test('initializes with default value', () => {
    const { result } = renderHook(() => useCounter());
   
    expect(result.current.count).toBe(0);
  });

  test('initializes with custom value', () => {
    const { result } = renderHook(() => useCounter(10));
   
    expect(result.current.count).toBe(10);
  });

  test('increments count', () => {
    const { result } = renderHook(() => useCounter());
   
    act(() => {
      result.current.increment();
    });
   
    expect(result.current.count).toBe(1);
  });

  test('decrements count', () => {
    const { result } = renderHook(() => useCounter(5));
   
    act(() => {
      result.current.decrement();
    });
   
    expect(result.current.count).toBe(4);
  });

  test('resets to initial value', () => {
    const { result } = renderHook(() => useCounter(10));
   
    act(() => {
      result.current.increment();
      result.current.increment();
    });
   
    expect(result.current.count).toBe(12);
   
    act(() => {
      result.current.reset();
    });
   
    expect(result.current.count).toBe(10);
  });
});

Testing Components with Context

Context Provider Testing

// src/contexts/ThemeContext.js
import React, { createContext, useContext, useState } from 'react';

const ThemeContext = createContext();

export const ThemeProvider = ({ children }) => {
  const [theme, setTheme] = useState('light');

  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context;
};
// src/components/ThemeButton.js
import React from 'react';
import { useTheme } from '../contexts/ThemeContext';

const ThemeButton = () => {
  const { theme, toggleTheme } = useTheme();

  return (
    <button
      onClick={toggleTheme}
      className={`theme-button theme-button--${theme}`}
    >
      Switch to {theme === 'light' ? 'dark' : 'light'} mode
    </button>
  );
};export default ThemeButton;
// src/components/ThemeButton.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../contexts/ThemeContext';
import ThemeButton from './ThemeButton';

const renderWithTheme = (component) => {
  return render(
    <ThemeProvider>
      {component}
    </ThemeProvider>
  );
};

describe('ThemeButton Component', () => {
  test('renders with initial theme', () => {
    renderWithTheme(<ThemeButton />);
   
    expect(screen.getByText('Switch to dark mode')).toBeInTheDocument();
  });

  test('toggles theme when clicked', async () => {
    const user = userEvent.setup();
    renderWithTheme(<ThemeButton />);
   
    const button = screen.getByRole('button');
   
    // Initial state
    expect(button).toHaveTextContent('Switch to dark mode');
   
    // After click
    await user.click(button);
    expect(button).toHaveTextContent('Switch to light mode');
   
    // After second click
    await user.click(button);
    expect(button).toHaveTextContent('Switch to dark mode');
  });

  test('throws error when used outside provider', () => {
    // Suppress console.error for this test
    const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
   
    expect(() => {
      render(<ThemeButton />);
    }).toThrow('useTheme must be used within ThemeProvider');
   
    consoleSpy.mockRestore();
  });
});

Testing Asynchronous Components

Component with API Calls

// src/components/UserProfile.js
import React, { useState, useEffect } from 'react';

const UserProfile = ({ userId }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchUser = async () => {
      try {
        setLoading(true);
        setError(null);
       
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) {
          throw new Error('Failed to fetch user');
        }
       
        const userData = await response.json();
        setUser(userData);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    if (userId) {
      fetchUser();
    }
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return <div>No user found</div>;

  return (
    <div data-testid="user-profile">
      <h2>{user.name}</h2>
      <p>Email: {user.email}</p>
      <p>Role: {user.role}</p>
    </div>
  );
};

export default UserProfile;
// src/components/UserProfile.test.js
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';

// Mock fetch
global.fetch = jest.fn();

describe('UserProfile Component', () => {
  beforeEach(() => {
    fetch.mockClear();
  });

  test('shows loading state initially', () => {
    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => ({ id: 1, name: 'John Doe', email: 'john@example.com', role: 'admin' })
    });

    render(<UserProfile userId={1} />);
   
    expect(screen.getByText('Loading...')).toBeInTheDocument();
  });

  test('displays user data after successful fetch', async () => {
    const mockUser = {
      id: 1,
      name: 'John Doe',
      email: 'john@example.com',
      role: 'admin'
    };

    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => mockUser
    });

    render(<UserProfile userId={1} />);

    await waitFor(() => {
      expect(screen.getByTestId('user-profile')).toBeInTheDocument();
    });

    expect(screen.getByText('John Doe')).toBeInTheDocument();
    expect(screen.getByText('Email: john@example.com')).toBeInTheDocument();
    expect(screen.getByText('Role: admin')).toBeInTheDocument();
  });

  test('displays error message on fetch failure', async () => {
    fetch.mockRejectedValueOnce(new Error('Network error'));

    render(<UserProfile userId={1} />);

    await waitFor(() => {
      expect(screen.getByText('Error: Network error')).toBeInTheDocument();
    });
  });

  test('displays error for non-ok response', async () => {
    fetch.mockResolvedValueOnce({
      ok: false,
      status: 404
    });

    render(<UserProfile userId={1} />);

    await waitFor(() => {
      expect(screen.getByText('Error: Failed to fetch user')).toBeInTheDocument();
    });
  });

  test('does not fetch when userId is not provided', () => {
    render(<UserProfile />);
   
    expect(fetch).not.toHaveBeenCalled();
    expect(screen.getByText('Loading...')).toBeInTheDocument();
  });
});

Snapshot Testing

Snapshot testing captures the rendered output of a component and compares it against a saved snapshot.

// src/components/Button.test.js
import React from 'react';
import { render } from '@testing-library/react';
import Button from './Button';

describe('Button Snapshots', () => {
  test('renders primary button snapshot', () => {
    const { container } = render(
      <Button variant="primary" size="large">
        Click me
      </Button>
    );
   
    expect(container.firstChild).toMatchSnapshot();
  });

  test('renders disabled button snapshot', () => {
    const { container } = render(
      <Button disabled>
        Disabled button
      </Button>
    );
   
    expect(container.firstChild).toMatchSnapshot();
  });
});

Testing Best Practices

1. Use Descriptive Test Names

// Good
test('displays error message when form submission fails')

// Bad
test('error handling')

2. Test User Behavior, Not Implementation

// Good - tests what user sees
expect(screen.getByText('Welcome, John!')).toBeInTheDocument();

// Bad - tests implementation details
expect(component.state.username).toBe('John');

3. Use Proper Queries

// Prefer accessible queries
screen.getByRole('button', { name: 'Submit' })
screen.getByLabelText('Email address')

// Over generic queries
screen.getByTestId('submit-button')

4. Group Related Tests

describe('LoginForm', () => {
  describe('validation', () => {
    test('shows error for empty email');
    test('shows error for invalid email format');
  });

  describe('submission', () => {
    test('calls onSubmit with form data');
    test('prevents submission with validation errors');
  });
});

5. Use Setup and Teardown

describe('Component Tests', () => {
  let mockFn;

  beforeEach(() => {
    mockFn = jest.fn();
  });

  afterEach(() => {
    jest.clearAllMocks();
  });
});

Common Testing Patterns

Testing Lists and Dynamic Content

test('renders list of items', async () => {
  const items = [
    { id: 1, name: 'Item 1' },
    { id: 2, name: 'Item 2' },
    { id: 3, name: 'Item 3' }
  ];

  render(<ItemList items={items} />);

  // Wait for async rendering
  await waitFor(() => {
    expect(screen.getAllByRole('listitem')).toHaveLength(3);
  });

  items.forEach(item => {
    expect(screen.getByText(item.name)).toBeInTheDocument();
  });
});

Testing Conditional Rendering

test('shows different content based on user role', () => {
  const { rerender } = render(<Dashboard user={{ role: 'user' }} />);
  expect(screen.queryByText('Admin Panel')).not.toBeInTheDocument();

  rerender(<Dashboard user={{ role: 'admin' }} />);
  expect(screen.getByText('Admin Panel')).toBeInTheDocument();
});

Testing Error Boundaries

const ThrowError = ({ shouldThrow }) => {
  if (shouldThrow) {
    throw new Error('Test error');
  }
  return <div>No error</div>;
};

test('error boundary catches and displays error', () => {
  const consoleSpy = jest.spyOn(console, 'error').mockImplementation();

  render(
    <ErrorBoundary>
      <ThrowError shouldThrow={true} />
    </ErrorBoundary>
  );

  expect(screen.getByText('Something went wrong')).toBeInTheDocument();
  consoleSpy.mockRestore();
});

Performance Testing

Testing Component Re-renders

test('component does not re-render unnecessarily', () => {
  const ExpensiveComponent = React.memo(({ data }) => {
    const renderCount = React.useRef(0);
    renderCount.current += 1;
   
    return <div data-testid="render-count">{renderCount.current}</div>;
  });

  const { rerender } = render(<ExpensiveComponent data="test" />);
  expect(screen.getByTestId('render-count')).toHaveTextContent('1');

  // Same props should not cause re-render
  rerender(<ExpensiveComponent data="test" />);
  expect(screen.getByTestId('render-count')).toHaveTextContent('1');

  // Different props should cause re-render
  rerender(<ExpensiveComponent data="new-test" />);
  expect(screen.getByTestId('render-count')).toHaveTextContent('2');
});

Debugging Tests

Using debug()

test('debug failing test', () => {
  render(<MyComponent />);
 
  // Print current DOM state
  screen.debug();
 
  // Print specific element
  screen.debug(screen.getByRole('button'));
});

Using logRoles()

import { logRoles } from '@testing-library/dom';

test('find available roles', () => {
  const { container } = render(<MyComponent />);
  logRoles(container);
});

Conclusion

Testing React components effectively requires understanding both React’s component model and testing best practices. Focus on testing user behavior rather than implementation details, use appropriate queries and assertions, and structure your tests for maintainability.

Key takeaways:

  • Test what users see and do, not internal implementation
  • Use React Testing Library’s accessible queries
  • Test both happy paths and error scenarios
  • Mock external dependencies appropriately
  • Group related tests logically
  • Write descriptive test names and use proper setup/teardown

In the next chapter, we’ll explore End-to-End Testing with Selenium to test complete user workflows across your React application.

Related Articles:

Scroll to Top