component-testing

IvanTorresEdge's avatarfrom IvanTorresEdge

React component testing with Vitest and Testing Library. Use when writing unit tests for components.

⭐0stars🔀0forks📁View on GitHub🕐Updated Jan 1, 2026

When & Why to Use This Skill

This Claude skill provides a comprehensive framework and standardized patterns for React component testing using Vitest and React Testing Library. It emphasizes the core principle of testing user behavior rather than implementation details, ensuring UI components are robust, accessible, and maintainable. The skill covers everything from basic rendering and user interactions to complex scenarios like form validation, asynchronous API mocking, and custom hook testing.

Use Cases

  • Unit Testing UI Components: Verifying that React components render correctly and handle props as expected using accessible queries.
  • Simulating User Interactions: Using user-event to test realistic workflows like button clicks, text input, and form submissions.
  • Asynchronous Behavior Validation: Testing components that rely on API calls, handling loading states, and verifying data display after promises resolve.
  • Testing Custom Hooks and Context: Isolating complex logic in hooks or verifying state propagation through React Context providers.
  • Regression Testing during Refactoring: Ensuring that internal code changes do not break existing user-facing functionality.
namecomponent-testing
descriptionReact component testing with Vitest and Testing Library. Use when writing unit tests for components.

Component Testing Skill

This skill covers React component testing patterns with Vitest and Testing Library.

When to Use

Use this skill when:

  • Writing unit tests for React components
  • Testing user interactions
  • Verifying component behavior
  • Testing hooks and context

Core Principle

TEST USER BEHAVIOR - Test what users see and do, not implementation details. Your tests should not break when you refactor.

Setup

Installation

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

Configuration

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

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/__tests__/**/*.test.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 80,
        statements: 80,
      },
    },
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';

Basic Component Test

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Button } from '../Button';

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

    expect(screen.getByRole('button')).toHaveTextContent('Click me');
  });

  it('applies variant class', () => {
    render(<Button variant="primary">Primary</Button>);

    expect(screen.getByRole('button')).toHaveClass('bg-primary');
  });

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

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

User Interaction Tests

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

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

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

    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });

  it('calls onChange with new count', async () => {
    const user = userEvent.setup();
    const handleChange = vi.fn();
    render(<Counter onChange={handleChange} />);

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

    expect(handleChange).toHaveBeenCalledWith(1);
  });
});

Form Testing

import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } 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 = 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: /sign in/i }));

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

  it('shows validation 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: /sign in/i }));

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

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

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

Query Priority

Use queries in this order of preference:

// 1. Accessible queries (best)
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { level: 1 });

// 2. Semantic queries
screen.getByLabelText(/email/i);
screen.getByPlaceholderText(/enter email/i);
screen.getByText(/welcome/i);
screen.getByAltText(/profile picture/i);

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

Async Testing

import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from '../UserProfile';

// Mock fetch
vi.mock('../api', () => ({
  fetchUser: vi.fn(() =>
    Promise.resolve({ id: '1', name: 'John Doe' })
  ),
}));

describe('UserProfile', () => {
  it('displays loading state initially', () => {
    render(<UserProfile userId="1" />);

    expect(screen.getByText(/loading/i)).toBeInTheDocument();
  });

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

    expect(await screen.findByText('John Doe')).toBeInTheDocument();
    expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
  });

  it('displays error when fetch fails', async () => {
    const { fetchUser } = await import('../api');
    vi.mocked(fetchUser).mockRejectedValueOnce(new Error('Failed'));

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

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

Testing with Context

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../ThemeContext';
import { ThemedButton } from '../ThemedButton';

function renderWithTheme(ui: React.ReactElement, theme = 'light') {
  return render(
    <ThemeProvider defaultTheme={theme}>
      {ui}
    </ThemeProvider>
  );
}

describe('ThemedButton', () => {
  it('renders with light theme styles', () => {
    renderWithTheme(<ThemedButton>Click</ThemedButton>, 'light');

    expect(screen.getByRole('button')).toHaveClass('bg-white');
  });

  it('renders with dark theme styles', () => {
    renderWithTheme(<ThemedButton>Click</ThemedButton>, 'dark');

    expect(screen.getByRole('button')).toHaveClass('bg-gray-900');
  });
});

Testing Custom Hooks

import { describe, it, expect } from 'vitest';
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 provided 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);
  });
});

Mocking

Mocking Modules

import { vi } from 'vitest';

// Mock entire module
vi.mock('../api');

// Mock with implementation
vi.mock('../utils', () => ({
  formatDate: vi.fn((date) => '2024-01-01'),
}));

Mocking Functions

const mockFn = vi.fn();
mockFn.mockReturnValue('default');
mockFn.mockReturnValueOnce('first call');
mockFn.mockResolvedValue({ data: 'async result' });
mockFn.mockRejectedValue(new Error('Failed'));

Test Structure

File Organization

src/
├── components/
│   ├── Button/
│   │   ├── Button.tsx
│   │   ├── index.ts
│   │   └── __tests__/
│   │       └── Button.test.tsx

Test Naming

// Use verb-first naming
it('renders children correctly', ...);
it('calls onClick when clicked', ...);
it('throws error for invalid input', ...);
it('displays loading state initially', ...);

Best Practices

  1. Test behavior, not implementation - Focus on what users experience
  2. Use accessible queries - getByRole, getByLabelText first
  3. Avoid testing styles - Test classes/attributes, not computed styles
  4. Keep tests focused - One behavior per test
  5. Use userEvent over fireEvent - More realistic interactions
  6. Wait for async operations - Use findBy, waitFor

Notes

  • Testing Library encourages accessible components
  • Avoid snapshot tests for components (too brittle)
  • Mock at the boundary (API, not internal functions)
  • Tests should give confidence to refactor