Skip to content
Home / Skills / React / Testing
RE

Testing

React testing v1.0.0

React Testing

Overview

React testing encompasses unit tests for components and hooks, integration tests for user flows, and end-to-end tests for complete features. This skill covers React Testing Library, MSW for mocking, and testing best practices.


Key Concepts

Testing Pyramid for React

┌─────────────────────────────────────────────────────────────┐
│                  React Testing Strategy                      │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│                         /\                                   │
│                        /  \                                  │
│                       / E2E\       Cypress / Playwright     │
│                      /      \      • Full user journeys     │
│                     /────────\     • Critical paths          │
│                    /Integration\   React Testing Library     │
│                   /            \   • Component interactions  │
│                  /──────────────\  • Hook behavior          │
│                 /     Unit       \ Jest + RTL                │
│                /                  \ • Isolated components   │
│               /────────────────────\• Pure functions         │
│                                                              │
│  Testing Library Philosophy:                                 │
│  ┌────────────────────────────────────────────────────────┐ │
│  │ "The more your tests resemble the way your software   │ │
│  │  is used, the more confidence they can give you."     │ │
│  │                                                        │ │
│  │ Query Priority:                                        │ │
│  │ 1. getByRole (accessible elements)                    │ │
│  │ 2. getByLabelText (form fields)                       │ │
│  │ 3. getByPlaceholderText                               │ │
│  │ 4. getByText (non-interactive elements)               │ │
│  │ 5. getByTestId (last resort)                          │ │
│  └────────────────────────────────────────────────────────┘ │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Best Practices

1. Test Behavior, Not Implementation

Focus on what users see and do, not internal state.

2. Use Accessible Queries

Prefer getByRole to encourage accessible markup.

3. Avoid Testing Implementation Details

Don’t test internal state or lifecycle methods.

4. Mock at Network Boundary

Use MSW to mock API calls, not component internals.

5. Write Maintainable Tests

Avoid testing library internals; focus on public APIs.


Code Examples

Example 1: Component Testing with RTL

import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
  // Setup user event instance for each test
  const user = userEvent.setup();

  it('renders login form with email and password fields', () => {
    render(<LoginForm onSubmit={jest.fn()} />);

    // Use accessible queries
    expect(screen.getByRole('textbox', { name: /email/i })).toBeInTheDocument();
    expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
  });

  it('shows validation errors for invalid input', async () => {
    render(<LoginForm onSubmit={jest.fn()} />);

    // Submit empty form
    await user.click(screen.getByRole('button', { name: /sign in/i }));

    // Check for error messages
    expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
    expect(screen.getByText(/password is required/i)).toBeInTheDocument();
  });

  it('shows error for invalid email format', async () => {
    render(<LoginForm onSubmit={jest.fn()} />);

    await user.type(screen.getByRole('textbox', { name: /email/i }), 'invalid-email');
    await user.type(screen.getByLabelText(/password/i), 'password123');
    await user.click(screen.getByRole('button', { name: /sign in/i }));

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

  it('calls onSubmit with form data when valid', async () => {
    const handleSubmit = jest.fn();
    render(<LoginForm onSubmit={handleSubmit} />);

    await user.type(
      screen.getByRole('textbox', { name: /email/i }),
      'user@example.com'
    );
    await user.type(screen.getByLabelText(/password/i), 'securePassword123');
    await user.click(screen.getByRole('button', { name: /sign in/i }));

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

  it('disables submit button while loading', async () => {
    render(<LoginForm onSubmit={jest.fn()} isLoading />);

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

  it('displays error message from props', () => {
    render(
      <LoginForm
        onSubmit={jest.fn()}
        error="Invalid credentials"
      />
    );

    expect(screen.getByRole('alert')).toHaveTextContent('Invalid credentials');
  });
});

Example 2: Testing Hooks

import { renderHook, act, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useCounter, useAsync, useLocalStorage } from './hooks';

describe('useCounter', () => {
  it('initializes with default value', () => {
    const { result } = renderHook(() => useCounter(10));

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

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

    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('respects min and max bounds', () => {
    const { result } = renderHook(() =>
      useCounter(5, { min: 0, max: 10 })
    );

    // Try to exceed max
    act(() => {
      for (let i = 0; i < 20; i++) result.current.increment();
    });
    expect(result.current.count).toBe(10);

    // Try to go below min
    act(() => {
      for (let i = 0; i < 20; i++) result.current.decrement();
    });
    expect(result.current.count).toBe(0);
  });
});

describe('useAsync', () => {
  it('handles successful async operation', async () => {
    const mockFn = jest.fn().mockResolvedValue({ data: 'test' });

    const { result } = renderHook(() => useAsync(mockFn, true));

    // Initially loading
    expect(result.current.loading).toBe(true);
    expect(result.current.data).toBeNull();

    // Wait for completion
    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toEqual({ data: 'test' });
    expect(result.current.error).toBeNull();
  });

  it('handles error', async () => {
    const error = new Error('Failed');
    const mockFn = jest.fn().mockRejectedValue(error);

    const { result } = renderHook(() => useAsync(mockFn, true));

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toBeNull();
    expect(result.current.error).toBe(error);
  });

  it('can execute manually', async () => {
    const mockFn = jest.fn().mockResolvedValue('result');

    const { result } = renderHook(() => useAsync(mockFn, false));

    expect(mockFn).not.toHaveBeenCalled();

    await act(async () => {
      await result.current.execute();
    });

    expect(mockFn).toHaveBeenCalledTimes(1);
    expect(result.current.data).toBe('result');
  });
});

describe('useLocalStorage', () => {
  beforeEach(() => {
    localStorage.clear();
  });

  it('returns initial value when storage is empty', () => {
    const { result } = renderHook(() =>
      useLocalStorage('key', 'default')
    );

    expect(result.current[0]).toBe('default');
  });

  it('returns stored value when present', () => {
    localStorage.setItem('key', JSON.stringify('stored'));

    const { result } = renderHook(() =>
      useLocalStorage('key', 'default')
    );

    expect(result.current[0]).toBe('stored');
  });

  it('updates localStorage when value changes', () => {
    const { result } = renderHook(() =>
      useLocalStorage('key', 'initial')
    );

    act(() => {
      result.current[1]('updated');
    });

    expect(result.current[0]).toBe('updated');
    expect(localStorage.getItem('key')).toBe(JSON.stringify('updated'));
  });
});

Example 3: API Mocking with MSW

import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserProfile } from './UserProfile';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

// Setup MSW server
const server = setupServer(
  // Default handlers
  rest.get('/api/users/:id', (req, res, ctx) => {
    const { id } = req.params;
    return res(
      ctx.json({
        id,
        name: 'John Doe',
        email: 'john@example.com',
      })
    );
  }),

  rest.patch('/api/users/:id', async (req, res, ctx) => {
    const { id } = req.params;
    const body = await req.json();
    return res(
      ctx.json({
        id,
        ...body,
      })
    );
  })
);

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

// Test wrapper with providers
function createWrapper() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
    },
  });

  return function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    );
  };
}

describe('UserProfile', () => {
  const user = userEvent.setup();

  it('displays user data after loading', async () => {
    render(<UserProfile userId="1" />, { wrapper: createWrapper() });

    // Should show loading state initially
    expect(screen.getByText(/loading/i)).toBeInTheDocument();

    // Should display user data
    await waitFor(() => {
      expect(screen.getByText('John Doe')).toBeInTheDocument();
    });
    expect(screen.getByText('john@example.com')).toBeInTheDocument();
  });

  it('handles API error', async () => {
    // Override handler for this test
    server.use(
      rest.get('/api/users/:id', (req, res, ctx) => {
        return res(ctx.status(500), ctx.json({ message: 'Server error' }));
      })
    );

    render(<UserProfile userId="1" />, { wrapper: createWrapper() });

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

  it('updates user successfully', async () => {
    render(<UserProfile userId="1" />, { wrapper: createWrapper() });

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

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

    // Update name
    const nameInput = screen.getByLabelText(/name/i);
    await user.clear(nameInput);
    await user.type(nameInput, 'Jane Doe');

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

    // Verify update
    await waitFor(() => {
      expect(screen.getByText('Jane Doe')).toBeInTheDocument();
    });
  });

  it('handles network failure gracefully', async () => {
    server.use(
      rest.patch('/api/users/:id', (req, res) => {
        return res.networkError('Failed to connect');
      })
    );

    render(<UserProfile userId="1" />, { wrapper: createWrapper() });

    await waitFor(() => {
      expect(screen.getByText('John Doe')).toBeInTheDocument();
    });

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

    await waitFor(() => {
      expect(screen.getByText(/failed to update/i)).toBeInTheDocument();
    });
  });
});

Example 4: Testing Context and Providers

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, useTheme } from './ThemeContext';
import { AuthProvider, useAuth } from './AuthContext';

// Test component that uses context
function ThemeConsumer() {
  const { theme, toggleTheme } = useTheme();
  return (
    <div>
      <span data-testid="theme">{theme}</span>
      <button onClick={toggleTheme}>Toggle</button>
    </div>
  );
}

describe('ThemeContext', () => {
  const user = userEvent.setup();

  it('provides default theme', () => {
    render(
      <ThemeProvider>
        <ThemeConsumer />
      </ThemeProvider>
    );

    expect(screen.getByTestId('theme')).toHaveTextContent('light');
  });

  it('toggles theme', async () => {
    render(
      <ThemeProvider>
        <ThemeConsumer />
      </ThemeProvider>
    );

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

    expect(screen.getByTestId('theme')).toHaveTextContent('dark');
  });

  it('throws error when used outside provider', () => {
    // Suppress console.error for this test
    const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});

    expect(() => render(<ThemeConsumer />)).toThrow(
      'useTheme must be used within ThemeProvider'
    );

    consoleSpy.mockRestore();
  });
});

// Custom render with all providers
interface RenderOptions {
  initialUser?: User | null;
  initialTheme?: 'light' | 'dark';
}

function renderWithProviders(
  ui: React.ReactElement,
  options: RenderOptions = {}
) {
  const { initialUser = null, initialTheme = 'light' } = options;

  function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <AuthProvider initialUser={initialUser}>
        <ThemeProvider initialTheme={initialTheme}>
          {children}
        </ThemeProvider>
      </AuthProvider>
    );
  }

  return render(ui, { wrapper: Wrapper });
}

describe('Protected Component', () => {
  it('shows login prompt when not authenticated', () => {
    renderWithProviders(<ProtectedPage />, { initialUser: null });

    expect(screen.getByText(/please log in/i)).toBeInTheDocument();
  });

  it('shows content when authenticated', () => {
    const user = { id: '1', name: 'John' };
    renderWithProviders(<ProtectedPage />, { initialUser: user });

    expect(screen.getByText(/welcome, john/i)).toBeInTheDocument();
  });
});

Example 5: Integration and E2E Tests

// Integration test for complete user flow
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';

describe('Shopping Cart Flow', () => {
  const user = userEvent.setup();

  function renderApp() {
    return render(
      <BrowserRouter>
        <App />
      </BrowserRouter>
    );
  }

  it('completes purchase flow', async () => {
    renderApp();

    // Navigate to products
    await user.click(screen.getByRole('link', { name: /products/i }));

    // Add item to cart
    const productCard = screen.getByTestId('product-1');
    await user.click(within(productCard).getByRole('button', { name: /add to cart/i }));

    // Verify cart badge updates
    expect(screen.getByTestId('cart-badge')).toHaveTextContent('1');

    // Go to cart
    await user.click(screen.getByRole('link', { name: /cart/i }));

    // Verify item in cart
    expect(screen.getByText('Product 1')).toBeInTheDocument();

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

    // Fill shipping info
    await user.type(screen.getByLabelText(/address/i), '123 Main St');
    await user.type(screen.getByLabelText(/city/i), 'Anytown');
    await user.type(screen.getByLabelText(/zip/i), '12345');

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

    // Verify confirmation
    await waitFor(() => {
      expect(screen.getByText(/order confirmed/i)).toBeInTheDocument();
    });
  });
});

// Cypress E2E test
describe('Login Flow', () => {
  beforeEach(() => {
    cy.visit('/login');
  });

  it('logs in successfully with valid credentials', () => {
    cy.findByRole('textbox', { name: /email/i }).type('user@example.com');
    cy.findByLabelText(/password/i).type('password123');
    cy.findByRole('button', { name: /sign in/i }).click();

    cy.url().should('include', '/dashboard');
    cy.findByText(/welcome back/i).should('be.visible');
  });

  it('shows error for invalid credentials', () => {
    cy.findByRole('textbox', { name: /email/i }).type('user@example.com');
    cy.findByLabelText(/password/i).type('wrongpassword');
    cy.findByRole('button', { name: /sign in/i }).click();

    cy.findByRole('alert').should('contain', 'Invalid credentials');
    cy.url().should('include', '/login');
  });

  it('redirects to requested page after login', () => {
    cy.visit('/dashboard');
    
    // Should redirect to login
    cy.url().should('include', '/login');

    // Login
    cy.findByRole('textbox', { name: /email/i }).type('user@example.com');
    cy.findByLabelText(/password/i).type('password123');
    cy.findByRole('button', { name: /sign in/i }).click();

    // Should redirect back to dashboard
    cy.url().should('include', '/dashboard');
  });
});

Anti-Patterns

❌ Testing Implementation Details

// WRONG - testing internal state
expect(component.state.isOpen).toBe(true);

// ✅ CORRECT - test user-visible behavior
expect(screen.getByRole('dialog')).toBeVisible();

❌ Using Test IDs Unnecessarily

// WRONG - using test id when accessible query works
screen.getByTestId('submit-button');

// ✅ CORRECT - use accessible query
screen.getByRole('button', { name: /submit/i });

References