Files
ai-development-scaffold/.agents/skills/testing/ui-testing/SKILL.md
T
james.bland 3f0786f1e7 refactor: agents and skills move to a client-neutral source that renders per client
`.claude/agents/` was the source of truth, which made every role Claude-Code
shaped. Adding a second client meant rewriting each role in that client's syntax
and maintaining both copies — the drift this scaffold exists to prevent, one layer
up.

Roles and skills now live under `.agents/` and render into each registered
client. `.claude/agents/`, `.claude/skills/` and `.codex/agents/` are generated;
`scripts/sync-agent-integrations.py --check` fails on drift and belongs in CI.

The role metadata is portable rather than vendor-named: `reasoning_tier`
(deep/balanced/fast/vision), `capabilities`, `mutation`, `invocation`, and an
optional `preload_skills`. A client manifest maps those to native syntax and must
declare what it cannot express — `codex.yaml` declares `tier_policy: unsupported`
and its adapters say so in the file, rather than the tier silently evaporating and
leaving the repository to believe it was enforced.

The port is behaviour-preserving where it should be and a fix where it should not.
Every instruction body is byte-identical — the whole diff to `.claude/agents/` is
18 added lines and zero deletions. What changed is frontmatter that was missing:

- four agents (`code-reviewer`, `tdd-guardian`, `dependency-audit`, `pr-creator`)
  declared no `tools:` and therefore inherited the ENTIRE tool pool, so three
  review-only agents could edit and write the code they were reviewing. All eight
  now declare capabilities explicitly.
- the six read-only roles gain a non-editing permission mode, so the constraint is
  enforced by the client rather than by the prompt asking nicely.
- `mutation` is now explicit, which records the two roles that genuinely need to
  write: `pr-creator` (external-write — it pushes a branch and opens a PR) and
  `dependency-audit` (workspace-write — package managers rewrite lockfiles).

`pr-creator` keeps `shell` because opening a PR needs it, but it is now the only
agent here with a write mutation and a declared reason for it, instead of one of
four with unlimited access by omission.
2026-09-19 17:27:26 -04: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), '[email protected]');
    await user.type(screen.getByLabelText(/password/i), 'securepass123');
    await user.click(screen.getByRole('button', { name: /sign in/i }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: '[email protected]',
      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 })