---
name: shadcn/ui Component Testing
description: Testing skill for shadcn/ui and Radix UI component libraries covering accessible component testing, dialog and popover testing, form validation testing, data table testing, command palette testing, and theme switching verification.
version: 1.0.0
author: thetestingacademy
license: MIT
tags: [shadcn, radix-ui, component-testing, accessibility, dialog, form, data-table, tailwind]
testingTypes: [unit, integration, e2e, accessibility]
frameworks: [vitest, playwright, react-testing-library]
languages: [typescript, javascript]
domains: [web]
agents: [claude-code, cursor, github-copilot, windsurf, codex, aider, continue, cline, zed, bolt, gemini-cli, amp]
---
# shadcn/ui Component Testing Skill
You are an expert software engineer specializing in testing shadcn/ui and Radix UI component libraries. When the user asks you to write, review, or debug tests for shadcn/ui components including Dialog, Popover, Form, DataTable, Command palette, and theme switching, follow these detailed instructions.
## Core Principles
1. **Test user interactions, not Radix internals** -- Radix primitives are well-tested; focus on your composition and customization.
2. **Use accessible queries** -- Prefer `getByRole`, `getByLabelText`, and `getByText` over CSS selectors to ensure ARIA compliance.
3. **Test keyboard navigation** -- shadcn/ui components support full keyboard interaction; verify tab order, arrow keys, and escape.
4. **Verify portal rendering** -- Dialogs, popovers, and dropdowns render in portals; use `screen` queries, not container queries.
5. **Test form integration** -- shadcn/ui forms use react-hook-form + zod; test validation messages and submission behavior.
6. **Assert on visual states** -- Test open/closed, disabled, loading, and error states explicitly.
7. **Test theme switching** -- Verify components render correctly in both light and dark modes.
## Project Structure
```
project/
src/
components/
ui/
button.tsx
dialog.tsx
popover.tsx
select.tsx
form.tsx
data-table.tsx
command.tsx
sheet.tsx
accordion.tsx
toast.tsx
__tests__/
button.test.tsx
dialog.test.tsx
popover.test.tsx
select.test.tsx
form.test.tsx
data-table.test.tsx
command.test.tsx
sheet.test.tsx
accordion.test.tsx
toast.test.tsx
theme-switching.test.tsx
keyboard-navigation.test.tsx
test-utils/
render-with-providers.tsx
mock-data.ts
accessibility-helpers.ts
vitest.config.ts
playwright.config.ts
```
## Test Utilities Setup
```typescript
// src/components/test-utils/render-with-providers.tsx
import React, { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { ThemeProvider } from 'next-themes';
import { Toaster } from '@/components/ui/toaster';
interface TestProviderOptions {
theme?: 'light' | 'dark' | 'system';
}
function TestProviders({
children,
theme = 'light',
}: {
children: React.ReactNode;
theme?: string;
}) {
return (
{children}
);
}
export function renderWithProviders(
ui: ReactElement,
options?: RenderOptions & TestProviderOptions
) {
const { theme, ...renderOptions } = options || {};
return render(ui, {
wrapper: ({ children }) => (
{children}
),
...renderOptions,
});
}
export * from '@testing-library/react';
export { renderWithProviders as render };
```
```typescript
// src/components/test-utils/accessibility-helpers.ts
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
export async function expectNoA11yViolations(container: HTMLElement) {
const results = await axe(container);
expect(results).toHaveNoViolations();
}
export function expectFocusVisible(element: HTMLElement) {
expect(element).toHaveFocus();
expect(document.activeElement).toBe(element);
}
export function expectAriaExpanded(element: HTMLElement, expanded: boolean) {
expect(element).toHaveAttribute('aria-expanded', String(expanded));
}
export function expectAriaSelected(element: HTMLElement, selected: boolean) {
expect(element).toHaveAttribute('aria-selected', String(selected));
}
```
## Dialog Testing
```typescript
// src/components/__tests__/dialog.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '../test-utils/render-with-providers';
import userEvent from '@testing-library/user-event';
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { expectNoA11yViolations } from '../test-utils/accessibility-helpers';
function ConfirmDialog({
onConfirm,
onCancel,
}: {
onConfirm: () => void;
onCancel?: () => void;
}) {
return (
);
}
describe('Dialog', () => {
it('should open when trigger is clicked', async () => {
const user = userEvent.setup();
render();
// Dialog content should not be visible initially
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
// Click trigger to open
await user.click(screen.getByRole('button', { name: /delete item/i }));
// Dialog should be visible
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Are you sure?')).toBeInTheDocument();
expect(screen.getByText(/cannot be undone/i)).toBeInTheDocument();
});
it('should close when escape key is pressed', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole('button', { name: /delete item/i }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
await user.keyboard('{Escape}');
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
it('should close when overlay is clicked', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole('button', { name: /delete item/i }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
// Click the overlay (outside dialog content)
const overlay = document.querySelector('[data-state="open"][data-overlay]');
if (overlay) {
await user.click(overlay as HTMLElement);
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
}
});
it('should call onConfirm when delete button is clicked', async () => {
const user = userEvent.setup();
const onConfirm = vi.fn();
render();
await user.click(screen.getByRole('button', { name: /delete item/i }));
await user.click(screen.getByRole('button', { name: /^delete$/i }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('should close when cancel button is clicked', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render();
await user.click(screen.getByRole('button', { name: /delete item/i }));
await user.click(screen.getByRole('button', { name: /cancel/i }));
expect(onCancel).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
it('should trap focus inside the dialog', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole('button', { name: /delete item/i }));
// Tab through dialog elements
await user.tab();
const cancelBtn = screen.getByRole('button', { name: /cancel/i });
const deleteBtn = screen.getByRole('button', { name: /^delete$/i });
// Focus should cycle within dialog
const focusableElements = [cancelBtn, deleteBtn];
for (const el of focusableElements) {
expect(document.activeElement === el || focusableElements.includes(document.activeElement as HTMLElement)).toBe(true);
await user.tab();
}
});
it('should have correct ARIA attributes', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole('button', { name: /delete item/i }));
const dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('aria-labelledby');
expect(dialog).toHaveAttribute('aria-describedby');
const titleId = dialog.getAttribute('aria-labelledby');
const title = document.getElementById(titleId!);
expect(title).toHaveTextContent('Are you sure?');
});
it('should pass accessibility audit', async () => {
const user = userEvent.setup();
const { container } = render();
await user.click(screen.getByRole('button', { name: /delete item/i }));
await expectNoA11yViolations(container);
});
});
```
## Popover and Dropdown Testing
```typescript
// src/components/__tests__/popover.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '../test-utils/render-with-providers';
import userEvent from '@testing-library/user-event';
import {
Popover,
PopoverTrigger,
PopoverContent,
} from '@/components/ui/popover';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuLabel,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
function UserMenu({ onLogout }: { onLogout: () => void }) {
return (
My Account
Settings
Billing
Log out
);
}
describe('DropdownMenu', () => {
it('should open on click and show menu items', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole('button', { name: /profile/i }));
expect(screen.getByText('My Account')).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /settings/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /billing/i })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /log out/i })).toBeInTheDocument();
});
it('should navigate items with arrow keys', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole('button', { name: /profile/i }));
// Arrow down to navigate
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('menuitem', { name: /settings/i })).toHaveFocus();
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('menuitem', { name: /billing/i })).toHaveFocus();
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('menuitem', { name: /log out/i })).toHaveFocus();
});
it('should call onLogout when Log out is clicked', async () => {
const user = userEvent.setup();
const onLogout = vi.fn();
render();
await user.click(screen.getByRole('button', { name: /profile/i }));
await user.click(screen.getByRole('menuitem', { name: /log out/i }));
expect(onLogout).toHaveBeenCalledTimes(1);
});
it('should close on escape', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole('button', { name: /profile/i }));
expect(screen.getByText('My Account')).toBeInTheDocument();
await user.keyboard('{Escape}');
await waitFor(() => {
expect(screen.queryByText('My Account')).not.toBeInTheDocument();
});
});
it('should select item with Enter key', async () => {
const user = userEvent.setup();
const onLogout = vi.fn();
render();
await user.click(screen.getByRole('button', { name: /profile/i }));
await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}{Enter}');
expect(onLogout).toHaveBeenCalledTimes(1);
});
});
```
## Select Component Testing
```typescript
// src/components/__tests__/select.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '../test-utils/render-with-providers';
import userEvent from '@testing-library/user-event';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
SelectGroup,
SelectLabel,
} from '@/components/ui/select';
function PrioritySelect({
value,
onChange,
}: {
value?: string;
onChange: (value: string) => void;
}) {
return (
);
}
describe('Select', () => {
it('should show placeholder when no value selected', () => {
render();
expect(screen.getByText('Select priority')).toBeInTheDocument();
});
it('should open options on click', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole('combobox', { name: /priority/i }));
expect(screen.getByRole('option', { name: /low/i })).toBeInTheDocument();
expect(screen.getByRole('option', { name: /medium/i })).toBeInTheDocument();
expect(screen.getByRole('option', { name: /high/i })).toBeInTheDocument();
});
it('should call onChange when option is selected', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.click(screen.getByRole('combobox', { name: /priority/i }));
await user.click(screen.getByRole('option', { name: /high/i }));
expect(onChange).toHaveBeenCalledWith('high');
});
it('should not allow selecting disabled options', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.click(screen.getByRole('combobox', { name: /priority/i }));
const criticalOption = screen.getByRole('option', { name: /critical/i });
expect(criticalOption).toHaveAttribute('aria-disabled', 'true');
});
it('should display selected value', () => {
render();
expect(screen.getByText('Medium')).toBeInTheDocument();
});
it('should support keyboard navigation', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
// Open with Enter
const trigger = screen.getByRole('combobox', { name: /priority/i });
trigger.focus();
await user.keyboard('{Enter}');
// Navigate with arrows and select with Enter
await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
expect(onChange).toHaveBeenCalled();
});
});
```
## Form Testing with react-hook-form + zod
```typescript
// src/components/__tests__/form.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '../test-utils/render-with-providers';
import userEvent from '@testing-library/user-event';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
const profileSchema = z.object({
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be at most 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
email: z.string().email('Please enter a valid email address'),
bio: z.string().max(160, 'Bio must be at most 160 characters').optional(),
});
type ProfileFormValues = z.infer;
function ProfileForm({ onSubmit }: { onSubmit: (data: ProfileFormValues) => void }) {
const form = useForm({
resolver: zodResolver(profileSchema),
defaultValues: { username: '', email: '', bio: '' },
});
return (
);
}
describe('ProfileForm', () => {
it('should render all form fields', () => {
render();
expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/bio/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('should show validation errors for empty required fields', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(screen.getByText(/at least 3 characters/i)).toBeInTheDocument();
expect(screen.getByText(/valid email/i)).toBeInTheDocument();
});
});
it('should show validation error for short username', async () => {
const user = userEvent.setup();
render();
await user.type(screen.getByLabelText(/username/i), 'ab');
await user.type(screen.getByLabelText(/email/i), 'valid@example.com');
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(screen.getByText(/at least 3 characters/i)).toBeInTheDocument();
});
});
it('should show validation error for invalid username characters', async () => {
const user = userEvent.setup();
render();
await user.type(screen.getByLabelText(/username/i), 'user name!');
await user.type(screen.getByLabelText(/email/i), 'valid@example.com');
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(screen.getByText(/only contain letters, numbers/i)).toBeInTheDocument();
});
});
it('should show validation error for invalid email', async () => {
const user = userEvent.setup();
render();
await user.type(screen.getByLabelText(/username/i), 'validuser');
await user.type(screen.getByLabelText(/email/i), 'not-an-email');
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(screen.getByText(/valid email/i)).toBeInTheDocument();
});
});
it('should submit valid form data', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render();
await user.type(screen.getByLabelText(/username/i), 'johndoe');
await user.type(screen.getByLabelText(/email/i), 'john@example.com');
await user.type(screen.getByLabelText(/bio/i), 'Hello world');
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{ username: 'johndoe', email: 'john@example.com', bio: 'Hello world' },
expect.anything()
);
});
});
it('should clear errors when valid input is provided', async () => {
const user = userEvent.setup();
render();
// Trigger validation errors
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(screen.getByText(/at least 3 characters/i)).toBeInTheDocument();
});
// Fix the error
await user.type(screen.getByLabelText(/username/i), 'validuser');
await user.type(screen.getByLabelText(/email/i), 'valid@example.com');
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(screen.queryByText(/at least 3 characters/i)).not.toBeInTheDocument();
});
});
});
```
## DataTable Testing
```typescript
// src/components/__tests__/data-table.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, within } from '../test-utils/render-with-providers';
import userEvent from '@testing-library/user-event';
// Assume a DataTable component built with @tanstack/react-table + shadcn/ui
import { DataTable, columns } from '@/components/data-table';
const mockData = [
{ id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin', status: 'active' },
{ id: '2', name: 'Bob', email: 'bob@example.com', role: 'user', status: 'active' },
{ id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'user', status: 'inactive' },
{ id: '4', name: 'Diana', email: 'diana@example.com', role: 'admin', status: 'active' },
{ id: '5', name: 'Eve', email: 'eve@example.com', role: 'user', status: 'active' },
];
describe('DataTable', () => {
it('should render all rows', () => {
render();
const rows = screen.getAllByRole('row');
// Header row + 5 data rows
expect(rows).toHaveLength(6);
});
it('should render column headers', () => {
render();
expect(screen.getByRole('columnheader', { name: /name/i })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /email/i })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /role/i })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /status/i })).toBeInTheDocument();
});
it('should sort by column when header is clicked', async () => {
const user = userEvent.setup();
render();
// Click name column header to sort ascending
await user.click(screen.getByRole('columnheader', { name: /name/i }));
const rows = screen.getAllByRole('row').slice(1); // Skip header
const names = rows.map((row) => within(row).getAllByRole('cell')[0].textContent);
expect(names).toEqual(['Alice', 'Bob', 'Charlie', 'Diana', 'Eve']);
// Click again for descending
await user.click(screen.getByRole('columnheader', { name: /name/i }));
const rowsDesc = screen.getAllByRole('row').slice(1);
const namesDesc = rowsDesc.map((row) => within(row).getAllByRole('cell')[0].textContent);
expect(namesDesc).toEqual(['Eve', 'Diana', 'Charlie', 'Bob', 'Alice']);
});
it('should filter rows by search input', async () => {
const user = userEvent.setup();
render();
const searchInput = screen.getByPlaceholderText(/filter/i);
await user.type(searchInput, 'alice');
const rows = screen.getAllByRole('row').slice(1);
expect(rows).toHaveLength(1);
expect(within(rows[0]).getByText('Alice')).toBeInTheDocument();
});
it('should show empty state when no data matches', async () => {
const user = userEvent.setup();
render();
const searchInput = screen.getByPlaceholderText(/filter/i);
await user.type(searchInput, 'nonexistent');
expect(screen.getByText(/no results/i)).toBeInTheDocument();
});
it('should handle row selection with checkboxes', async () => {
const user = userEvent.setup();
const onSelectionChange = vi.fn();
render(
);
const checkboxes = screen.getAllByRole('checkbox');
// First checkbox is "select all", rest are row checkboxes
expect(checkboxes).toHaveLength(6);
// Select first row
await user.click(checkboxes[1]);
expect(onSelectionChange).toHaveBeenCalledWith(
expect.objectContaining({ '1': true })
);
});
it('should handle pagination', async () => {
const user = userEvent.setup();
const largeData = Array.from({ length: 25 }, (_, i) => ({
id: String(i),
name: `User ${i}`,
email: `user${i}@example.com`,
role: 'user',
status: 'active',
}));
render();
// First page should show 10 rows
const rows = screen.getAllByRole('row').slice(1);
expect(rows).toHaveLength(10);
// Navigate to next page
const nextButton = screen.getByRole('button', { name: /next/i });
await user.click(nextButton);
// Second page should also show 10 rows
const page2Rows = screen.getAllByRole('row').slice(1);
expect(page2Rows).toHaveLength(10);
});
});
```
## Command Palette (cmdk) Testing
```typescript
// src/components/__tests__/command.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '../test-utils/render-with-providers';
import userEvent from '@testing-library/user-event';
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
} from '@/components/ui/command';
function AppCommandPalette({
open,
onOpenChange,
onSelect,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (value: string) => void;
}) {
return (
No results found.
onSelect('/dashboard')}>Dashboard
onSelect('/settings')}>Settings
onSelect('/profile')}>Profile
onSelect('new-project')}>New Project
onSelect('new-team')}>New Team
);
}
describe('Command Palette', () => {
it('should render when open', () => {
render(
);
expect(screen.getByPlaceholderText(/type a command/i)).toBeInTheDocument();
expect(screen.getByText('Dashboard')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
});
it('should filter items by search input', async () => {
const user = userEvent.setup();
render(
);
await user.type(screen.getByPlaceholderText(/type a command/i), 'dash');
expect(screen.getByText('Dashboard')).toBeInTheDocument();
expect(screen.queryByText('Settings')).not.toBeInTheDocument();
expect(screen.queryByText('Profile')).not.toBeInTheDocument();
});
it('should show empty state when nothing matches', async () => {
const user = userEvent.setup();
render(
);
await user.type(screen.getByPlaceholderText(/type a command/i), 'zzzzz');
expect(screen.getByText('No results found.')).toBeInTheDocument();
});
it('should call onSelect when item is clicked', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(
);
await user.click(screen.getByText('Dashboard'));
expect(onSelect).toHaveBeenCalledWith('/dashboard');
});
it('should navigate with arrow keys and select with Enter', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(
);
const input = screen.getByPlaceholderText(/type a command/i);
await user.click(input);
await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
expect(onSelect).toHaveBeenCalled();
});
});
```
## Theme Switching Testing
```typescript
// src/components/__tests__/theme-switching.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '../test-utils/render-with-providers';
import userEvent from '@testing-library/user-event';
import { Button } from '@/components/ui/button';
import { useTheme } from 'next-themes';
function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
);
}
describe('Theme Switching', () => {
it('should render with light theme by default', () => {
render(, { theme: 'light' });
expect(screen.getByRole('button', { name: /switch to dark/i })).toBeInTheDocument();
});
it('should render with dark theme when configured', () => {
render(, { theme: 'dark' });
expect(screen.getByRole('button', { name: /switch to light/i })).toBeInTheDocument();
});
it('should toggle theme on button click', async () => {
const user = userEvent.setup();
render(, { theme: 'light' });
await user.click(screen.getByRole('button', { name: /switch to dark/i }));
await waitFor(() => {
expect(screen.getByRole('button', { name: /switch to light/i })).toBeInTheDocument();
});
});
it('should apply correct CSS class to document', async () => {
const user = userEvent.setup();
render(, { theme: 'light' });
await user.click(screen.getByRole('button', { name: /switch to dark/i }));
await waitFor(() => {
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
});
});
```
## Accordion Testing
```typescript
// src/components/__tests__/accordion.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '../test-utils/render-with-providers';
import userEvent from '@testing-library/user-event';
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from '@/components/ui/accordion';
function FAQ() {
return (
What is shadcn/ui?
A collection of reusable components built with Radix UI and Tailwind CSS.
Is it accessible?
Yes, it follows WAI-ARIA design patterns.
);
}
describe('Accordion', () => {
it('should render all triggers', () => {
render();
expect(screen.getByRole('button', { name: /what is shadcn/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /is it accessible/i })).toBeInTheDocument();
});
it('should expand content when trigger is clicked', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole('button', { name: /what is shadcn/i }));
expect(screen.getByText(/reusable components/i)).toBeVisible();
});
it('should collapse when clicking the same trigger again', async () => {
const user = userEvent.setup();
render();
const trigger = screen.getByRole('button', { name: /what is shadcn/i });
await user.click(trigger);
expect(screen.getByText(/reusable components/i)).toBeVisible();
await user.click(trigger);
// Content should be hidden (aria-hidden or removed)
expect(trigger).toHaveAttribute('aria-expanded', 'false');
});
it('should close previous item when opening another (single mode)', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole('button', { name: /what is shadcn/i }));
expect(screen.getByText(/reusable components/i)).toBeVisible();
await user.click(screen.getByRole('button', { name: /is it accessible/i }));
expect(screen.getByText(/WAI-ARIA/i)).toBeVisible();
// First item should be collapsed
const firstTrigger = screen.getByRole('button', { name: /what is shadcn/i });
expect(firstTrigger).toHaveAttribute('aria-expanded', 'false');
});
it('should support keyboard navigation', async () => {
const user = userEvent.setup();
render();
const firstTrigger = screen.getByRole('button', { name: /what is shadcn/i });
firstTrigger.focus();
// Space should toggle
await user.keyboard(' ');
expect(firstTrigger).toHaveAttribute('aria-expanded', 'true');
// Enter should also toggle
await user.keyboard('{Enter}');
expect(firstTrigger).toHaveAttribute('aria-expanded', 'false');
});
});
```
## E2E Tests with Playwright
```typescript
// e2e/components.spec.ts
import { test, expect } from '@playwright/test';
test.describe('shadcn/ui Components E2E', () => {
test('dialog should open and close with keyboard', async ({ page }) => {
await page.goto('/components/dialog-demo');
await page.getByRole('button', { name: /open dialog/i }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// Close with Escape
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).not.toBeVisible();
});
test('command palette should open with Cmd+K', async ({ page }) => {
await page.goto('/dashboard');
// Open command palette with keyboard shortcut
await page.keyboard.press('Meta+k');
await expect(page.getByPlaceholder(/type a command/i)).toBeVisible();
// Search and select
await page.getByPlaceholder(/type a command/i).fill('settings');
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/\/settings/);
});
test('data table should sort and filter', async ({ page }) => {
await page.goto('/dashboard/users');
// Sort by name
await page.getByRole('columnheader', { name: /name/i }).click();
const firstRow = page.getByRole('row').nth(1);
await expect(firstRow.getByRole('cell').first()).toHaveText(/^A/);
// Filter
await page.getByPlaceholder(/filter/i).fill('admin');
const rows = page.getByRole('row');
await expect(rows).toHaveCount(3); // Header + 2 admin rows
});
test('form should show validation errors and submit', async ({ page }) => {
await page.goto('/profile/edit');
// Submit empty form
await page.getByRole('button', { name: /save/i }).click();
await expect(page.getByText(/at least 3 characters/i)).toBeVisible();
// Fill valid data
await page.getByLabel(/username/i).fill('testuser');
await page.getByLabel(/email/i).fill('test@example.com');
await page.getByRole('button', { name: /save/i }).click();
await expect(page.getByText(/saved successfully/i)).toBeVisible();
});
});
```
## Best Practices
1. **Use `getByRole` over `getByTestId`** -- Role queries test accessibility for free; test IDs skip it.
2. **Always use `userEvent` over `fireEvent`** -- `userEvent` simulates real browser interactions including focus.
3. **Wrap state changes in `waitFor`** -- Radix components use animations; state changes may be async.
4. **Test portal-rendered content with `screen`** -- Dialogs and popovers render outside the component tree.
5. **Run axe audits on interactive states** -- Test accessibility of both closed and open states.
6. **Mock `next-themes` for predictable theme testing** -- Avoid relying on browser/OS theme preferences.
7. **Test disabled states explicitly** -- Verify that disabled buttons, inputs, and menu items are not interactive.
8. **Use `within()` for scoped queries** -- When testing tables or lists, scope queries to a specific row or item.
9. **Test the complete form lifecycle** -- Empty submit, validation errors, correction, successful submit.
10. **Keep component tests focused** -- Test one component behavior per test; compose for integration tests.
## Anti-Patterns to Avoid
1. **Testing Radix internal state** -- Do not assert on `data-state` attributes; test visible behavior instead.
2. **Using CSS selectors for queries** -- `querySelector('.shadcn-button')` is fragile; use ARIA roles.
3. **Forgetting to wait for animations** -- Radix uses enter/exit animations; wrap assertions in `waitFor`.
4. **Testing styled-component class names** -- Tailwind classes are implementation details; test visual output.
5. **Skipping keyboard interaction tests** -- Many users rely on keyboard navigation; it must work.
6. **Mocking Radix primitives** -- Never mock the component library; test the real components.
7. **Testing only the open state** -- Verify that closed/collapsed states also render correctly.
8. **Ignoring focus management** -- After a dialog closes, focus should return to the trigger element.
9. **Not testing with screen readers** -- Run automated ARIA checks and manual VoiceOver/NVDA testing.
10. **Hardcoding animation durations in tests** -- Use `waitFor` instead of `setTimeout` for animation timing.
## Running Tests
```bash
# Run all component tests
npx vitest run src/components/__tests__/
# Run a specific component test
npx vitest run src/components/__tests__/dialog.test.tsx
# Run with coverage
npx vitest run src/components/__tests__/ --coverage
# Watch mode for development
npx vitest watch src/components/__tests__/
# Run E2E tests
npx playwright test e2e/components.spec.ts
# Run E2E with UI mode
npx playwright test --ui
# Run accessibility audit
npx vitest run src/components/__tests__/ --reporter=verbose
# Debug a failing test
npx vitest run src/components/__tests__/form.test.tsx --reporter=verbose
```