Testing Strategies for React Applications

Testing isn’t just about finding bugs—it’s about confidence. It allows you to refactor legacy code, upgrade dependencies, and ship features without the constant fear of breaking something.

In the React ecosystem, we follow the “Testing Trophy” philosophy: Write tests. Not too many. Mostly integration.

flowchart TD
    subgraph Trophy["🏆 Testing Trophy"]
        E2E["E2E Tests<br/>Few, critical paths"]
        INT["Integration Tests<br/>Most valuable"]
        UNIT["Unit Tests<br/>Complex logic only"]
        STATIC["Static Analysis<br/>TypeScript, ESLint"]
    end
    
    E2E --> INT
    INT --> UNIT
    UNIT --> STATIC
    
    style INT fill:#10b981


The Stack

Tool Purpose
Vitest Fast unit/integration test runner
React Testing Library Test components like users
MSW Mock API calls
Playwright E2E browser tests
TypeScript Static type checking
npm install -D vitest jsdom @testing-library/react @testing-library/user-event msw

Configuration

Vitest Setup

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

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
      exclude: ['node_modules', 'src/test'],
    },
  },
});
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, beforeAll, afterAll } from 'vitest';
import { server } from './mocks/server';

// MSW setup
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
  cleanup();
  server.resetHandlers();
});
afterAll(() => server.close());

Writing Your First Test

The Component

// src/components/Counter.tsx
import { useState } from 'react';

export function Counter({ initialCount = 0 }: { initialCount?: number }) {
  const [count, setCount] = useState(initialCount);

  return (
    <div>
      <p data-testid="count">Count: {count}</p>
      <button onClick={() => setCount((c) => c - 1)}>Decrement</button>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

The Test

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

describe('Counter', () => {
  it('renders initial count', () => {
    render(<Counter initialCount={5} />);
    expect(screen.getByTestId('count')).toHaveTextContent('Count: 5');
  });

  it('increments when clicking increment button', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByRole('button', { name: /increment/i }));
    
    expect(screen.getByTestId('count')).toHaveTextContent('Count: 1');
  });

  it('decrements when clicking decrement button', async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={5} />);

    await user.click(screen.getByRole('button', { name: /decrement/i }));
    
    expect(screen.getByTestId('count')).toHaveTextContent('Count: 4');
  });

  it('resets to zero', async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={10} />);

    await user.click(screen.getByRole('button', { name: /reset/i }));
    
    expect(screen.getByTestId('count')).toHaveTextContent('Count: 0');
  });
});

Query Priority

React Testing Library encourages querying by accessibility:

// ✅ Best: By role (most accessible)
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { level: 1 });

// ✅ Good: By label
screen.getByLabelText(/password/i);
screen.getByPlaceholderText(/search/i);
screen.getByText(/welcome/i);

// ⚠️ Okay: By test ID (when no semantic option)
screen.getByTestId('loading-spinner');

// ❌ Avoid: Implementation details
document.querySelector('.btn-primary');
container.querySelector('#submit-btn');

Testing Async Components

// src/components/UserProfile.tsx
import { useQuery } from '@tanstack/react-query';

export function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error loading user</div>;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}
// src/components/UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { UserProfile } from './UserProfile';

const createWrapper = () => {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
    },
  });
  return ({ children }) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
};

describe('UserProfile', () => {
  it('shows loading state initially', () => {
    render(<UserProfile userId="1" />, { wrapper: createWrapper() });
    expect(screen.getByText(/loading/i)).toBeInTheDocument();
  });

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

    await waitFor(() => {
      expect(screen.getByRole('heading')).toHaveTextContent('John Doe');
    });
    expect(screen.getByText('john@example.com')).toBeInTheDocument();
  });
});

Mocking API Calls with MSW

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

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: 'John Doe',
      email: 'john@example.com',
    });
  }),

  http.post('/api/projects', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      { id: '123', ...body },
      { status: 201 }
    );
  }),
];
// src/test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

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

Override Handlers in Tests

import { server } from '../test/mocks/server';
import { http, HttpResponse } from 'msw';

it('shows error when API fails', async () => {
  server.use(
    http.get('/api/users/:id', () => {
      return HttpResponse.json(
        { message: 'User not found' },
        { status: 404 }
      );
    })
  );

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

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

Testing Custom Hooks

// src/hooks/useLocalStorage.ts
import { useState, useEffect } from 'react';

export function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue] as const;
}
// src/hooks/useLocalStorage.test.ts
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from './useLocalStorage';

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

  it('returns initial value when no stored value exists', () => {
    const { result } = renderHook(() => useLocalStorage('theme', 'light'));
    expect(result.current[0]).toBe('light');
  });

  it('returns stored value when it exists', () => {
    localStorage.setItem('theme', JSON.stringify('dark'));
    const { result } = renderHook(() => useLocalStorage('theme', 'light'));
    expect(result.current[0]).toBe('dark');
  });

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

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

    expect(result.current[0]).toBe('dark');
    expect(localStorage.getItem('theme')).toBe('"dark"');
  });
});

Testing Forms

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

describe('LoginForm', () => {
  it('submits with valid data', async () => {
    const onSubmit = vi.fn();
    const user = userEvent.setup();

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

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

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

  it('shows validation errors', async () => {
    const user = userEvent.setup();

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

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

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

Test Organization

src/
├── components/
│   ├── Button/
│   │   ├── Button.tsx
│   │   ├── Button.test.tsx
│   │   └── index.ts
├── hooks/
│   ├── useAuth.ts
│   └── useAuth.test.ts
├── test/
│   ├── mocks/
│   │   ├── handlers.ts
│   │   └── server.ts
│   ├── setup.ts
│   └── utils.tsx        # Custom render, providers

Coverage Goals

Category Target
Critical paths 100%
Business logic 90%+
UI components 70-80%
Utilities 100%
# Run tests with coverage
npm test -- --coverage

TipTesting Philosophy
  1. Test behavior, not implementation — Query like a user
  2. Prefer integration tests — They catch more bugs per test
  3. Mock at the network layer — MSW over mocking fetch
  4. Keep tests fast — Parallel, in-memory, no real API calls
  5. Test the error cases — Happy paths are obvious

Testing is an investment. By focusing on user interactions rather than implementation details, your tests become resilient to refactoring and provide long-term value.