Files
James Bland befb8fbaeb feat: initial Claude Code configuration scaffold
Comprehensive Claude Code guidance system with:

- 5 agents: tdd-guardian, code-reviewer, security-scanner, refactor-scan, dependency-audit
- 18 skills covering languages (Python, TypeScript, Rust, Go, Java, C#),
  infrastructure (AWS, Azure, GCP, Terraform, Ansible, Docker/K8s, Database, CI/CD),
  testing (TDD, UI, Browser), and patterns (Monorepo, API Design, Observability)
- 3 hooks: secret detection, auto-formatting, TDD git pre-commit
- Strict TDD enforcement with 80%+ coverage requirements
- Multi-model strategy: Opus for planning, Sonnet for execution (opusplan)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 15:47:34 -05:00

12 KiB

name, description
name description
ui-testing React component testing with React Testing Library, user-centric patterns, and accessibility testing. Use when writing tests for React components or UI interactions.

UI Testing Skill

Core Principles

  1. Test behavior, not implementation - What users see and do
  2. Query by accessibility - Role, label, text (what assistive tech sees)
  3. User events over fire events - Real user interactions
  4. Avoid test IDs when possible - Last resort only

Query Priority

// Best: Accessible to everyone
screen.getByRole('button', { name: 'Submit' })
screen.getByLabelText('Email address')
screen.getByPlaceholderText('Search...')
screen.getByText('Welcome back')

// Good: Semantic queries
screen.getByAltText('Company logo')
screen.getByTitle('Close dialog')

// Acceptable: Test IDs (when no other option)
screen.getByTestId('custom-datepicker')

Component Testing Pattern

// tests/features/dashboard/Dashboard.test.tsx
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';

import { Dashboard } from '@/features/dashboard/Dashboard';
import { getMockUser, getMockProjects } from './factories';

describe('Dashboard', () => {
  it('should display user name in header', () => {
    const user = getMockUser({ name: 'Alice' });

    render(<Dashboard user={user} projects={[]} />);

    expect(
      screen.getByRole('heading', { name: /welcome, alice/i })
    ).toBeInTheDocument();
  });

  it('should show empty state when no projects', () => {
    render(<Dashboard user={getMockUser()} projects={[]} />);

    expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
    expect(
      screen.getByRole('button', { name: /create project/i })
    ).toBeInTheDocument();
  });

  it('should list all projects with their status', () => {
    const projects = [
      getMockProject({ name: 'Project A', status: 'active' }),
      getMockProject({ name: 'Project B', status: 'completed' }),
    ];

    render(<Dashboard user={getMockUser()} projects={projects} />);

    const projectList = screen.getByRole('list', { name: /projects/i });
    const items = within(projectList).getAllByRole('listitem');

    expect(items).toHaveLength(2);
    expect(screen.getByText('Project A')).toBeInTheDocument();
    expect(screen.getByText('Project B')).toBeInTheDocument();
  });

  it('should navigate to project when clicked', async () => {
    const user = userEvent.setup();
    const onNavigate = vi.fn();
    const projects = [getMockProject({ id: 'proj-1', name: 'My Project' })];

    render(
      <Dashboard
        user={getMockUser()}
        projects={projects}
        onNavigate={onNavigate}
      />
    );

    await user.click(screen.getByRole('link', { name: /my project/i }));

    expect(onNavigate).toHaveBeenCalledWith('/projects/proj-1');
  });
});

Form Testing

describe('LoginForm', () => {
  it('should show validation errors for empty fields', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={vi.fn()} />);

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

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

  it('should show error for invalid email format', 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(screen.getByText(/invalid email format/i)).toBeInTheDocument();
  });

  it('should submit form with valid data', async () => {
    const user = userEvent.setup();
    const onSubmit = vi.fn();
    render(<LoginForm onSubmit={onSubmit} />);

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

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

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

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

  it('should show server error message', () => {
    render(
      <LoginForm onSubmit={vi.fn()} error="Invalid credentials" />
    );

    expect(screen.getByRole('alert')).toHaveTextContent(/invalid credentials/i);
  });
});

Async Testing

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

    expect(screen.getByRole('progressbar')).toBeInTheDocument();
  });

  it('should display user data when loaded', async () => {
    // Mock API returns user data
    server.use(
      http.get('/api/users/user-1', () => {
        return HttpResponse.json(getMockUser({ name: 'Alice' }));
      })
    );

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

    // Wait for data to load
    await waitFor(() => {
      expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
    });

    expect(screen.getByText('Alice')).toBeInTheDocument();
  });

  it('should show error state on failure', async () => {
    server.use(
      http.get('/api/users/user-1', () => {
        return HttpResponse.error();
      })
    );

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

    await waitFor(() => {
      expect(screen.getByRole('alert')).toBeInTheDocument();
    });

    expect(screen.getByText(/failed to load user/i)).toBeInTheDocument();
  });
});

Modal and Dialog Testing

describe('ConfirmDialog', () => {
  it('should not be visible when closed', () => {
    render(<ConfirmDialog isOpen={false} onConfirm={vi.fn()} />);

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

  it('should be visible when open', () => {
    render(
      <ConfirmDialog
        isOpen={true}
        title="Delete item?"
        message="This action cannot be undone."
        onConfirm={vi.fn()}
      />
    );

    expect(screen.getByRole('dialog')).toBeInTheDocument();
    expect(
      screen.getByRole('heading', { name: /delete item/i })
    ).toBeInTheDocument();
  });

  it('should call onConfirm when confirmed', async () => {
    const user = userEvent.setup();
    const onConfirm = vi.fn();

    render(
      <ConfirmDialog isOpen={true} onConfirm={onConfirm} onCancel={vi.fn()} />
    );

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

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

  it('should call onCancel when cancelled', async () => {
    const user = userEvent.setup();
    const onCancel = vi.fn();

    render(
      <ConfirmDialog isOpen={true} onConfirm={vi.fn()} onCancel={onCancel} />
    );

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

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

  it('should close on escape key', async () => {
    const user = userEvent.setup();
    const onCancel = vi.fn();

    render(
      <ConfirmDialog isOpen={true} onConfirm={vi.fn()} onCancel={onCancel} />
    );

    await user.keyboard('{Escape}');

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

Accessibility Testing

import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

describe('Accessibility', () => {
  it('should have no accessibility violations', async () => {
    const { container } = render(<LoginForm onSubmit={vi.fn()} />);

    const results = await axe(container);

    expect(results).toHaveNoViolations();
  });

  it('should have proper focus management', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={vi.fn()} />);

    // Tab through form
    await user.tab();
    expect(screen.getByLabelText(/email/i)).toHaveFocus();

    await user.tab();
    expect(screen.getByLabelText(/password/i)).toHaveFocus();

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

  it('should announce errors to screen readers', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={vi.fn()} />);

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

    const errorMessage = screen.getByText(/email is required/i);
    expect(errorMessage).toHaveAttribute('role', 'alert');
  });
});

Testing Select/Dropdown Components

describe('CountrySelect', () => {
  it('should show selected country', () => {
    render(<CountrySelect value="GB" onChange={vi.fn()} />);

    expect(screen.getByRole('combobox')).toHaveTextContent('United Kingdom');
  });

  it('should show options when opened', async () => {
    const user = userEvent.setup();
    render(<CountrySelect value="" onChange={vi.fn()} />);

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

    expect(screen.getByRole('listbox')).toBeInTheDocument();
    expect(screen.getByRole('option', { name: /united states/i })).toBeInTheDocument();
    expect(screen.getByRole('option', { name: /united kingdom/i })).toBeInTheDocument();
  });

  it('should call onChange when option selected', async () => {
    const user = userEvent.setup();
    const onChange = vi.fn();
    render(<CountrySelect value="" onChange={onChange} />);

    await user.click(screen.getByRole('combobox'));
    await user.click(screen.getByRole('option', { name: /germany/i }));

    expect(onChange).toHaveBeenCalledWith('DE');
  });

  it('should filter options when typing', async () => {
    const user = userEvent.setup();
    render(<CountrySelect value="" onChange={vi.fn()} />);

    await user.click(screen.getByRole('combobox'));
    await user.type(screen.getByRole('combobox'), 'united');

    const options = screen.getAllByRole('option');
    expect(options).toHaveLength(2); // US and UK
  });
});

Testing with Context Providers

// tests/utils/render.tsx
import { render, type RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@/shared/context/ThemeContext';
import { AuthProvider } from '@/features/auth/AuthContext';

type WrapperProps = {
  children: React.ReactNode;
};

const createTestQueryClient = () =>
  new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  });

export function renderWithProviders(
  ui: React.ReactElement,
  options?: Omit<RenderOptions, 'wrapper'>
) {
  const queryClient = createTestQueryClient();

  function Wrapper({ children }: WrapperProps) {
    return (
      <QueryClientProvider client={queryClient}>
        <AuthProvider>
          <ThemeProvider>{children}</ThemeProvider>
        </AuthProvider>
      </QueryClientProvider>
    );
  }

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

Anti-Patterns to Avoid

// BAD: Testing implementation details
it('should update state', () => {
  const { result } = renderHook(() => useState(0));
  act(() => result.current[1](1));
  expect(result.current[0]).toBe(1);
});

// GOOD: Test user-visible behavior
it('should increment counter when clicked', async () => {
  render(<Counter />);
  await userEvent.click(screen.getByRole('button', { name: /increment/i }));
  expect(screen.getByText('1')).toBeInTheDocument();
});


// BAD: Using test IDs when accessible query exists
screen.getByTestId('submit-button')

// GOOD: Use accessible query
screen.getByRole('button', { name: /submit/i })


// BAD: Waiting with arbitrary timeout
await new Promise(r => setTimeout(r, 1000));

// GOOD: Wait for specific condition
await waitFor(() => {
  expect(screen.getByText('Loaded')).toBeInTheDocument();
});


// BAD: Using fireEvent for user interactions
fireEvent.click(button);

// GOOD: Use userEvent for realistic interactions
await userEvent.click(button);


// BAD: Querying by class or internal structure
container.querySelector('.btn-primary')

// GOOD: Query by role and accessible name
screen.getByRole('button', { name: /save/i })