by thetestingacademy
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.
npx @qaskills/cli add shadcn-component-testingAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
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.
getByRole, getByLabelText, and getByText over CSS selectors to ensure ARIA compliance.screen queries, not container queries.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
// 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 (
<ThemeProvider attribute="class" defaultTheme={theme} enableSystem={false}>
{children}
<Toaster />
</ThemeProvider>
);
}
export function renderWithProviders(
ui: ReactElement,
options?: RenderOptions & TestProviderOptions
) {
const { theme, ...renderOptions } = options || {};
return render(ui, {
wrapper: ({ children }) => (
<TestProviders theme={theme}>{children}</TestProviders>
),
...renderOptions,
});
}
export * from '@testing-library/react';
export { renderWithProviders as render };
// 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));
}
// 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 (
<Dialog>
<DialogTrigger asChild>
<Button>Delete Item</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the item.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
</DialogClose>
<Button variant="destructive" onClick={onConfirm}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
describe('Dialog', () => {
it('should open when trigger is clicked', async () => {
const user = userEvent.setup();
render(<ConfirmDialog onConfirm={vi.fn()} />);
// 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(<ConfirmDialog onConfirm={vi.fn()} />);
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(<ConfirmDialog onConfirm={vi.fn()} />);
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(<ConfirmDialog onConfirm={onConfirm} />);
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(<ConfirmDialog onConfirm={vi.fn()} onCancel={onCancel} />);
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(<ConfirmDialog onConfirm={vi.fn()} />);
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(<ConfirmDialog onConfirm={vi.fn()} />);
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(<ConfirmDialog onConfirm={vi.fn()} />);
await user.click(screen.getByRole('button', { name: /delete item/i }));
await expectNoA11yViolations(container);
});
});
// 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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost">Profile</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>Billing</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout}>Log out</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
describe('DropdownMenu', () => {
it('should open on click and show menu items', async () => {
const user = userEvent.setup();
render(<UserMenu onLogout={vi.fn()} />);
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(<UserMenu onLogout={vi.fn()} />);
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(<UserMenu onLogout={onLogout} />);
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(<UserMenu onLogout={vi.fn()} />);
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(<UserMenu onLogout={onLogout} />);
await user.click(screen.getByRole('button', { name: /profile/i }));
await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}{Enter}');
expect(onLogout).toHaveBeenCalledTimes(1);
});
});
// 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 (
<Select value={value} onValueChange={onChange}>
<SelectTrigger aria-label="Priority">
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Priority</SelectLabel>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="critical" disabled>
Critical
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
);
}
describe('Select', () => {
it('should show placeholder when no value selected', () => {
render(<PrioritySelect onChange={vi.fn()} />);
expect(screen.getByText('Select priority')).toBeInTheDocument();
});
it('should open options on click', async () => {
const user = userEvent.setup();
render(<PrioritySelect onChange={vi.fn()} />);
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(<PrioritySelect onChange={onChange} />);
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(<PrioritySelect onChange={onChange} />);
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(<PrioritySelect value="medium" onChange={vi.fn()} />);
expect(screen.getByText('Medium')).toBeInTheDocument();
});
it('should support keyboard navigation', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<PrioritySelect onChange={onChange} />);
// 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();
});
});
// 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<typeof profileSchema>;
function ProfileForm({ onSubmit }: { onSubmit: (data: ProfileFormValues) => void }) {
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
defaultValues: { username: '', email: '', bio: '' },
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} data-testid="profile-form">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="johndoe" {...field} />
</FormControl>
<FormDescription>Your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="john@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Input placeholder="Tell us about yourself" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Save</Button>
</form>
</Form>
);
}
describe('ProfileForm', () => {
it('should render all form fields', () => {
render(<ProfileForm onSubmit={vi.fn()} />);
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(<ProfileForm onSubmit={vi.fn()} />);
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(<ProfileForm onSubmit={vi.fn()} />);
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(<ProfileForm onSubmit={vi.fn()} />);
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(<ProfileForm onSubmit={vi.fn()} />);
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(<ProfileForm onSubmit={onSubmit} />);
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(<ProfileForm onSubmit={vi.fn()} />);
// 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();
});
});
});
// 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(<DataTable columns={columns} data={mockData} />);
const rows = screen.getAllByRole('row');
// Header row + 5 data rows
expect(rows).toHaveLength(6);
});
it('should render column headers', () => {
render(<DataTable columns={columns} data={mockData} />);
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(<DataTable columns={columns} data={mockData} />);
// 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(<DataTable columns={columns} data={mockData} searchColumn="name" />);
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(<DataTable columns={columns} data={mockData} searchColumn="name" />);
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(
<DataTable
columns={columns}
data={mockData}
enableSelection
onSelectionChange={onSelectionChange}
/>
);
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(<DataTable columns={columns} data={largeData} pageSize={10} />);
// 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);
});
});
// 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 (
<CommandDialog open={open} onOpenChange={onOpenChange}>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Pages">
<CommandItem onSelect={() => onSelect('/dashboard')}>Dashboard</CommandItem>
<CommandItem onSelect={() => onSelect('/settings')}>Settings</CommandItem>
<CommandItem onSelect={() => onSelect('/profile')}>Profile</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Actions">
<CommandItem onSelect={() => onSelect('new-project')}>New Project</CommandItem>
<CommandItem onSelect={() => onSelect('new-team')}>New Team</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
);
}
describe('Command Palette', () => {
it('should render when open', () => {
render(
<AppCommandPalette open={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
);
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(
<AppCommandPalette open={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
);
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(
<AppCommandPalette open={true} onOpenChange={vi.fn()} onSelect={vi.fn()} />
);
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(
<AppCommandPalette open={true} onOpenChange={vi.fn()} onSelect={onSelect} />
);
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(
<AppCommandPalette open={true} onOpenChange={vi.fn()} onSelect={onSelect} />
);
const input = screen.getByPlaceholderText(/type a command/i);
await user.click(input);
await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
expect(onSelect).toHaveBeenCalled();
});
});
// 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 (
<Button
variant="outline"
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? 'Dark Mode' : 'Light Mode'}
</Button>
);
}
describe('Theme Switching', () => {
it('should render with light theme by default', () => {
render(<ThemeToggle />, { theme: 'light' });
expect(screen.getByRole('button', { name: /switch to dark/i })).toBeInTheDocument();
});
it('should render with dark theme when configured', () => {
render(<ThemeToggle />, { 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(<ThemeToggle />, { 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(<ThemeToggle />, { theme: 'light' });
await user.click(screen.getByRole('button', { name: /switch to dark/i }));
await waitFor(() => {
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
});
});
// 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 (
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger>What is shadcn/ui?</AccordionTrigger>
<AccordionContent>
A collection of reusable components built with Radix UI and Tailwind CSS.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>
Yes, it follows WAI-ARIA design patterns.
</AccordionContent>
</AccordionItem>
</Accordion>
);
}
describe('Accordion', () => {
it('should render all triggers', () => {
render(<FAQ />);
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(<FAQ />);
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(<FAQ />);
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(<FAQ />);
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(<FAQ />);
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/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();
});
});
getByRole over getByTestId -- Role queries test accessibility for free; test IDs skip it.userEvent over fireEvent -- userEvent simulates real browser interactions including focus.waitFor -- Radix components use animations; state changes may be async.screen -- Dialogs and popovers render outside the component tree.next-themes for predictable theme testing -- Avoid relying on browser/OS theme preferences.within() for scoped queries -- When testing tables or lists, scope queries to a specific row or item.data-state attributes; test visible behavior instead.querySelector('.shadcn-button') is fragile; use ARIA roles.waitFor.waitFor instead of setTimeout for animation timing.# 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
- name: Install QA Skills
run: npx @qaskills/cli add shadcn-component-testing12 of 29 agents supported