React Component Testing with Claude Code

Written by Michael Lip · Solo founder of Zovo · $400K+ on Upwork · 100% JSS Join 50+ builders · More at zovo.one

The Problem

Your React application has components but limited test coverage. You are not sure what to test, how to test user interactions, or how to handle components that depend on context providers, API calls, or router state. Writing tests feels like duplicating the implementation.

Quick Start

Point Claude Code at a component and ask for tests:

Read src/components/UserProfile.tsx and generate comprehensive tests.
Use React Testing Library. Test:
- Renders user data correctly
- Handles loading state
- Handles error state
- User interaction (edit button, form submission)
- Accessibility (roles, labels, keyboard navigation)
Do not test implementation details.

What’s Happening

React component testing verifies that your components render correctly and respond to user interactions as expected. React Testing Library encourages testing from the user’s perspective: what they see and what they can do, not how the component implements it internally.

Claude Code generates effective tests because it reads your component code, understands the props interface, identifies state transitions, and knows which user interactions trigger behavior changes.

Step-by-Step Guide

Step 1: Set up the testing environment

Ask Claude Code to configure the test setup:

npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event jest jest-environment-jsdom @types/jest ts-jest
// jest.config.ts
export default {
  testEnvironment: 'jsdom',
  setupFilesAfterSetup: ['<rootDir>/tests/setup.ts'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\\.(css|less|scss)$': 'identity-obj-proxy',
  },
  transform: {
    '^.+\\.tsx?$': 'ts-jest',
  },
};
// tests/setup.ts
import '@testing-library/jest-dom';

Step 2: Test a basic component

Given a component:

// src/components/UserCard.tsx
interface UserCardProps {
  name: string;
  email: string;
  avatar?: string;
  onEdit: () => void;
}

export function UserCard({ name, email, avatar, onEdit }: UserCardProps) {
  return (
    <article aria-label={`User card for ${name}`}>
      {avatar && <img src={avatar} alt={`${name}'s avatar`} />}
      <h3>{name}</h3>
      <p>{email}</p>
      <button onClick={onEdit}>Edit Profile</button>
    </article>
  );
}

Claude Code generates:

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

describe('UserCard', () => {
  const defaultProps = {
    name: 'Alice Johnson',
    email: '[email protected]',
    onEdit: jest.fn(),
  };

  it('renders name and email', () => {
    render(<UserCard {...defaultProps} />);

    expect(screen.getByText('Alice Johnson')).toBeInTheDocument();
    expect(screen.getByText('[email protected]')).toBeInTheDocument();
  });

  it('renders avatar when provided', () => {
    render(<UserCard {...defaultProps} avatar="https://example.com/avatar.jpg" />);

    const img = screen.getByAltText("Alice Johnson's avatar");
    expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg');
  });

  it('does not render avatar when not provided', () => {
    render(<UserCard {...defaultProps} />);

    expect(screen.queryByRole('img')).not.toBeInTheDocument();
  });

  it('calls onEdit when edit button is clicked', async () => {
    const user = userEvent.setup();
    const onEdit = jest.fn();
    render(<UserCard {...defaultProps} onEdit={onEdit} />);

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

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

  it('has accessible article label', () => {
    render(<UserCard {...defaultProps} />);

    expect(
      screen.getByRole('article', { name: /user card for alice johnson/i })
    ).toBeInTheDocument();
  });
});

Step 3: Test components with state

For components that manage their own state:

// src/components/SearchInput.tsx
import { useState, useCallback } from 'react';
import { useDebouncedCallback } from 'use-debounce';

interface SearchInputProps {
  onSearch: (query: string) => void;
  placeholder?: string;
}

export function SearchInput({ onSearch, placeholder = 'Search...' }: SearchInputProps) {
  const [value, setValue] = useState('');

  const debouncedSearch = useDebouncedCallback((query: string) => {
    onSearch(query);
  }, 300);

  const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
    debouncedSearch(e.target.value);
  }, [debouncedSearch]);

  const handleClear = useCallback(() => {
    setValue('');
    onSearch('');
  }, [onSearch]);

  return (
    <div role="search">
      <input
        type="text"
        value={value}
        onChange={handleChange}
        placeholder={placeholder}
        aria-label="Search"
      />
      {value && (
        <button onClick={handleClear} aria-label="Clear search">
          Clear
        </button>
      )}
    </div>
  );
}

Tests:

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

describe('SearchInput', () => {
  it('updates input value as user types', async () => {
    const user = userEvent.setup();
    render(<SearchInput onSearch={jest.fn()} />);

    const input = screen.getByRole('textbox', { name: /search/i });
    await user.type(input, 'hello');

    expect(input).toHaveValue('hello');
  });

  it('calls onSearch after debounce delay', async () => {
    const user = userEvent.setup();
    const onSearch = jest.fn();
    render(<SearchInput onSearch={onSearch} />);

    await user.type(screen.getByRole('textbox'), 'react');

    // onSearch should not be called immediately
    expect(onSearch).not.toHaveBeenCalled();

    // Wait for debounce
    await waitFor(() => {
      expect(onSearch).toHaveBeenCalledWith('react');
    }, { timeout: 500 });
  });

  it('shows clear button when input has value', async () => {
    const user = userEvent.setup();
    render(<SearchInput onSearch={jest.fn()} />);

    expect(screen.queryByRole('button', { name: /clear/i })).not.toBeInTheDocument();

    await user.type(screen.getByRole('textbox'), 'test');

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

  it('clears input and calls onSearch with empty string on clear', async () => {
    const user = userEvent.setup();
    const onSearch = jest.fn();
    render(<SearchInput onSearch={onSearch} />);

    await user.type(screen.getByRole('textbox'), 'test');
    await user.click(screen.getByRole('button', { name: /clear/i }));

    expect(screen.getByRole('textbox')).toHaveValue('');
    expect(onSearch).toHaveBeenCalledWith('');
  });
});

Step 4: Test components with API calls

Mock API calls at the network level:

import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { render, screen, waitFor } from '@testing-library/react';
import { UserList } from './UserList';

const server = setupServer(
  rest.get('/api/users', (req, res, ctx) => {
    return res(
      ctx.json([
        { id: '1', name: 'Alice', email: '[email protected]' },
        { id: '2', name: 'Bob', email: '[email protected]' },
      ])
    );
  })
);

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

describe('UserList', () => {
  it('shows loading state initially', () => {
    render(<UserList />);
    expect(screen.getByText(/loading/i)).toBeInTheDocument();
  });

  it('renders users after loading', async () => {
    render(<UserList />);

    await waitFor(() => {
      expect(screen.getByText('Alice')).toBeInTheDocument();
      expect(screen.getByText('Bob')).toBeInTheDocument();
    });
  });

  it('shows error message on API failure', async () => {
    server.use(
      rest.get('/api/users', (req, res, ctx) => {
        return res(ctx.status(500));
      })
    );

    render(<UserList />);

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

Step 5: Test components with context providers

Create a test wrapper for components that need providers:

// tests/renderWithProviders.tsx
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from '../src/contexts/ThemeContext';

function createTestProviders({ route = '/' } = {}) {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });

  return function Providers({ children }: { children: React.ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <MemoryRouter initialEntries={[route]}>
          <ThemeProvider>
            {children}
          </ThemeProvider>
        </MemoryRouter>
      </QueryClientProvider>
    );
  };
}

export function renderWithProviders(
  ui: React.ReactElement,
  options?: RenderOptions & { route?: string }
) {
  return render(ui, {
    wrapper: createTestProviders({ route: options?.route }),
    ...options,
  });
}

Prevention

Add testing rules to your CLAUDE.md:

## React Testing Rules
- Use React Testing Library, not Enzyme
- Query by role, label, or text — never by test ID unless necessary
- Use userEvent, not fireEvent, for user interactions
- Test behavior, not implementation details
- Every component must have at least: render test, interaction test, edge case test
- Mock at the network level (MSW), not at the module level

**Written by Michael** — solo dev, Da Nang, Vietnam. 50K+ Chrome extension users. $500K+ on Upwork (100% Job Success). Runs 5 Claude Max subs in parallel. Built this site with autonomous agent fleets. [See what I'm building →](https://zovo.one)

**Without a CLAUDE.md — what actually happens:** You type: "Add auth to my Next.js app" Claude generates: `pages/api/auth/[...nextauth].js` — wrong directory (you're on App Router), wrong file extension (you use TypeScript), wrong NextAuth version (v4 patterns, you need v5), session handling that doesn't match your middleware setup. You spend 40 minutes reverting and rewriting. Claude was "helpful." **With the Zovo Lifetime CLAUDE.md:** Same prompt. Claude reads 300 lines of context about YOUR project. Generates: `app/api/auth/[...nextauth]/route.ts` with v5 patterns, your session types, your middleware config, your test patterns. Works on first run. You commit and move on. That's the difference a $99 file makes. **[Get the CLAUDE.md for your stack →](https://zovo.one/lifetime?utm_source=ccg&utm_medium=cta-beforeafter&utm_campaign=claude-code-react-component-testing-guide)**
I'm a solo developer in Vietnam. 50K Chrome extension users. $500K+ on Upwork. 5 Claude Max subscriptions running agent fleets in parallel. These are my actual CLAUDE.md templates, orchestration configs, and prompts. Not a course. Not theory. The files I copy into every project before I write a line of code. **[See what's inside →](https://zovo.one/lifetime?utm_source=ccg&utm_medium=cta-default&utm_campaign=claude-code-react-component-testing-guide)** $99 once. Free forever. 47/500 founding spots left.