---
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();
expect(
screen.getByRole('heading', { name: /welcome, alice/i })
).toBeInTheDocument();
});
it('should show empty state when no projects', () => {
render();
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();
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(
);
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();
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();
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();
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();
expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled();
});
it('should show server error message', () => {
render(
);
expect(screen.getByRole('alert')).toHaveTextContent(/invalid credentials/i);
});
});
```
## Async Testing
```typescript
describe('UserProfile', () => {
it('should show loading state initially', () => {
render();
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();
// 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();
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();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('should be visible when open', () => {
render(
);
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(
);
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(
);
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(
);
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();
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should have proper focus management', async () => {
const user = userEvent.setup();
render();
// 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();
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();
expect(screen.getByRole('combobox')).toHaveTextContent('United Kingdom');
});
it('should show options when opened', async () => {
const user = userEvent.setup();
render();
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();
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();
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
) {
const queryClient = createTestQueryClient();
function Wrapper({ children }: WrapperProps) {
return (
{children}
);
}
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();
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 })
```