--- 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 }) ```