Skip to main content
Back to Blog
ReactTestingJestReact Testing LibraryJavaScriptTypeScriptFrontend

Testing React Components with Jest and React Testing Library

Master React component testing with Jest and React Testing Library. Learn testing patterns, mocking strategies, and best practices for reliable tests.

6 min read

Writing tests that give you confidence without slowing you down is an art. After testing hundreds of React components across production applications, I've developed patterns that catch real bugs while keeping tests maintainable. Here's what actually works.

Setting Up Your Testing Environment

Installation

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

Jest Configuration

// jest.config.js
const nextJest = require('next/jest');

const createJestConfig = nextJest({
  dir: './',
});

const customJestConfig = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  testEnvironment: 'jest-environment-jsdom',
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.{js,jsx,ts,tsx}',
  ],
};

module.exports = createJestConfig(customJestConfig);
// jest.setup.js
import '@testing-library/jest-dom';

The Testing Philosophy

React Testing Library encourages testing from the user's perspective. Instead of testing implementation details, test what users see and do.

// Bad: Testing implementation details
expect(component.state.isOpen).toBe(true);
expect(wrapper.find('.dropdown-menu').exists()).toBe(true);

// Good: Testing user behavior
expect(screen.getByRole('menu')).toBeVisible();
expect(screen.getByText('Settings')).toBeInTheDocument();

Your First Component Test

Let's test a simple button component:

// components/Button.tsx
interface ButtonProps {
  children: React.ReactNode;
  onClick?: () => void;
  disabled?: boolean;
  variant?: 'primary' | 'secondary';
}

export function Button({
  children,
  onClick,
  disabled = false,
  variant = 'primary'
}: ButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`btn btn-${variant}`}
    >
      {children}
    </button>
  );
}
// components/Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';

describe('Button', () => {
  it('renders children correctly', () => {
    render(<Button>Click me</Button>);

    expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
  });

  it('calls onClick when clicked', async () => {
    const user = userEvent.setup();
    const handleClick = jest.fn();

    render(<Button onClick={handleClick}>Click me</Button>);

    await user.click(screen.getByRole('button'));

    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('does not call onClick when disabled', async () => {
    const user = userEvent.setup();
    const handleClick = jest.fn();

    render(<Button onClick={handleClick} disabled>Click me</Button>);

    await user.click(screen.getByRole('button'));

    expect(handleClick).not.toHaveBeenCalled();
  });

  it('applies variant class correctly', () => {
    render(<Button variant="secondary">Click me</Button>);

    expect(screen.getByRole('button')).toHaveClass('btn-secondary');
  });
});

Testing User Interactions

Form Testing

// components/LoginForm.tsx
export function LoginForm({ onSubmit }: { onSubmit: (data: LoginData) => void }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!email || !password) {
      setError('Please fill in all fields');
      return;
    }
    onSubmit({ email, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        placeholder="Email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        aria-label="Email"
      />
      <input
        type="password"
        placeholder="Password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        aria-label="Password"
      />
      {error && <p role="alert">{error}</p>}
      <button type="submit">Log In</button>
    </form>
  );
}
// components/LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
  it('submits form with email and password', async () => {
    const user = userEvent.setup();
    const handleSubmit = jest.fn();

    render(<LoginForm onSubmit={handleSubmit} />);

    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: /log in/i }));

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

  it('shows error when fields are empty', async () => {
    const user = userEvent.setup();
    const handleSubmit = jest.fn();

    render(<LoginForm onSubmit={handleSubmit} />);

    await user.click(screen.getByRole('button', { name: /log in/i }));

    expect(screen.getByRole('alert')).toHaveTextContent(/please fill in all fields/i);
    expect(handleSubmit).not.toHaveBeenCalled();
  });
});

Mocking in Tests

Mocking API Calls

// hooks/useUser.ts
export function useUser(userId: string) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      });
  }, [userId]);

  return { user, loading };
}
// components/UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';

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

describe('UserProfile', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('displays user data after loading', async () => {
    (fetch as jest.Mock).mockResolvedValueOnce({
      json: async () => ({ name: 'John Doe', email: 'john@example.com' }),
    });

    render(<UserProfile userId="123" />);

    // Initially shows loading
    expect(screen.getByText(/loading/i)).toBeInTheDocument();

    // Wait for data to load
    await waitFor(() => {
      expect(screen.getByText('John Doe')).toBeInTheDocument();
    });

    expect(screen.getByText('john@example.com')).toBeInTheDocument();
  });

  it('handles fetch error gracefully', async () => {
    (fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));

    render(<UserProfile userId="123" />);

    await waitFor(() => {
      expect(screen.getByText(/error loading user/i)).toBeInTheDocument();
    });
  });
});

Mocking Modules

// Mock Next.js router
jest.mock('next/navigation', () => ({
  useRouter: () => ({
    push: jest.fn(),
    replace: jest.fn(),
    back: jest.fn(),
  }),
  usePathname: () => '/current-path',
  useSearchParams: () => new URLSearchParams(),
}));

// Mock custom hooks
jest.mock('@/hooks/useAuth', () => ({
  useAuth: () => ({
    user: { id: '1', name: 'Test User' },
    isAuthenticated: true,
    logout: jest.fn(),
  }),
}));

Testing Async Components

// components/ProductList.tsx
export async function ProductList() {
  const products = await fetchProducts();

  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>{product.name} - ${product.price}</li>
      ))}
    </ul>
  );
}
// components/ProductList.test.tsx
import { render, screen } from '@testing-library/react';
import { ProductList } from './ProductList';

jest.mock('@/lib/api', () => ({
  fetchProducts: jest.fn().mockResolvedValue([
    { id: '1', name: 'Widget', price: 9.99 },
    { id: '2', name: 'Gadget', price: 19.99 },
  ]),
}));

describe('ProductList', () => {
  it('renders products from API', async () => {
    render(await ProductList());

    expect(screen.getByText(/widget/i)).toBeInTheDocument();
    expect(screen.getByText(/gadget/i)).toBeInTheDocument();
    expect(screen.getByText('$9.99')).toBeInTheDocument();
  });
});

Testing Custom Hooks

// hooks/useCounter.ts
export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);
  const reset = () => setCount(initialValue);

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

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

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

  it('increments count', () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it('resets to initial value', () => {
    const { result } = renderHook(() => useCounter(5));

    act(() => {
      result.current.increment();
      result.current.increment();
      result.current.reset();
    });

    expect(result.current.count).toBe(5);
  });
});

Best Practices

Query Priority

Use queries in this order for accessibility:

  1. getByRole - Best for accessibility
  2. getByLabelText - Form elements
  3. getByPlaceholderText - Input placeholders
  4. getByText - Non-interactive elements
  5. getByTestId - Last resort
// Prefer accessible queries
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { level: 1 });

// Avoid test IDs when possible
screen.getByTestId('submit-button'); // Less preferred

Arrange-Act-Assert Pattern

it('adds item to cart', async () => {
  // Arrange
  const user = userEvent.setup();
  render(<ProductPage product={mockProduct} />);

  // Act
  await user.click(screen.getByRole('button', { name: /add to cart/i }));

  // Assert
  expect(screen.getByText(/added to cart/i)).toBeInTheDocument();
});

Test Coverage Goals

Aim for meaningful coverage, not 100%:

  • Critical paths: Authentication, checkout, data mutations
  • Edge cases: Error states, empty states, loading states
  • User interactions: Forms, navigation, dynamic content

Run coverage reports:

npm test -- --coverage

Testing is an investment in code quality. Start with the patterns above, and you'll catch bugs before users do. For more React development tips, check out my React hooks guide.