seed-skills/shadcn-component-testing/SKILL.md
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 skillsauth add PramodDutta/qaskills shadcn/ui Component TestingInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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="[email protected]" {...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), '[email protected]');
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), '[email protected]');
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), '[email protected]');
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: '[email protected]', 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), '[email protected]');
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: '[email protected]', role: 'admin', status: 'active' },
{ id: '2', name: 'Bob', email: '[email protected]', role: 'user', status: 'active' },
{ id: '3', name: 'Charlie', email: '[email protected]', role: 'user', status: 'inactive' },
{ id: '4', name: 'Diana', email: '[email protected]', role: 'admin', status: 'active' },
{ id: '5', name: 'Eve', email: '[email protected]', 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('[email protected]');
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
development
Generate test data from the schemas you already have. Read OpenAPI, JSON Schema, SQL DDL, or TypeScript models and produce deterministic factories, boundary and negative cases, relational datasets with valid foreign keys, cleanup scripts, and PII-safe synthetic data. Production records never leave the machine.
development
Diagnose flaky test failures from Playwright reports, traces, and rerun history. Classify each failure as product, test, environment, data, or unknown with cited evidence and a proposed fix. Never auto-modifies code without opt-in.
tools
Test LLM, RAG, MCP, and agentic systems end to end. Build golden datasets, run deterministic checks and LLM judges, score retrieval, probe prompt injection, verify tool use, and gate CI on thresholds. Orchestrates DeepEval, Ragas, promptfoo, and Langfuse.
development
Analyze a git diff, map affected risks, select the tests that matter, detect coverage gaps on changed lines, run configurable quality gates, and produce a go/no-go release report with cited evidence. Recommends only; never merges or deploys.