Testing React Components: A Practical Guide

Testing doesn't have to be painful. Here's a practical approach that actually helps.

The Testing Stack

  • **Vitest** - Test runner (Jest-compatible, faster)
  • **React Testing Library** - Testing utilities
  • **MSW** - API mocking
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom

Philosophy

Test behavior, not implementation.

// ❌ Testing implementation
expect(wrapper.state('isOpen')).toBe(true);

// ✅ Testing behavior
expect(screen.getByRole('dialog')).toBeInTheDocument();

Users don't care about your state. They care about what they see.

Basic Component Test

// Button.jsx
function Button({ onClick, children }) {
  return <button onClick={onClick}>{children}</button>;
}

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

test('renders button text', () => {
  render(<Button>Click me</Button>);
  expect(screen.getByText('Click me')).toBeInTheDocument();
});

test('calls onClick when clicked', () => {
  const handleClick = vi.fn();
  render(<Button onClick={handleClick}>Click</Button>);
  
  fireEvent.click(screen.getByRole('button'));
  
  expect(handleClick).toHaveBeenCalledTimes(1);
});

Testing Async Components

// UserProfile.jsx
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(setUser);
  }, [userId]);
  
  if (!user) return <p>Loading...</p>;
  return <h1>{user.name}</h1>;
}

// UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import UserProfile from './UserProfile';

const server = setupServer(
  http.get('/api/users/:id', () => {
    return HttpResponse.json({ name: 'Chris' });
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('loads and displays user', async () => {
  render(<UserProfile userId="1" />);
  
  // Initially shows loading
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  
  // Eventually shows user
  await waitFor(() => {
    expect(screen.getByText('Chris')).toBeInTheDocument();
  });
});

Testing Form Submission

test('submits form with user input', async () => {
  const handleSubmit = vi.fn();
  render(<LoginForm onSubmit={handleSubmit} />);
  
  // Fill form
  await userEvent.type(screen.getByLabelText('Email'), 'test@example.com');
  await userEvent.type(screen.getByLabelText('Password'), 'password123');
  
  // Submit
  await userEvent.click(screen.getByRole('button', { name: /submit/i }));
  
  // Check submission
  expect(handleSubmit).toHaveBeenCalledWith({
    email: 'test@example.com',
    password: 'password123'
  });
});

What to Test

**Do test:**

  • User interactions
  • Conditional rendering
  • Form validation feedback
  • Error states
  • Loading states

**Don't test:**

  • Implementation details
  • Third-party libraries
  • Static content
  • Every edge case (diminishing returns)

My Rule

If it can break in a way that affects users, test it. Otherwise, maybe don't.

Back to Journal