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>
446 lines
12 KiB
Markdown
446 lines
12 KiB
Markdown
---
|
|
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 })
|
|
```
|