Testing React Applications: A Comprehensive Guide
Frontend15 min read

Testing React Applications: A Comprehensive Guide

Master testing in React with Jest and React Testing Library. Unit tests, integration tests, mocking, and best practices for reliable code.

Taha Kocal

Taha Kocal

Full Stack Developer

Mar 18, 2025
#React#Testing#Jest#React Testing Library#TDD

Testing React applications means verifying components behave correctly from the user's perspective, using a test runner like Jest or Vitest together with React Testing Library, which renders components and queries the DOM the way real users experience it. The guiding principle comes from Testing Library author Kent C. Dodds: the more your tests resemble the way your software is used, the more confidence they give you. In practice, that means querying by accessible roles and labels instead of CSS selectors, simulating real interactions with user-event, mocking network requests at the boundary with Mock Service Worker (MSW), and favoring integration tests over brittle unit tests tied to implementation details. A good suite catches regressions early, documents intended behavior, and survives refactoring untouched. This guide covers setup, component and interaction testing, async operations, MSW, custom hooks, context, forms, and the best practices that keep tests maintainable.

What Should You Actually Test?

The more your tests resemble the way your software is used, the more confidence they can give you. Focus on testing behavior, not implementation details.

Testing priorities:

  • Test what users see and interact with
  • Don't test implementation details
  • Write tests that survive refactoring
  • Favor integration tests over unit tests
  • Mock only when necessary

Setup

bash
# For Vite projects
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

# For Create React App (included by default)
# Just start writing tests!

Vite Configuration

typescript
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/test/setup.ts',
    css: true,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
    },
  },
});

Test Setup File

typescript
// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';

// Cleanup after each test
afterEach(() => {
  cleanup();
});

// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation((query) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});

How Do You Test Components?

Testing a Simple Component

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

export function Button({ children, onClick, disabled, variant = 'primary' }: ButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`btn btn-${variant}`}
    >
      {children}
    </button>
  );
}
tsx
// Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
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 = vi.fn();

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

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

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

  it('is disabled when disabled prop is true', () => {
    render(<Button disabled>Click me</Button>);

    expect(screen.getByRole('button')).toBeDisabled();
  });

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

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

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

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

How Do You Test User Interactions?

tsx
// SearchForm.tsx
import { useState } from 'react';

interface SearchFormProps {
  onSearch: (query: string) => void;
}

export function SearchForm({ onSearch }: SearchFormProps) {
  const [query, setQuery] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (query.trim()) {
      onSearch(query);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
        aria-label="Search query"
      />
      <button type="submit">Search</button>
    </form>
  );
}
tsx
// SearchForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { SearchForm } from './SearchForm';

describe('SearchForm', () => {
  it('calls onSearch with the query when submitted', async () => {
    const user = userEvent.setup();
    const handleSearch = vi.fn();

    render(<SearchForm onSearch={handleSearch} />);

    const input = screen.getByLabelText(/search query/i);
    const button = screen.getByRole('button', { name: /search/i });

    await user.type(input, 'react testing');
    await user.click(button);

    expect(handleSearch).toHaveBeenCalledWith('react testing');
  });

  it('does not call onSearch with empty query', async () => {
    const user = userEvent.setup();
    const handleSearch = vi.fn();

    render(<SearchForm onSearch={handleSearch} />);

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

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

  it('trims whitespace before searching', async () => {
    const user = userEvent.setup();
    const handleSearch = vi.fn();

    render(<SearchForm onSearch={handleSearch} />);

    await user.type(screen.getByLabelText(/search query/i), '   ');
    await user.click(screen.getByRole('button'));

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

How Do You Test Async Operations?

Component with Data Fetching

tsx
// UserProfile.tsx
import { useState, useEffect } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
}

export function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    async function fetchUser() {
      try {
        setLoading(true);
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) throw new Error('User not found');
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err instanceof Error ? err.message : 'An error occurred');
      } finally {
        setLoading(false);
      }
    }

    fetchUser();
  }, [userId]);

  if (loading) return <div role="status">Loading...</div>;
  if (error) return <div role="alert">{error}</div>;
  if (!user) return null;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}
tsx
// UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserProfile } from './UserProfile';

// Mock fetch globally
const mockFetch = vi.fn();
global.fetch = mockFetch;

describe('UserProfile', () => {
  beforeEach(() => {
    mockFetch.mockReset();
  });

  it('shows loading state initially', () => {
    mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves

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

    expect(screen.getByRole('status')).toHaveTextContent(/loading/i);
  });

  it('displays user data when fetch succeeds', async () => {
    const mockUser = { id: 1, name: 'John Doe', email: 'john@example.com' };

    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve(mockUser),
    });

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

    await waitFor(() => {
      expect(screen.getByRole('heading')).toHaveTextContent('John Doe');
    });

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

  it('displays error when fetch fails', async () => {
    mockFetch.mockResolvedValueOnce({
      ok: false,
    });

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

    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent(/user not found/i);
    });
  });
});

How Do You Mock APIs with MSW?

Mock Service Worker (MSW) intercepts network requests at the network level, providing more realistic testing.

typescript
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params;

    if (id === '999') {
      return new HttpResponse(null, { status: 404 });
    }

    return HttpResponse.json({
      id: Number(id),
      name: 'John Doe',
      email: 'john@example.com',
    });
  }),

  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      { id: 1, ...body },
      { status: 201 }
    );
  }),

  http.delete('/api/users/:id', () => {
    return new HttpResponse(null, { status: 204 });
  }),
];
typescript
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// src/test/setup.ts
import { server } from '../mocks/server';

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

How Do You Test Custom Hooks?

tsx
// useCounter.ts
import { useState, useCallback } from 'react';

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

  const increment = useCallback(() => setCount((c) => c + 1), []);
  const decrement = useCallback(() => setCount((c) => c - 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);

  return { count, increment, decrement, reset };
}
tsx
// useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
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('decrements count', () => {
    const { result } = renderHook(() => useCounter(5));

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

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

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

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

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

Testing with Context

tsx
// test-utils.tsx
import { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext';

interface WrapperProps {
  children: React.ReactNode;
}

function AllProviders({ children }: WrapperProps) {
  return (
    <BrowserRouter>
      <AuthProvider>
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </AuthProvider>
    </BrowserRouter>
  );
}

function customRender(
  ui: ReactElement,
  options?: Omit<RenderOptions, 'wrapper'>
) {
  return render(ui, { wrapper: AllProviders, ...options });
}

export * from '@testing-library/react';
export { customRender as render };
tsx
// Component.test.tsx
import { render, screen } from '../test-utils';
import { UserMenu } from './UserMenu';

describe('UserMenu', () => {
  it('shows login button when not authenticated', () => {
    render(<UserMenu />);

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

Testing Forms

tsx
// LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
  it('shows validation errors for empty fields', async () => {
    const user = userEvent.setup();

    render(<LoginForm onSubmit={vi.fn()} />);

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

    expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
    expect(await screen.findByText(/password is required/i)).toBeInTheDocument();
  });

  it('shows error for invalid email', async () => {
    const user = userEvent.setup();

    render(<LoginForm onSubmit={vi.fn()} />);

    await user.type(screen.getByLabelText(/email/i), 'invalid-email');
    await user.click(screen.getByRole('button', { name: /submit/i }));

    expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
  });

  it('submits form with valid data', async () => {
    const user = userEvent.setup();
    const handleSubmit = vi.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: /submit/i }));

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

  it('disables submit button while submitting', async () => {
    const user = userEvent.setup();
    const handleSubmit = vi.fn(() => new Promise((r) => setTimeout(r, 100)));

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

    expect(screen.getByRole('button', { name: /submitting/i })).toBeDisabled();
  });
});

Snapshot Testing

tsx
// Use snapshots sparingly - only for stable UI components
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Card } from './Card';

describe('Card', () => {
  it('matches snapshot', () => {
    const { container } = render(
      <Card title="Test Card" description="Test description" />
    );

    expect(container).toMatchSnapshot();
  });
});

// Better: Test specific behavior instead
it('renders title and description', () => {
  render(<Card title="Test Card" description="Test description" />);

  expect(screen.getByRole('heading')).toHaveTextContent('Test Card');
  expect(screen.getByText('Test description')).toBeInTheDocument();
});

Best Practices

Testing best practices:

  • Use getByRole, getByLabelText, getByText - avoid getByTestId
  • Test behavior, not implementation
  • Use userEvent over fireEvent for realistic interactions
  • Avoid testing internal state - test what users see
  • Keep tests independent - no shared state between tests
  • Name tests clearly: "it does X when Y happens"

Query Priority

typescript
// Priority order for queries (most to least preferred)

// 1. Accessible to everyone
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText(/email/i);
screen.getByPlaceholderText(/search/i);
screen.getByText(/welcome/i);

// 2. Semantic queries
screen.getByAltText(/profile/i);
screen.getByTitle(/close/i);

// 3. Test IDs (last resort)
screen.getByTestId('custom-element');

Running Tests

bash
# Run all tests
npm test

# Run tests in watch mode
npm test -- --watch

# Run specific test file
npm test -- Button.test.tsx

# Run tests with coverage
npm test -- --coverage

# Run tests matching pattern
npm test -- -t "should submit form"

Write tests that give you confidence your app works correctly. Focus on user behavior, not implementation details. Use React Testing Library's guiding principle: test your components the way users use them.

A well-tested React application is easier to maintain, refactor, and extend. Start with integration tests for critical user flows, then add unit tests for complex logic. Remember: the goal is confidence, not coverage percentage.

Share this article

Related Articles