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>
This commit is contained in:
445
.claude/skills/testing/ui-testing/SKILL.md
Normal file
445
.claude/skills/testing/ui-testing/SKILL.md
Normal file
@@ -0,0 +1,445 @@
|
||||
---
|
||||
name: ui-testing
|
||||
description: 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
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
```typescript
|
||||
// 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 })
|
||||
```
|
||||
Reference in New Issue
Block a user